Testing Strategy trên .NET 10 — TestContainers, xUnit v3 và Mutation Testing cho Production
Posted on: 4/18/2026 4:12:39 AM
Table of contents
- 1. Testing Pyramid và chiến lược phân tầng trên .NET 10
- 2. Microsoft Testing Platform — Thay thế VSTest sau 10 năm
- 3. xUnit v3 — Những thay đổi quan trọng
- 4. TestContainers — Integration Test với dependency thật
- 5. WebApplicationFactory — Test API không cần deploy
- 6. .NET Aspire Testing — Test hệ thống phân tán
- 7. Stryker Mutation Testing — Đo chất lượng thực sự của test
- 8. Chiến lược Testing cho Production — Kết hợp tất cả
- 9. Các Anti-Pattern cần tránh
- 10. Kết luận
Viết code chạy đúng là một chuyện, nhưng đảm bảo code vẫn chạy đúng sau mỗi lần thay đổi mới là thứ quyết định sự sống còn của một sản phẩm. Với .NET 10 (phát hành tháng 11/2025), Microsoft đã nâng cấp đáng kể hạ tầng testing — từ Microsoft Testing Platform (MTP) thay thế VSTest, đến tích hợp sâu với .NET Aspire cho distributed testing. Kết hợp cùng TestContainers, xUnit v3, Stryker Mutation Testing và WebApplicationFactory, chúng ta có một bộ công cụ testing production-grade hoàn chỉnh.
1. Testing Pyramid và chiến lược phân tầng trên .NET 10
Trước khi đi sâu vào công cụ, cần hiểu rõ Testing Pyramid — mô hình phân tầng giúp cân bằng giữa tốc độ, chi phí và độ tin cậy của test suite.
graph TD
A["🔺 E2E Tests
Ít nhất · Chậm nhất · Tin cậy cao"] --> B["🔶 Integration Tests
Vừa phải · TestContainers + Aspire"]
B --> C["🟢 Unit Tests
Nhiều nhất · Nhanh nhất · Cô lập"]
style A fill:#e94560,stroke:#fff,color:#fff
style B fill:#ff9800,stroke:#fff,color:#fff
style C fill:#4CAF50,stroke:#fff,color:#fff
🟢 Unit Tests (70-80% test suite)
Test một đơn vị logic duy nhất — method, function, class — trong điều kiện cô lập hoàn toàn. Chạy nhanh (milliseconds), không phụ thuộc database hay network. Trên .NET 10, xUnit v3 là lựa chọn hàng đầu với hỗ trợ native cho Microsoft Testing Platform.
🔶 Integration Tests (15-25% test suite)
Kiểm tra sự tương tác giữa các component thực — API endpoint → database, service → message queue. TestContainers spin Docker container thật (SQL Server, Redis, RabbitMQ) ngay trong test, đảm bảo test chạy với dependency giống production.
🔺 E2E Tests (5-10% test suite)
Kiểm tra toàn bộ flow từ UI đến database. Chậm, dễ flaky, nhưng bắt được lỗi mà các tầng dưới bỏ sót. Chỉ nên cover golden path và critical business flows.
2. Microsoft Testing Platform — Thay thế VSTest sau 10 năm
Microsoft Testing Platform (MTP) là nền tảng testing mới, thay thế hoàn toàn VSTest vốn đã tồn tại từ .NET Framework. MTP được thiết kế lại từ đầu với mục tiêu: nhanh hơn, nhẹ hơn và extensible hơn.
| Tiêu chí | VSTest (cũ) | MTP (.NET 10) |
|---|---|---|
| Test Discovery | ~8 giây (3,500 tests) | ~2.5 giây — nhanh gấp 3x |
| Architecture | Out-of-process runner | In-process + Out-of-process |
| Extensibility | Adapter pattern phức tạp | Plugin-based, đơn giản |
| Output | TRX format | TRX + JUnit + Custom format |
| .NET 10 Default | Không còn nhận feature mới | Mặc định cho project mới |
| Parallel Execution | Assembly-level | Class-level + Method-level |
💡 Lưu ý khi upgrade
MTP v1 vẫn là default trong .NET 10 SDK để đảm bảo backward compatibility. Nếu muốn dùng MTP v2, cần opt-in qua <TestingPlatformVersion>2</TestingPlatformVersion> trong csproj. Tuy nhiên, MTP v2 không hỗ trợ VSTest — nếu CI/CD pipeline đang dùng dotnet test --logger trx, cần verify lại trước khi upgrade.
2.1. Cấu hình MTP trong project .NET 10
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.2.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
</Project>Với .NET 10, template project testing đã tích hợp sẵn Coverlet — không cần cài thêm package nào để chạy code coverage với --collect:"XPlat Code Coverage".
3. xUnit v3 — Những thay đổi quan trọng
xUnit v3 là bản viết lại lớn nhất kể từ khi xUnit ra đời, với hàng loạt cải tiến đáng chú ý cho .NET 10.
graph LR
A["xUnit v2"] -->|"Migration"| B["xUnit v3"]
B --> C["Native MTP Support"]
B --> D["Explicit Test Ordering"]
B --> E["Assembly-level Fixtures"]
B --> F["Retry trên Flaky Tests"]
B --> G["Improved Parallelism"]
style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style B fill:#e94560,stroke:#fff,color:#fff
style C fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
style D fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
style E fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
style F fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
style G fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
3.1. Viết Unit Test với xUnit v3
using Xunit;
public class OrderServiceTests
{
[Fact]
public void CalculateTotal_WithValidItems_ReturnsCorrectSum()
{
// Arrange
var service = new OrderService();
var items = new List<OrderItem>
{
new("Laptop", 25_000_000, 1),
new("Mouse", 500_000, 2)
};
// Act
var total = service.CalculateTotal(items);
// Assert
Assert.Equal(26_000_000, total);
}
[Theory]
[InlineData(0, 0)]
[InlineData(100, 10)] // 10% VAT
[InlineData(1000, 100)]
public void CalculateVAT_ReturnsCorrectTax(decimal amount, decimal expectedVat)
{
var service = new OrderService();
Assert.Equal(expectedVat, service.CalculateVAT(amount));
}
}
3.2. Tính năng mới: Retry và Test Ordering
// Retry cho flaky tests (network-dependent)
public class ExternalApiTests
{
[Fact]
[Trait("Category", "Integration")]
public async Task GetExchangeRate_ReturnsValidRate()
{
// xUnit v3 hỗ trợ retry policy qua configuration
var client = new HttpClient();
var response = await client.GetAsync("https://api.exchangerate.host/latest");
Assert.True(response.IsSuccessStatusCode);
}
}
// Test Ordering — hữu ích cho integration test có state
[TestCaseOrderer(
ordererTypeName: "MyProject.Tests.PriorityOrderer",
ordererAssemblyName: "MyProject.Tests")]
public class UserWorkflowTests
{
[Fact, TestPriority(1)]
public async Task Step1_CreateUser() { /* ... */ }
[Fact, TestPriority(2)]
public async Task Step2_UpdateProfile() { /* ... */ }
[Fact, TestPriority(3)]
public async Task Step3_DeleteUser() { /* ... */ }
}⚠️ Cảnh báo Performance
Có báo cáo về regression hiệu năng 80-90% khi chạy xUnit v3 trên .NET 10 với In-Process Runner (issue #123124 trên dotnet/runtime). Nếu gặp vấn đề, tạm thời chuyển sang Out-of-Process Runner hoặc theo dõi bản vá từ Microsoft.
4. TestContainers — Integration Test với dependency thật
Đây là bước ngoặt lớn nhất trong testing .NET: không cần mock database nữa. TestContainers tự động spin một Docker container SQL Server, PostgreSQL, Redis... ngay trong test, chạy xong thì dispose. Mỗi test class có database riêng, cô lập hoàn toàn.
sequenceDiagram
participant T as Test Runner
participant TC as TestContainers
participant D as Docker Engine
participant DB as SQL Server Container
participant App as Application Code
T->>TC: Initialize Test
TC->>D: Pull & Start Container
D->>DB: Create SQL Server Instance
DB-->>TC: Connection String
TC-->>T: Ready
T->>App: Execute Test Logic
App->>DB: Query/Insert/Update
DB-->>App: Results
App-->>T: Assert Results
T->>TC: Dispose
TC->>D: Stop & Remove Container
4.1. Cài đặt và cấu hình
# Package cần thiết
dotnet add package Testcontainers --version 4.4.0
dotnet add package Testcontainers.MsSql --version 4.4.0
dotnet add package Testcontainers.Redis --version 4.4.04.2. Integration Test với SQL Server thật
using Testcontainers.MsSql;
using Microsoft.Data.SqlClient;
public class ProductRepositoryTests : IAsyncLifetime
{
private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
public async Task InitializeAsync()
{
await _sqlContainer.StartAsync();
// Apply migrations
await using var connection = new SqlConnection(
_sqlContainer.GetConnectionString());
await connection.OpenAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE Products (
Id INT IDENTITY PRIMARY KEY,
Name NVARCHAR(200) NOT NULL,
Price DECIMAL(18,2) NOT NULL,
Stock INT NOT NULL DEFAULT 0
);
INSERT INTO Products (Name, Price, Stock)
VALUES (N'Laptop Dell XPS', 35000000, 50);
""";
await cmd.ExecuteNonQueryAsync();
}
[Fact]
public async Task GetById_ExistingProduct_ReturnsProduct()
{
// Arrange
var repo = new ProductRepository(_sqlContainer.GetConnectionString());
// Act
var product = await repo.GetByIdAsync(1);
// Assert
Assert.NotNull(product);
Assert.Equal("Laptop Dell XPS", product.Name);
Assert.Equal(35_000_000m, product.Price);
}
[Fact]
public async Task Create_ValidProduct_InsertsAndReturnsId()
{
var repo = new ProductRepository(_sqlContainer.GetConnectionString());
var newProduct = new Product("Chuột Logitech MX", 2_500_000, 100);
var id = await repo.CreateAsync(newProduct);
Assert.True(id > 0);
var saved = await repo.GetByIdAsync(id);
Assert.Equal(newProduct.Name, saved.Name);
}
public async Task DisposeAsync() => await _sqlContainer.DisposeAsync();
}4.3. Chia sẻ container giữa các test class
Mỗi test class tạo container riêng thì tốn thời gian startup. Với xUnit v3, có thể dùng Collection Fixture để chia sẻ container:
// Shared fixture — container được tạo 1 lần, dùng chung
public class DatabaseFixture : IAsyncLifetime
{
public MsSqlContainer SqlContainer { get; } = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
public string ConnectionString => SqlContainer.GetConnectionString();
public async Task InitializeAsync()
{
await SqlContainer.StartAsync();
// Run migrations once
}
public async Task DisposeAsync() => await SqlContainer.DisposeAsync();
}
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
// Mọi test class trong collection này dùng chung 1 SQL Server container
[Collection("Database")]
public class OrderRepositoryTests(DatabaseFixture db)
{
[Fact]
public async Task PlaceOrder_DeductsStock()
{
var repo = new OrderRepository(db.ConnectionString);
// ... test logic
}
}💡 Best Practice: Pin Docker Image Version
Luôn chỉ định version cụ thể của Docker image (ví dụ 2022-latest thay vì latest). Điều này ngăn chặn test bị flaky do Docker image tự update lên phiên bản mới có breaking changes.
5. WebApplicationFactory — Test API không cần deploy
WebApplicationFactory của ASP.NET Core cho phép spin một in-memory test server chạy toàn bộ ứng dụng. Kết hợp với TestContainers, ta có thể test API endpoint từ đầu đến cuối với database thật.
using Microsoft.AspNetCore.Mvc.Testing;
using Testcontainers.MsSql;
public class ProductApiTests : IAsyncLifetime
{
private readonly MsSqlContainer _sql = new MsSqlBuilder().Build();
private WebApplicationFactory<Program> _factory = null!;
private HttpClient _client = null!;
public async Task InitializeAsync()
{
await _sql.StartAsync();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Thay connection string bằng TestContainers
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(_sql.GetConnectionString()));
});
});
_client = _factory.CreateClient();
}
[Fact]
public async Task GET_Products_Returns200WithList()
{
var response = await _client.GetAsync("/api/products");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var products = await response.Content
.ReadFromJsonAsync<List<ProductDto>>();
Assert.NotNull(products);
}
[Fact]
public async Task POST_Product_InvalidData_Returns400()
{
var invalidProduct = new { Name = "", Price = -1 };
var response = await _client.PostAsJsonAsync(
"/api/products", invalidProduct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
public async Task DisposeAsync()
{
_client.Dispose();
await _factory.DisposeAsync();
await _sql.DisposeAsync();
}
}6. .NET Aspire Testing — Test hệ thống phân tán
Với các ứng dụng microservices, .NET Aspire cung cấp DistributedApplicationTestingBuilder — một công cụ thay thế cả WebApplicationFactory lẫn TestContainers cho môi trường distributed.
graph TB
subgraph Aspire["DistributedApplicationTestingBuilder"]
A["AppHost Project"] --> B["API Service"]
A --> C["Worker Service"]
A --> D["Redis Cache"]
A --> E["SQL Server"]
A --> F["RabbitMQ"]
end
T["Test Project"] -->|"Create & Start"| Aspire
T -->|"HTTP Client"| B
T -->|"Assert State"| E
style Aspire fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style T fill:#e94560,stroke:#fff,color:#fff
style A fill:#2c3e50,stroke:#fff,color:#fff
style B fill:#4CAF50,stroke:#fff,color:#fff
style C fill:#4CAF50,stroke:#fff,color:#fff
style D fill:#ff9800,stroke:#fff,color:#fff
style E fill:#ff9800,stroke:#fff,color:#fff
style F fill:#ff9800,stroke:#fff,color:#fff
using Aspire.Hosting.Testing;
public class DistributedOrderTests
{
[Fact]
public async Task OrderWorkflow_EndToEnd()
{
// Aspire tự động start tất cả services + dependencies
await using var app = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyShop_AppHost>();
await app.BuildAsync();
var resourceNotification = await app.StartAsync();
// Lấy HTTP client cho API service
var client = app.CreateHttpClient("api-service");
// Test flow: tạo order → kiểm tra stock → verify notification
var order = new { ProductId = 1, Quantity = 2 };
var response = await client.PostAsJsonAsync("/api/orders", order);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
// Verify side effects qua service khác
var stockClient = app.CreateHttpClient("inventory-service");
var stock = await stockClient
.GetFromJsonAsync<StockDto>("/api/stock/1");
Assert.Equal(48, stock?.Available); // 50 - 2
}
}🔑 Aspire vs TestContainers — Khi nào dùng gì?
TestContainers: phù hợp khi test một service đơn lẻ cần database/cache thật. Nhẹ, nhanh, không cần Aspire AppHost.
Aspire Testing: phù hợp khi test tương tác giữa nhiều services (API → Worker → Queue → DB). Aspire tự xử lý service discovery, container orchestration và connection strings.
7. Stryker Mutation Testing — Đo chất lượng thực sự của test
Code coverage 90% nghe rất ấn tượng, nhưng nó chỉ đo test đã chạy qua bao nhiêu dòng code — không đo test có phát hiện bug hay không. Mutation Testing giải quyết vấn đề này bằng cách tự động tạo các "mutant" — phiên bản code bị sửa đổi nhỏ — rồi kiểm tra xem test có catch được không.
graph LR
A["Source Code"] -->|"Stryker tạo mutant"| B["Mutated Code"]
B -->|"VD: a + b → a - b"| C["Chạy Test Suite"]
C -->|"Test FAIL"| D["✅ Mutant Killed"]
C -->|"Test PASS"| E["❌ Mutant Survived"]
E -->|"Test yếu!"| F["Cần viết thêm test"]
style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style B fill:#ff9800,stroke:#fff,color:#fff
style D fill:#4CAF50,stroke:#fff,color:#fff
style E fill:#e94560,stroke:#fff,color:#fff
style F fill:#2c3e50,stroke:#fff,color:#fff
7.1. Cài đặt và chạy Stryker
# Cài đặt Stryker CLI
dotnet tool install -g dotnet-stryker
# Chạy mutation testing
cd MyProject.Tests
dotnet stryker --project MyProject.csproj
# Kết quả mẫu:
# Mutation score: 78.5%
# Killed: 157 | Survived: 43 | Timeout: 3 | No Coverage: 127.2. Các loại mutation phổ biến
| Loại Mutation | Original | Mutated | Mục đích kiểm tra |
|---|---|---|---|
| Arithmetic | a + b | a - b | Phép tính có đúng không? |
| Conditional | a > b | a >= b | Boundary condition có test? |
| Boolean | true | false | Nhánh logic có test đủ? |
| String | "hello" | "" | Empty string có handle? |
| Return Value | return x; | return default; | Return value có assert? |
| Method Call | list.Add(x) | // removed | Side effect có verify? |
7.3. Tích hợp Stryker vào CI/CD
# GitHub Actions — chạy mutation testing trên PR
name: Mutation Testing
on:
pull_request:
branches: [main]
jobs:
stryker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install Stryker
run: dotnet tool install -g dotnet-stryker
- name: Run Mutation Tests
run: |
cd tests/MyProject.Tests
dotnet stryker \
--project ../../src/MyProject/MyProject.csproj \
--threshold-high 80 \
--threshold-low 60 \
--threshold-break 50 \
--reporters "['html', 'json', 'markdown']"
- name: Upload Report
uses: actions/upload-artifact@v4
with:
name: stryker-report
path: tests/MyProject.Tests/StrykerOutput/⚠️ Mutation Testing rất tốn thời gian
Stryker phải chạy toàn bộ test suite cho mỗi mutant. Với 1,000 dòng code có thể tạo ra 2,000+ mutant. Khuyến nghị: chỉ chạy trên changed files trong PR (dùng --since:main), chạy full mutation test vào lịch hàng tuần (scheduled job).
8. Chiến lược Testing cho Production — Kết hợp tất cả
graph TB
subgraph Dev["Developer Workflow"]
A["Write Code"] --> B["Unit Tests
xUnit v3"]
B --> C["Integration Tests
TestContainers"]
C --> D["Local Commit"]
end
subgraph CI["CI Pipeline"]
D --> E["Build + Unit Tests"]
E --> F["Integration Tests
+ TestContainers"]
F --> G["Code Coverage
Coverlet"]
G --> H["Mutation Testing
Stryker (changed files)"]
end
subgraph CD["CD Pipeline"]
H --> I["Aspire E2E Tests"]
I --> J["Deploy to Staging"]
J --> K["Smoke Tests"]
K --> L["Deploy to Production"]
end
style Dev fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
style CI fill:#f8f9fa,stroke:#ff9800,color:#2c3e50
style CD fill:#f8f9fa,stroke:#e94560,color:#2c3e50
8.1. Cấu trúc thư mục khuyến nghị
src/
MyApp.Api/ → API project
MyApp.Domain/ → Business logic
MyApp.Infrastructure/ → Data access, external services
tests/
MyApp.UnitTests/ → Unit tests (xUnit v3, no Docker needed)
MyApp.IntegrationTests/ → Integration tests (TestContainers)
MyApp.E2ETests/ → End-to-end tests (Aspire Testing)
MyApp.AppHost.Tests/ → Aspire distributed tests8.2. Nguyên tắc vàng
9. Các Anti-Pattern cần tránh
| ❌ Anti-Pattern | ✅ Cách làm đúng | Lý do |
|---|---|---|
| Mock database cho integration test | Dùng TestContainers với DB thật | Mock che giấu lỗi migration, query sai cú pháp, constraint violation |
| Test dùng chung database, lệ thuộc thứ tự chạy | Mỗi test có database riêng hoặc clean state trước khi chạy | Tests phải chạy độc lập, bất kỳ thứ tự nào |
| Chỉ đo code coverage | Kết hợp code coverage + mutation testing | Coverage 100% không nghĩa test phát hiện bug — mutation score mới phản ánh chất lượng |
| Test quá chi tiết implementation | Test behavior, không test implementation | Khi refactor, test không nên fail nếu behavior không đổi |
| Hardcode connection string / port | Để TestContainers tự chọn port ngẫu nhiên | Tránh conflict khi chạy parallel trên CI |
Skip flaky test bằng [Skip] | Fix root cause hoặc dùng retry policy | Skipped test = blind spot trong test suite |
10. Kết luận
Testing trên .NET 10 đã trưởng thành đáng kể so với các phiên bản trước. Microsoft Testing Platform mang lại tốc độ test discovery nhanh gấp 3 lần. TestContainers loại bỏ hoàn toàn nhu cầu mock database. xUnit v3 cung cấp khả năng parallel execution và fixture management vượt trội. .NET Aspire biến việc test distributed systems từ ác mộng thành quy trình tự động. Và Stryker Mutation Testing đặt ra tiêu chuẩn mới cho chất lượng test — vượt xa code coverage truyền thống.
Điều quan trọng nhất: testing không phải chi phí, mà là đầu tư. Một test suite vững chắc cho phép team tự tin refactor, deploy thường xuyên, và phát hiện regression trước khi user gặp phải.
💡 Bắt đầu từ đâu?
Nếu project chưa có test: bắt đầu với Unit Tests (xUnit v3) cho business logic → thêm Integration Tests (TestContainers) cho data access → sau đó mới tính đến mutation testing và E2E. Đừng cố đạt 100% coverage ngay — hãy tập trung vào critical business flows trước.
Tham khảo:
Cloudflare Tunnel + Zero Trust — Expose ứng dụng nội bộ ra Internet an toàn, miễn phí
Micro-Frontend 2026: Chia để trị Frontend với Module Federation 2.0
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.