Testing Strategy on .NET 10 — TestContainers, xUnit v3, and Mutation Testing for Production
Posted on: 4/18/2026 4:12:39 AM
Table of contents
- 1. The Testing Pyramid and Layered Strategy on .NET 10
- 2. Microsoft Testing Platform — Replacing VSTest after 10 Years
- 3. xUnit v3 — The Important Changes
- 4. TestContainers — Integration Tests Against Real Dependencies
- 5. WebApplicationFactory — Testing APIs Without Deploying
- 6. .NET Aspire Testing — Testing Distributed Systems
- 7. Stryker Mutation Testing — Measuring True Test Quality
- 8. A Production Testing Strategy — Tying It All Together
- 9. Anti-Patterns to Avoid
- 10. Conclusion
Writing code that works is one thing, but making sure the code still works after every change is what decides whether a product survives. With .NET 10 (released November 2025), Microsoft significantly upgraded the testing infrastructure — from the Microsoft Testing Platform (MTP) replacing VSTest, to deep integration with .NET Aspire for distributed testing. Combined with TestContainers, xUnit v3, Stryker Mutation Testing, and WebApplicationFactory, we now have a complete production-grade testing toolkit.
1. The Testing Pyramid and Layered Strategy on .NET 10
Before diving into tools, you need a solid grasp of the Testing Pyramid — the layered model that balances speed, cost, and reliability across the test suite.
graph TD
A["🔺 E2E Tests
Fewest · Slowest · High confidence"] --> B["🔶 Integration Tests
Moderate · TestContainers + Aspire"]
B --> C["🟢 Unit Tests
Most · Fastest · Isolated"]
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% of the suite)
Test a single unit of logic — method, function, class — in complete isolation. They run fast (milliseconds) and don't touch database or network. On .NET 10, xUnit v3 is the top choice with native support for the Microsoft Testing Platform.
🔶 Integration Tests (15-25% of the suite)
Verify interactions between real components — API endpoint → database, service → message queue. TestContainers spins up real Docker containers (SQL Server, Redis, RabbitMQ) inside the test, ensuring it runs against dependencies identical to production.
🔺 E2E Tests (5-10% of the suite)
Exercise the full flow from UI to database. Slow, easy to make flaky, but catches bugs the lower layers miss. Only cover the golden path and critical business flows.
2. Microsoft Testing Platform — Replacing VSTest after 10 Years
Microsoft Testing Platform (MTP) is the new testing foundation that fully replaces VSTest, which has been around since .NET Framework. MTP was rebuilt from scratch with clear goals: faster, lighter, and more extensible.
| Criterion | VSTest (old) | MTP (.NET 10) |
|---|---|---|
| Test Discovery | ~8 seconds (3,500 tests) | ~2.5 seconds — 3× faster |
| Architecture | Out-of-process runner | In-process + Out-of-process |
| Extensibility | Complex adapter pattern | Plugin-based, simple |
| Output | TRX format | TRX + JUnit + custom formats |
| .NET 10 default | No more new features | Default for new projects |
| Parallel Execution | Assembly-level | Class-level + Method-level |
💡 Upgrade notes
MTP v1 remains the default in the .NET 10 SDK for backward compatibility. To use MTP v2, opt in with <TestingPlatformVersion>2</TestingPlatformVersion> in the csproj. Note that MTP v2 does not support VSTest — if your CI/CD uses dotnet test --logger trx, verify the pipeline before upgrading.
2.1. Configuring MTP in a .NET 10 project
<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>
On .NET 10, testing project templates ship with Coverlet pre-integrated — you don't need any extra package to run code coverage with --collect:"XPlat Code Coverage".
3. xUnit v3 — The Important Changes
xUnit v3 is the biggest rewrite since xUnit first shipped, with a long list of improvements tailored for .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 for 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. Writing unit tests with 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. New features: Retry and Test Ordering
// Retry for flaky tests (network-dependent)
public class ExternalApiTests
{
[Fact]
[Trait("Category", "Integration")]
public async Task GetExchangeRate_ReturnsValidRate()
{
// xUnit v3 supports retry policy via configuration
var client = new HttpClient();
var response = await client.GetAsync("https://api.exchangerate.host/latest");
Assert.True(response.IsSuccessStatusCode);
}
}
// Test Ordering — useful for stateful integration tests
[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() { /* ... */ }
}
⚠️ Performance warning
There are reports of an 80-90% performance regression running xUnit v3 on .NET 10 with the In-Process Runner (issue #123124 on dotnet/runtime). If you hit it, temporarily switch to the Out-of-Process Runner or track Microsoft's patch releases.
4. TestContainers — Integration Tests Against Real Dependencies
This is the biggest turning point in .NET testing: no more mocking the database. TestContainers spins a real SQL Server, PostgreSQL, Redis, … Docker container right inside the test, then disposes it when done. Each test class gets its own database — fully isolated.
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. Installation and configuration
# Required packages
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.0
4.2. Integration tests against a real SQL Server
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("Logitech MX Mouse", 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. Sharing containers between test classes
Creating a new container for every test class wastes startup time. With xUnit v3 you can use Collection Fixtures to share a container:
// Shared fixture — the container is created once and reused
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> { }
// Every test class in this collection shares the 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 versions
Always pin a specific Docker image version (e.g. 2022-latest instead of latest). That prevents flaky tests caused by Docker auto-updating to a new version with breaking changes.
5. WebApplicationFactory — Testing APIs Without Deploying
ASP.NET Core's WebApplicationFactory spins up an in-memory test server running your full application. Combined with TestContainers you can exercise an API endpoint end-to-end against a real database.
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 =>
{
// Swap the connection string with the TestContainer's
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 — Testing Distributed Systems
For microservice applications, .NET Aspire provides DistributedApplicationTestingBuilder — a tool that replaces both WebApplicationFactory and TestContainers for distributed environments.
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 auto-starts every service + its dependencies
await using var app = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyShop_AppHost>();
await app.BuildAsync();
var resourceNotification = await app.StartAsync();
// Grab the HTTP client for the API service
var client = app.CreateHttpClient("api-service");
// Flow: create order → check 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 through another service
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 — which to use?
TestContainers: ideal when testing a single service that needs a real database/cache. Lightweight, fast, no Aspire AppHost required.
Aspire Testing: ideal when testing cross-service interactions (API → Worker → Queue → DB). Aspire handles service discovery, container orchestration, and connection strings for you.
7. Stryker Mutation Testing — Measuring True Test Quality
90% code coverage sounds impressive, but it only measures how much code your tests ran — not whether they'd catch bugs. Mutation Testing addresses this by automatically generating "mutants" — slightly modified versions of the code — and checking whether your tests catch them.
graph LR
A["Source Code"] -->|"Stryker creates mutants"| B["Mutated Code"]
B -->|"e.g. a + b → a - b"| C["Run Test Suite"]
C -->|"Test FAILS"| D["✅ Mutant Killed"]
C -->|"Test PASSES"| E["❌ Mutant Survived"]
E -->|"Weak tests!"| F["Write additional tests"]
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. Installing and running Stryker
# Install the Stryker CLI
dotnet tool install -g dotnet-stryker
# Run mutation testing
cd MyProject.Tests
dotnet stryker --project MyProject.csproj
# Sample output:
# Mutation score: 78.5%
# Killed: 157 | Survived: 43 | Timeout: 3 | No Coverage: 12
7.2. Common mutation categories
| Mutation type | Original | Mutated | What it checks |
|---|---|---|---|
| Arithmetic | a + b | a - b | Is the math correct? |
| Conditional | a > b | a >= b | Are boundary conditions tested? |
| Boolean | true | false | Are both branches covered? |
| String | "hello" | "" | Is the empty-string case handled? |
| Return value | return x; | return default; | Is the return value asserted? |
| Method call | list.Add(x) | // removed | Is the side-effect verified? |
7.3. Integrating Stryker into CI/CD
# GitHub Actions — run mutation testing on PRs
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 is expensive
Stryker has to rerun the full test suite for every mutant. 1,000 lines of code can spawn 2,000+ mutants. Recommendation: run only on changed files in a PR (--since:main) and run the full mutation pass on a weekly schedule.
8. A Production Testing Strategy — Tying It All Together
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. Recommended folder structure
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 tests
8.2. Golden rules
9. Anti-Patterns to Avoid
| ❌ Anti-pattern | ✅ Do this instead | Why |
|---|---|---|
| Mocking the database in integration tests | Use TestContainers with a real DB | Mocks hide migration errors, syntax errors, and constraint violations |
| Tests share a database and depend on run order | Each test gets its own DB or cleans state first | Tests must run independently in any order |
| Measuring only code coverage | Combine code coverage + mutation testing | 100% coverage doesn't mean bugs are caught — mutation score reflects real quality |
| Testing implementation details | Test behavior, not implementation | Refactors shouldn't break tests when behavior is unchanged |
| Hardcoded connection strings / ports | Let TestContainers pick random ports | Avoid conflicts when running in parallel on CI |
Skipping flaky tests with [Skip] | Fix the root cause or use retry policies | A skipped test is a blind spot in your suite |
10. Conclusion
Testing on .NET 10 has matured significantly compared to previous versions. The Microsoft Testing Platform makes test discovery 3× faster. TestContainers eliminates the need to mock databases. xUnit v3 delivers vastly better parallel execution and fixture management. .NET Aspire turns distributed-system testing from a nightmare into an automated workflow. And Stryker Mutation Testing sets a new quality bar — far beyond traditional code coverage.
The most important takeaway: testing isn't a cost, it's an investment. A solid test suite lets the team refactor confidently, ship frequently, and catch regressions before users do.
💡 Where to start?
If your project has no tests yet: start with Unit Tests (xUnit v3) for business logic → add Integration Tests (TestContainers) for data access → only then consider mutation testing and E2E. Don't chase 100% coverage first — focus on critical business flows.
References:
Cloudflare Tunnel + Zero Trust — Expose Internal Apps to the Internet Securely and for Free
Micro-Frontend 2026: Divide and Conquer the Frontend with 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.