Skip to content

9.8 - API Testing ​

Tóm tắt: WebApplicationFactory dựng toàn bộ ứng dụng trong bộ nhớ và cho bạn một HttpClient gọi vào nó — không mở cổng, không cần chạy server, nhưng đi qua đầy đủ pipeline: routing, model binding, validation, filter, middleware. Đó là điểm ngọt giữa unit test và test end-to-end. Cạm bẫy lớn nhất là UseInMemoryDatabase: nó không phải database quan hệ, nó không có ràng buộc, không có transaction thật, không dịch SQL, nên test xanh trong khi production đỏ. Nếu code chạm tới database, hãy test trên database thật bằng Testcontainers.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Dựng WebApplicationFactory và thay thế service cho môi trường test.
  • Liệt kê những gì InMemory provider không phát hiện được.
  • Chạy test trên database thật bằng Testcontainers.
  • Cô lập dữ liệu giữa các test.
  • Bỏ qua xác thực trong test mà không tắt phân quyền.

Nội dung bài học ​

9.8.1 — Ba tầng test ​

TầngTest cái gìTốc độSố lượng
UnitMột lớp, mọi phụ thuộc là mockMili giâyNhiều
IntegrationAPI qua WebApplicationFactoryChục–trăm mili giâyVừa
End-to-endHệ thống thật đã triển khaiGiâyÍt

Integration test là tầng đáng đầu tư nhất cho một Web API, vì nó bắt được đúng những lỗi mà unit test bỏ sót: sai route, binding không như mong đợi, validation không chạy, thứ tự middleware sai, DI thiếu đăng ký, serialization JSON khác dự kiến.

9.8.2 — WebApplicationFactory ​

csharp
public sealed class CrmApiFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");

        builder.ConfigureTestServices(services =>
        {
            // Thay dịch vụ bên ngoài bằng fake
            services.RemoveAll<IEmailService>();
            services.AddSingleton<IEmailService, FakeEmailService>();

            services.RemoveAll<IPaymentGateway>();
            services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
        });
    }
}

ConfigureTestServices chạy sau mọi đăng ký của Program.cs, nên nó đè lên được. ConfigureServices chạy trước và sẽ bị Program.cs ghi đè — một lỗi tốn nhiều thời gian để phát hiện.

RemoveAll<T>() sạch hơn tìm ServiceDescriptor thủ công, nhất là khi một interface được đăng ký nhiều lần.

Nếu Program.cs dùng top-level statements, thêm dòng này vào cuối để lớp Program hiển thị với test project:

csharp
public partial class Program;

9.8.3 — UseInMemoryDatabase nói dối ​

Đây là lời khuyên phổ biến nhất và cũng gây hại nhất trong testing .NET:

csharp
services.AddDbContext<CrmDbContext>(o => o.UseInMemoryDatabase("Test"));

InMemory provider không phải database quan hệ. Nó là một Dictionary biết LINQ. Những gì nó không phát hiện:

Không phát hiệnHậu quả trên production
Ràng buộc uniqueTrùng email lọt qua test, nổ khi chạy thật
Khoá ngoạiXoá bản ghi còn tham chiếu vẫn qua
NOT NULL, độ dài cộtChuỗi 500 ký tự vào cột nvarchar(200)
Truy vấn không dịch được sang SQLTest xanh, production ném InvalidOperationException
Phân biệt hoa thườngContains phân biệt hoa thường trong test, không phân biệt trong SQL Server
Transaction và concurrency thậtOptimistic concurrency không bao giờ được test
Kiểu dữ liệu, độ chính xác decimalLàm tròn khác nhau

Dòng thứ tư nguy hiểm nhất: EF Core dịch khác nhau cho mỗi provider. Một truy vấn chạy tốt với InMemory có thể không dịch được sang SQL và ném exception lúc chạy — đúng thứ mà test lẽ ra phải bắt.

Chính tài liệu EF Core cũng khuyến cáo không dùng InMemory để test. Dùng nó chỉ cho những test không chạm database thật sự, ví dụ kiểm tra validation của controller.

9.8.4 — Testcontainers: database thật ​

bash
dotnet add package Testcontainers.MsSql
dotnet add package Respawn
csharp
public sealed class CrmApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly MsSqlContainer _db = new MsSqlBuilder()
        .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
        .Build();

    public async Task InitializeAsync()
    {
        await _db.StartAsync();

        using var scope = Services.CreateScope();
        var ctx = scope.ServiceProvider.GetRequiredService<CrmDbContext>();
        await ctx.Database.MigrateAsync();          // chay migration THAT
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<DbContextOptions<CrmDbContext>>();
            services.AddDbContext<CrmDbContext>(o =>
                o.UseSqlServer(_db.GetConnectionString()));
        });
    }

    public new async Task DisposeAsync() => await _db.DisposeAsync();
}

Testcontainers khởi động một container SQL Server thật, chạy migration thật, và huỷ nó khi xong. Chi phí: vài giây cho lần khởi động đầu tiên.

Lợi ích vượt xa chi phí đó: migration được test, ràng buộc được test, truy vấn được dịch thật sự. Một migration lỗi bị phát hiện trong CI thay vì lúc deploy.

Chia sẻ container cho cả một tập test bằng ICollectionFixture để không khởi động lại mỗi lớp test:

csharp
[CollectionDefinition("api")]
public sealed class ApiCollection : ICollectionFixture<CrmApiFactory>;

[Collection("api")]
public sealed class CustomerApiTests(CrmApiFactory factory) { }

9.8.5 — Cô lập dữ liệu giữa các test ​

Test dùng chung một database sẽ ảnh hưởng lẫn nhau: một test tạo 5 khách hàng, test đếm ở lớp khác thất bại — và chỉ thất bại khi chạy cùng nhau, không khi chạy riêng.

Respawn đặt lại database về trạng thái sạch giữa các test:

csharp
public sealed class CustomerApiTests(CrmApiFactory factory)
    : IClassFixture<CrmApiFactory>, IAsyncLifetime
{
    private readonly HttpClient _client = factory.CreateClient();

    public async Task InitializeAsync() => await factory.ResetDatabaseAsync();
    public Task DisposeAsync() => Task.CompletedTask;

    [Fact]
    public async Task Create_WithDuplicateEmail_Returns409()
    {
        var request = new CreateCustomerRequest
            { Name = "Nguyen Van A", Email = "[email protected]" };

        var first  = await _client.PostAsJsonAsync("/api/v1/customers", request);
        var second = await _client.PostAsJsonAsync("/api/v1/customers", request);

        Assert.Equal(HttpStatusCode.Created, first.StatusCode);
        Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
    }
}

Bài test này là ví dụ đúng cho lý do dùng database thật: với InMemory, cả hai request đều trả 201 vì không có ràng buộc unique — test xanh, production hỏng.

Hai cách cô lập khác: mỗi test một transaction rồi rollback (nhanh hơn nhưng không test được code tự quản transaction), hoặc mỗi test một database riêng (sạch nhất nhưng chậm).

9.8.6 — Xác thực trong test ​

Đừng tắt [Authorize] — nếu tắt, bạn không test được phân quyền, và đó chính là thứ cần test nhất.

Thay vào đó, thêm một authentication scheme cho môi trường test:

csharp
public sealed class TestAuthHandler(
    IOptionsMonitor<AuthenticationSchemeOptions> options,
    ILoggerFactory logger, UrlEncoder encoder)
    : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // Đọc vai trò từ header để mỗi bài kiểm thử tự chọn danh tính
        var roles = Context.Request.Headers["X-Test-Roles"].ToString()
            .Split(',', StringSplitOptions.RemoveEmptyEntries);

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, "test-user-id"),
            new(ClaimTypes.Name, "[email protected]")
        };
        claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));

        var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
        return Task.FromResult(AuthenticateResult.Success(
            new AuthenticationTicket(principal, "Test")));
    }
}
csharp
builder.ConfigureTestServices(services =>
{
    services.AddAuthentication("Test")
        .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
});
csharp
_client.DefaultRequestHeaders.Add("X-Test-Roles", "Admin");
var response = await _client.DeleteAsync($"/api/v1/customers/{id}");
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);

_client.DefaultRequestHeaders.Remove("X-Test-Roles");
var forbidden = await _client.DeleteAsync($"/api/v1/customers/{id}");
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);

Phân quyền vẫn chạy đầy đủ; chỉ phần lấy danh tính bị thay. Nhờ đó bạn test được cả đường thành công lẫn đường bị từ chối.

Ghép lại, một bài kiểm thử tích hợp chạy qua những thành phần sau:

WARNING

Đừng để lọt sang production

TestAuthHandler chỉ được đăng ký trong ConfigureTestServices của test project. Đừng bao giờ đặt nó trong Program.cs sau một câu if về môi trường — một biến môi trường đặt nhầm là một API không xác thực.

9.8.7 — Test những gì unit test không thấy ​

csharp
[Fact]
public async Task Create_WithInvalidEmail_Returns400WithProblemDetails()
{
    var response = await _client.PostAsJsonAsync("/api/v1/customers",
        new { name = "Test", email = "not-an-email" });

    Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

    var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
    Assert.Contains("email", problem!.Errors.Keys, StringComparer.OrdinalIgnoreCase);
}

[Fact]
public async Task GetAll_WithOversizedPageSize_ClampsToMaximum()
{
    var response = await _client.GetAsync("/api/v1/customers?pageSize=999999");
    var result = await response.Content.ReadFromJsonAsync<PagedResponse<CustomerDto>>();

    Assert.True(result!.Pagination.PageSize <= 100);
}

[Fact]
public async Task Create_ReturnsLocationHeader()
{
    var response = await _client.PostAsJsonAsync("/api/v1/customers", ValidRequest);

    Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    Assert.NotNull(response.Headers.Location);

    var follow = await _client.GetAsync(response.Headers.Location);
    Assert.Equal(HttpStatusCode.OK, follow.StatusCode);      // URL trả về phải dùng được
}

Bài test thứ ba đáng chú ý: nó kiểm tra rằng URL trong header Location thật sự gọi được. Đây là lỗi hay gặp khi đổi route mà quên sửa CreatedAtRoute, và không unit test nào bắt được.

9.8.8 — Rà lại code của bạn ​

Danh sách rà soát API testing

  • Dùng ConfigureTestServices, không phải ConfigureServices.
  • Không dùng UseInMemoryDatabase cho test chạm tới database.
  • Test database dùng Testcontainers với cùng loại database như production.
  • Migration được chạy trong test, không phải EnsureCreated.
  • Dữ liệu được đặt lại giữa các test để chúng không ảnh hưởng nhau.
  • Container được chia sẻ qua ICollectionFixture để không khởi động lại mỗi lớp.
  • [Authorize] không bị tắt; dùng authentication scheme riêng cho test.
  • TestAuthHandler không xuất hiện ở đâu trong Program.cs.
  • Có test cho cả đường bị từ chối, không chỉ đường thành công.
  • Có test kiểm tra header Location của 201 thật sự gọi được.

Bài tập áp dụng ​

Bài 1 — InMemoryDatabase nói dối ​

Viết một bài kiểm thử cho ràng buộc duy nhất trên email. Chạy với UseInMemoryDatabase và xác nhận nó xanh dù code sai. Chuyển sang Testcontainers và xác nhận nó bắt được lỗi.

Tiêu chí hoàn thành: bạn liệt kê được ít nhất bốn thứ mà InMemoryDatabase không mô phỏng.

Gợi ý và lời giải — Bài 1

Gợi ý. InMemoryDatabase không phải một database quan hệ — nó là một kho đối tượng trong bộ nhớ có API giống EF Core. Vậy nó có biết gì về ràng buộc, khoá, hay SQL không?

Lời giải — bài kiểm thử:

csharp
[Fact]
public async Task DuplicateEmail_IsRejected()
{
    using var db = CreateContext();

    db.Customers.Add(new Customer("An",  new Email("[email protected]")));
    db.Customers.Add(new Customer("Bình", new Email("[email protected]")));   // TRÙNG

    await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
}

Với InMemoryDatabase:

csharp
var opt = new DbContextOptionsBuilder<CrmDbContext>()
    .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
text
Assert.ThrowsAsync() Failure: No exception was thrown

Bài kiểm thử đỏ vì lý do sai: SaveChangesAsync thành công, hai bản ghi trùng email được lưu. Nếu bạn viết bài kiểm thử theo chiều ngược lại — khẳng định lưu thành công — nó sẽ xanh dù ràng buộc duy nhất chưa hề tồn tại trong database thật.

Với Testcontainers:

csharp
public class DatabaseFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .Build();

    public string ConnectionString => _db.GetConnectionString();

    public async Task InitializeAsync()
    {
        await _db.StartAsync();
        using var ctx = new CrmDbContext(Options());
        await ctx.Database.MigrateAsync();       // chạy migration THẬT
    }

    public Task DisposeAsync() => _db.DisposeAsync().AsTask();
}
text
DbUpdateException: 23505: duplicate key value violates unique constraint
"IX_Customers_Email"

Bài kiểm thử bắt được đúng vấn đề.

Bốn thứ InMemoryDatabase không mô phỏng:

Không cóHậu quả
Ràng buộc duy nhất và khoá ngoạiDữ liệu trùng và mồ côi lọt qua kiểm thử
Giao dịch thậtKhông kiểm chứng được tính nguyên tử ở bài 2.6
SQL thậtTruy vấn không dịch được vẫn "chạy", nên lỗi chỉ lộ ra trên production
Kiểu dữ liệu và độ chính xácdecimal(18,2) không cắt bớt, chuỗi quá dài không bị từ chối

Thêm hai thứ nữa: không có so sánh chuỗi theo quy tắc của database — nên Where(x => x.Ten == "AN") có thể cho kết quả khác giữa kiểm thử và thực tế — và không có khoá mức dòng, nên không kiểm chứng được xử lý đồng thời.

Điểm nguy hiểm nhất là dòng thứ ba. Từ EF Core 3.0, truy vấn không dịch được sang SQL sẽ ném ngoại lệ với nhà cung cấp thật. Với InMemoryDatabase, mọi thứ chạy trong bộ nhớ nên không bao giờ ném — bài kiểm thử xanh, production hỏng.

Khi nào InMemoryDatabase vẫn dùng được. Khi bài kiểm thử không quan tâm tới database, chỉ cần một chỗ chứa tạm:

csharp
// Kiểm thử logic tính hoa hồng — database chỉ là nơi lấy dữ liệu
var db = CreateInMemory();
db.Orders.AddRange(CreateSampleData());
await db.SaveChangesAsync();

var result = await new CommissionService(db).CalculateAsync(42, default);
Assert.Equal(1_250_000m, result);

Nhưng ngay cả ở đây, SQLite trong bộ nhớ thường là lựa chọn tốt hơn: nó là database quan hệ thật, có ràng buộc và giao dịch, và vẫn chạy trong vài mili-giây.

csharp
var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();                                   // giữ mở, đóng là mất dữ liệu
var opt = new DbContextOptionsBuilder<CrmDbContext>().UseSqlite(conn).Options;

Bảng chọn:

Mục tiêu kiểm thửDùng
Logic nghiệp vụ thuầnBản giả repository, không cần database
Logic có dùng dữ liệu, không quan tâm SQLSQLite trong bộ nhớ
Truy vấn, migration, ràng buộc, giao dịchTestcontainers
Toàn bộ đường ống HTTPWebApplicationFactory + Testcontainers

Microsoft cũng khuyến nghị không dùng InMemoryDatabase cho kiểm thử — nó tồn tại từ trước khi Testcontainers phổ biến, và tài liệu chính thức nay khuyên dùng database thật.

Bài 2 — Bài kiểm thử ảnh hưởng lẫn nhau ​

Viết hai lớp kiểm thử cùng tạo dữ liệu và một bài đếm tổng số bản ghi. Chạy riêng từng lớp (xanh) rồi chạy cùng nhau (đỏ). Thêm cơ chế dọn dữ liệu và kiểm chứng lại.

Tiêu chí hoàn thành: bạn nêu được vì sao loại lỗi này khó chẩn đoán hơn lỗi thông thường.

Gợi ý và lời giải — Bài 2

Gợi ý. xUnit chạy các lớp kiểm thử song song theo mặc định. Nếu chúng dùng chung một database, chúng dùng chung trạng thái.

Lời giải.

csharp
public class CustomerTests(DatabaseFixture fx) : IClassFixture<DatabaseFixture>
{
    [Fact]
    public async Task CreateCustomer()
    {
        using var db = fx.CreateContext();
        db.Customers.Add(new Customer("An", new Email("[email protected]")));
        await db.SaveChangesAsync();
        Assert.Equal(1, await db.Customers.CountAsync());      // giả định database trống
    }
}

public class OrderTests(DatabaseFixture fx) : IClassFixture<DatabaseFixture>
{
    [Fact]
    public async Task CreateOrder()
    {
        using var db = fx.CreateContext();
        db.Customers.Add(new Customer("Bình", new Email("[email protected]")));
        await db.SaveChangesAsync();
        Assert.Equal(1, await db.Customers.CountAsync());      // cũng giả định vậy
    }
}
bash
dotnet test --filter "FullyQualifiedName~CustomerTests"   # XANH
dotnet test --filter "FullyQualifiedName~OrderTests"      # XANH
dotnet test                                                # ĐỎ
text
Assert.Equal() Failure: Values differ
Expected: 1
Actual:   2

Vì sao khó chẩn đoán hơn lỗi thông thường — bốn lý do:

  1. Chạy riêng thì xanh. Phản xạ đầu tiên của lập trình viên là chạy lại đúng bài kiểm thử đỏ — và nó xanh. Kết luận sai: "chắc lỗi vặt".
  2. Kết quả không ổn định. Thứ tự chạy song song thay đổi giữa các lần, nên có lần đỏ có lần xanh.
  3. Bài kiểm thử báo lỗi không phải bài có vấn đề. OrderTests đỏ vì CustomerTests chèn dữ liệu. Người điều tra đi sửa nhầm chỗ.
  4. Máy nhanh và máy chậm cho kết quả khác nhau. Nó thường xanh trên máy cá nhân và đỏ trên CI, dẫn tới kết luận sai là "CI có vấn đề".

Hậu quả cuối cùng và tệ nhất: bộ kiểm thử mất uy tín. Khi một bài đỏ, người ta chạy lại thay vì điều tra — và một ngày nào đó nó đỏ vì lỗi thật mà không ai để ý.

Ba cách sửa, theo thứ tự nên chọn:

Cách 1 — dọn database giữa các bài kiểm thử:

csharp
public class DatabaseFixture : IAsyncLifetime
{
    private Respawner _respawner = null!;

    public async Task InitializeAsync()
    {
        await _db.StartAsync();
        await RunMigrationsAsync();

        _respawner = await Respawner.CreateAsync(ConnectionString, new RespawnerOptions
        {
            DbAdapter = DbAdapter.Postgres,
            TablesToIgnore = ["__EFMigrationsHistory"]
        });
    }

    public Task CleanAsync() => _respawner.ResetAsync(ConnectionString);
}

Gọi CleanAsync() trước mỗi bài. Respawner xoá dữ liệu mà giữ nguyên lược đồ, nên nhanh hơn nhiều so với tạo lại database.

Cách 2 — mỗi bài kiểm thử một giao dịch, quay lui ở cuối:

csharp
public async Task InitializeAsync()
{
    _tx = await _db.Database.BeginTransactionAsync();
}
public async Task DisposeAsync()
{
    await _tx.RollbackAsync();          // mọi thay đổi biến mất
}

Nhanh nhất, nhưng không dùng được khi bài kiểm thử tự quản lý giao dịch, hoặc khi đi qua WebApplicationFactory (mỗi request là một phạm vi khác).

Cách 3 — mỗi lớp kiểm thử một database riêng:

csharp
[CollectionDefinition("Database", DisableParallelization = true)]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }

Đơn giản nhất nhưng chậm nhất, vì mất hết tính song song.

Nguyên tắc: mỗi bài kiểm thử phải tự dựng đủ dữ liệu nó cần và không giả định gì về trạng thái ban đầu.

Viết lại bài kiểm thử ở trên cho đúng:

csharp
[Fact]
public async Task CreateCustomer()
{
    await fx.CleanAsync();
    using var db = fx.CreateContext();
    db.Customers.Add(new Customer("An", new Email("[email protected]")));
    await db.SaveChangesAsync();

    // Khẳng định theo dữ liệu MÌNH tạo, không theo tổng số
    Assert.NotNull(await db.Customers.FirstOrDefaultAsync(c => c.Email.Value == "[email protected]"));
}

Chú ý khẳng định đã đổi: thay vì đếm tổng — vốn phụ thuộc vào trạng thái chung — nó kiểm tra đúng bản ghi mình vừa tạo. Đây là thói quen nên áp dụng kể cả khi đã có cơ chế dọn dữ liệu.

Bài 3 — Header Location sai mà unit test không bắt được ​

Đổi tên route của GetById mà không sửa CreatedAtRoute, và xác nhận bài kiểm thử theo header Location bắt được lỗi trong khi unit test của controller vẫn xanh.

Tiêu chí hoàn thành: bạn nêu được lớp lỗi mà chỉ kiểm thử tích hợp bắt được.

Gợi ý và lời giải — Bài 3

Gợi ý. Unit test gọi thẳng phương thức của controller và nhận về một IActionResult. Nó có biết gì về bảng định tuyến không?

Lời giải — code có lỗi:

csharp
[HttpGet("{id:int}", Name = "GetCustomerById")]
public async Task<IActionResult> TheoId(int id) { }

[HttpPost]
public async Task<IActionResult> Create(CreateCustomerRequest req, CancellationToken ct)
{
    var customer = await _svc.CreateAsync(req, ct);
    return CreatedAtRoute("GetCustomerById", new { id = customer.Id }, customer);
    //                     ^^^^^^^^^^^^^^^ tên route KHÔNG TỒN TẠI
}

Unit test vẫn xanh:

csharp
[Fact]
public async Task Create_Returns201()
{
    var ctrl = new CustomersController(_svcGia);
    var result = await ctrl.Create(new CreateCustomerRequest("An", "[email protected]"), default);

    var created = Assert.IsType<CreatedAtRouteResult>(result);
    Assert.Equal(201, created.StatusCode);      // XANH
}

CreatedAtRouteResult chỉ là một đối tượng mô tả ý định. Nó chưa hề sinh URL, nên tên route sai không gây vấn đề gì ở tầng này.

Kiểm thử tích hợp bắt được:

csharp
[Fact]
public async Task Create_ReturnsValidLocation()
{
    var res = await _client.PostAsJsonAsync("/api/v1/customers",
        new { name = "An", email = "[email protected]" });

    Assert.Equal(HttpStatusCode.Created, res.StatusCode);
    Assert.NotNull(res.Headers.Location);                     // ĐỎ

    // Và quan trọng hơn: địa chỉ đó phải gọi được
    var kiemTra = await _client.GetAsync(res.Headers.Location);
    Assert.Equal(HttpStatusCode.OK, kiemTra.StatusCode);
}
text
InvalidOperationException: No route matches the supplied values.

Vì phải sinh URL thật, lỗi lộ ra ngay.

Lớp lỗi chỉ kiểm thử tích hợp bắt được:

Loại lỗiUnit testTích hợp
Tên route sai trong CreatedAtRouteKhôngCó
Thứ tự middleware sai — bài 7.6KhôngCó
Route nhập nhằng — bài 8.3KhôngCó
Đăng ký DI thiếuKhôngCó
Gắn dữ liệu không như mong đợiKhôngCó
Cấu hình JSON, quy ước đặt tên trườngKhôngCó
Xác thực và phân quyềnKhôngCó
Migration chưa chạy đượcKhôngCó

Điểm chung của tám dòng trên: chúng là lỗi ở chỗ ghép nối, không ở bên trong một lớp. Unit test kiểm tra từng mảnh; chỉ kiểm thử tích hợp kiểm tra chúng lắp vào nhau.

Không có nghĩa là bỏ unit test. Hai loại có vai trò khác nhau:

Unit testKiểm thử tích hợp
Kiểm traLogic bên trong một lớpCác mảnh ghép với nhau
Tốc độVài mili-giâyVài trăm mili-giây
Số lượng nên cóNhiềuVừa đủ luồng chính
Khi đỏ, biết sai ở đâuChính xácPhải điều tra

Tỉ lệ thực dụng: unit test bao phủ mọi nhánh nghiệp vụ; kiểm thử tích hợp bao phủ một luồng thành công và một luồng lỗi cho mỗi endpoint.

Một kiểm thử tích hợp đáng có cho mọi API:

csharp
[Fact]
public async Task MoiEndpointDeuKhoiDongDuoc()
{
    var nguon = _factory.Services.GetRequiredService<EndpointDataSource>();
    Assert.NotEmpty(nguon.Endpoints);
}

Nó xác nhận ứng dụng dựng được toàn bộ đồ thị DI và bảng định tuyến — bắt được lớp lỗi "khởi động không lên" trong CI thay vì lúc triển khai.

Tự kiểm tra ​

WebApplicationFactory cho bạn điều gì?

Nó dựng toàn bộ ứng dụng trong bộ nhớ và cho một HttpClient gọi vào, không mở cổng nhưng đi qua đầy đủ pipeline gồm routing, model binding, validation, filter và middleware. Đó là điểm ngọt giữa unit test và test end-to-end, bắt được đúng những lỗi unit test bỏ sót.

ConfigureTestServices khác ConfigureServices thế nào?

ConfigureTestServices chạy sau mọi đăng ký của Program.cs nên nó đè lên được. ConfigureServices chạy trước và sẽ bị Program.cs ghi đè, đây là lỗi tốn nhiều thời gian để phát hiện vì dịch vụ thật vẫn được dùng dù bạn nghĩ đã thay bằng fake.

Vì sao không nên dùng UseInMemoryDatabase?

Vì nó không phải database quan hệ mà là một Dictionary biết LINQ. Nó không có ràng buộc unique, không có khoá ngoại, không kiểm tra độ dài cột, không dịch SQL, và phân biệt hoa thường khác với SQL Server. Nguy hiểm nhất là truy vấn chạy tốt với nó có thể không dịch được sang SQL và ném exception trên production.

Testcontainers mang lại gì?

Nó khởi động một container database thật, chạy migration thật, và huỷ khi xong. Chi phí là vài giây khởi động, đổi lại migration được test, ràng buộc được test và truy vấn được dịch thật sự. Một migration lỗi bị phát hiện trong CI thay vì lúc deploy.

Vì sao phải cô lập dữ liệu giữa các test?

Vì test dùng chung database sẽ ảnh hưởng lẫn nhau, và kiểu lỗi đó chỉ xuất hiện khi chạy cùng nhau chứ không khi chạy riêng, rất khó chẩn đoán. Có thể dùng Respawn để đặt lại database, hoặc mỗi test một transaction rồi rollback, hoặc mỗi test một database riêng.

Xử lý xác thực trong integration test thế nào?

Đừng tắt Authorize, vì tắt là mất luôn khả năng test phân quyền. Thay vào đó đăng ký một authentication scheme riêng cho test, đọc vai trò từ một header để mỗi test tự chọn danh tính. Phân quyền vẫn chạy đầy đủ, chỉ phần lấy danh tính bị thay, nên test được cả đường thành công lẫn đường bị từ chối.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. Integration test là tầng đáng đầu tư nhất cho Web API — nó bắt lỗi route, binding, middleware và DI.
  2. InMemory provider làm test xanh trong khi production đỏ. Dùng Testcontainers.
  3. Đừng tắt [Authorize] — thay authentication scheme để test được cả đường bị từ chối.

Tham khảo ​

Điều hướng ​

Bài liên quan ​

Phát hành theo giấy phép MIT.