Testing Strategy on .NET 10 — TestContainers, xUnit v3, and Mutation Testing for Production

Posted on: 4/18/2026 4:12:39 AM

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.

3x Faster test discovery with MTP
xUnit v3 Native Microsoft Testing Platform support
80% Target mutation score for production
0 Mocks needed when using TestContainers

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
The Testing Pyramid — ratios and characteristics of each layer

🟢 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.

CriterionVSTest (old)MTP (.NET 10)
Test Discovery~8 seconds (3,500 tests)~2.5 seconds — 3× faster
ArchitectureOut-of-process runnerIn-process + Out-of-process
ExtensibilityComplex adapter patternPlugin-based, simple
OutputTRX formatTRX + JUnit + custom formats
.NET 10 defaultNo more new featuresDefault for new projects
Parallel ExecutionAssembly-levelClass-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
The headline additions in xUnit v3

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
TestContainers lifecycle inside an integration test

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
Aspire Testing — orchestrate the whole system inside a test
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
Mutation Testing workflow with Stryker

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 typeOriginalMutatedWhat it checks
Arithmetica + ba - bIs the math correct?
Conditionala > ba >= bAre boundary conditions tested?
BooleantruefalseAre both branches covered?
String"hello"""Is the empty-string case handled?
Return valuereturn x;return default;Is the return value asserted?
Method calllist.Add(x)// removedIs 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
A complete production testing pipeline
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

F.I.R.S.T Fast · Independent · Repeatable · Self-validating · Timely
AAA Arrange · Act · Assert
1 Assert One behavior asserted per test
No Mocks Prefer real dependencies via TestContainers

9. Anti-Patterns to Avoid

❌ Anti-pattern✅ Do this insteadWhy
Mocking the database in integration testsUse TestContainers with a real DBMocks hide migration errors, syntax errors, and constraint violations
Tests share a database and depend on run orderEach test gets its own DB or cleans state firstTests must run independently in any order
Measuring only code coverageCombine code coverage + mutation testing100% coverage doesn't mean bugs are caught — mutation score reflects real quality
Testing implementation detailsTest behavior, not implementationRefactors shouldn't break tests when behavior is unchanged
Hardcoded connection strings / portsLet TestContainers pick random portsAvoid conflicts when running in parallel on CI
Skipping flaky tests with [Skip]Fix the root cause or use retry policiesA 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: