In-Depth Guide · 10 Sections · Testing

Testing .NET In Depth

From xUnit basics and mocks to FluentAssertions, WebApplicationFactory, Testcontainers SQL, MediatR handlers, repository tests, optional snapshot/approval tests and production CI pipelines. Plain English, copy-paste .NET 9 code.

Unit Tests & Mocks Integration Tests Testcontainers CI & Production

Prerequisite: C# basics — C# for Dummies or Complete C# for Dummies. For the app under test see Minimal API or REST API. EF Core repository tests assume EF Core In Depth; SOLID patterns help — OOP & SOLID.

01

Why Test .NET Applications?

Tests give you confidence to refactor, deploy on Fridays, and catch regressions before users do. The .NET testing pyramid: many fast unit tests, fewer integration tests, and a thin slice of end-to-end tests.

1.1

Test pyramid for .NET APIs

// UNIT — isolate one class, mock dependencies (milliseconds)
//   PricingService.CalculateDiscount(...)

// INTEGRATION — real DI, real HTTP pipeline, maybe real DB (seconds)
//   WebApplicationFactory + Testcontainers SQL

// E2E — browser or Postman collections against deployed env (minutes)
//   Playwright, k6 load tests — keep count low

// Rule of thumb: 70% unit · 25% integration · 5% E2E
// Fast feedback loop → run unit tests on every save (dotnet watch test)
1.2

Solution layout

src/
  Api/                    // ASP.NET Core host
  Application/            // Handlers, services
  Domain/
  Infrastructure/         // EF Core, external APIs
tests/
  Api.UnitTests/
  Application.UnitTests/
  Api.IntegrationTests/   // WebApplicationFactory lives here

// dotnet new xunit -n Api.UnitTests
// dotnet add reference ../src/Application/Application.csproj
// dotnet add package Moq
// dotnet add package FluentAssertions
02

xUnit Basics

xUnit is the default test framework for modern .NET. Tests are methods marked with [Fact] (single case) or [Theory] (parameterized). Constructor runs before each test — perfect for fresh mocks.

2.1

Fact, Theory & Arrange-Act-Assert

public class DiscountCalculatorTests
{
    private readonly DiscountCalculator _sut = new();  // System Under Test

    [Fact]
    public void ApplyTenPercent_ReducesTotalByTenPercent()
    {
        // Arrange
        decimal total = 100m;

        // Act
        decimal result = _sut.Apply(total, percent: 10);

        // Assert
        Assert.Equal(90m, result);
    }

    [Theory]
    [InlineData(0, 0)]
    [InlineData(50, 5)]
    [InlineData(100, 10)]
    public void Apply_CalculatesCorrectly(decimal total, decimal expected)
        => Assert.Equal(expected, _sut.Apply(total, percent: 10));
}
2.2

Fixtures, IAsyncLifetime & skipping

// Shared expensive setup — implement IAsyncLifetime
public sealed class DatabaseFixture : IAsyncLifetime
{
    public AppDbContext Db { get; private set; } = null!;

    public async Task InitializeAsync()
    {
        // spin up Testcontainers — see section 6
    }

    public Task DisposeAsync() => Task.CompletedTask;
}

// Collection fixture — one DB for all tests in collection
[CollectionDefinition("db")]
public class DbCollection : ICollectionFixture<DatabaseFixture> { }

// Skip flaky test until fixed
[Fact(Skip = "Flaky on CI — issue #142")]
public void SometimesFails() { }
03

Unit Testing with Mocks

Unit tests isolate the system under test (SUT) by replacing dependencies with fakes. Moq and NSubstitute create test doubles that record calls and return canned responses.

3.1

Moq — setup, verify, callbacks

public sealed class OrderServiceTests
{
    private readonly Mock<IOrderRepository> _repo = new();
    private readonly Mock<IEmailSender> _email = new();
    private readonly OrderService _sut;

    public OrderServiceTests()
        => _sut = new OrderService(_repo.Object, _email.Object);

    [Fact]
    public async Task PlaceOrder_SavesAndSendsConfirmation()
    {
        _repo.Setup(r => r.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
             .ReturnsAsync((Order o, CancellationToken _) => o);

        var result = await _sut.PlaceOrderAsync(new PlaceOrderRequest(1, []), CancellationToken.None);

        Assert.NotNull(result.OrderNumber);
        _repo.Verify(r => r.AddAsync(It.Is<Order>(o => o.CustomerId == 1), It.IsAny<CancellationToken>()), Times.Once);
        _email.Verify(e => e.SendAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
    }
}
3.2

When NOT to mock

// ✅ Mock — external HTTP, email, clock, random, file system
// ✅ Mock — repository when testing service logic in isolation

// ❌ Don't mock — value objects, DTOs, pure functions
// ❌ Don't mock — DbContext (use in-memory or Testcontainers instead)
// ❌ Don't mock — the class you're testing

// Prefer interfaces for testability (see SOLID — Dependency Inversion)
public interface IOrderRepository
{
    Task<Order> AddAsync(Order order, CancellationToken ct);
}
04

FluentAssertions

FluentAssertions replaces cryptic Assert.Equal failures with readable messages. Chain assertions for collections, exceptions, and HTTP responses.

4.1

Readable assertions

using FluentAssertions;

[Fact]
public void Customer_HasExpectedDefaults()
{
    var customer = new Customer { Name = "Ada" };

    customer.Name.Should().Be("Ada");
    customer.Orders.Should().BeEmpty();
    customer.IsActive.Should().BeTrue();
}

[Fact]
public void InvalidOrder_ThrowsValidationException()
{
    var act = () => OrderValidator.Validate(new PlaceOrderRequest(-1, []));

    act.Should().Throw<ValidationException>()
       .WithMessage("*CustomerId*");
}
4.2

Collections, equivalents & HTTP

// Collection assertions
orders.Should().HaveCount(3)
      .And.OnlyContain(o => o.Total > 0)
      .And.BeInAscendingOrder(o => o.OrderDate);

// Object equivalence — ignore volatile fields
actual.Should().BeEquivalentTo(expected, opts => opts
    .Excluding(x => x.CreatedAt)
    .Excluding(x => x.Id));

// HttpResponseMessage (integration tests)
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
05

WebApplicationFactory Integration Tests

WebApplicationFactory<TProgram> boots your real ASP.NET Core app in-memory with TestServer — full middleware pipeline, DI, and routing without deploying anywhere.

5.1

Custom factory & test client

// Package: Microsoft.AspNetCore.Mvc.Testing

public sealed class ApiFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");
        builder.ConfigureServices(services =>
        {
            // Replace real email with fake
            services.RemoveAll<IEmailSender>();
            services.AddSingleton<IEmailSender, FakeEmailSender>();
        });
    }
}

public class CustomersEndpointTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
    private readonly HttpClient _client = factory.CreateClient();

    [Fact]
    public async Task GetCustomers_ReturnsOk()
    {
        var response = await _client.GetAsync("/api/customers");
        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}
5.2

Auth override & Program visibility

// Expose Program for WebApplicationFactory (.NET 6+ top-level statements)
// At bottom of Program.cs:
public partial class Program { }

// Authenticate as test user
_client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", TestJwtHelper.CreateToken(userId: "test-user"));

// Or bypass auth in Testing environment
builder.ConfigureServices(services =>
{
    services.AddAuthentication("Test")
        .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
});
06

Testcontainers SQL

Testcontainers spins up real SQL Server (or Postgres) in Docker for integration tests. No more "works on InMemory but fails in prod" — you test against the same engine you deploy.

6.1

SQL Server container fixture

// Package: Testcontainers.MsSql

public sealed class SqlContainerFixture : IAsyncLifetime
{
    private readonly MsSqlContainer _container = new MsSqlBuilder()
        .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
        .Build();

    public string ConnectionString => _container.GetConnectionString();

    public Task InitializeAsync() => _container.StartAsync();
    public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}

// Wire into WebApplicationFactory
builder.ConfigureServices(services =>
{
    services.RemoveAll<DbContextOptions<AppDbContext>>();
    services.AddDbContext<AppDbContext>(opts =>
        opts.UseSqlServer(_fixture.ConnectionString));
});
6.2

Migrate, seed & isolate tests

// Apply migrations once per fixture
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();

// Seed baseline data
await db.Customers.AddAsync(new Customer { Name = "Test Corp" });
await db.SaveChangesAsync();

// Isolate: transaction rollback or Respawn library between tests
// Respawn — fast DB reset without recreating container
Watch Out — Docker required

Testcontainers needs Docker running locally and in CI (GitHub Actions: services: docker). Skip integration tests with [Fact(Skip = "...")] or a custom trait filter when Docker is unavailable.

07

Testing MediatR Handlers

MediatR handlers are plain classes — test them directly with mocked dependencies, or through the MediatR pipeline with real behaviors (validation, logging).

7.1

Handler unit test

public record CreateCustomerCommand(string Name) : IRequest<int>;

public sealed class CreateCustomerHandler(IAppDbContext db) : IRequestHandler<CreateCustomerCommand, int>
{
    public async Task<int> Handle(CreateCustomerCommand request, CancellationToken ct)
    {
        var customer = new Customer { Name = request.Name };
        db.Customers.Add(customer);
        await db.SaveChangesAsync(ct);
        return customer.Id;
    }
}

[Fact]
public async Task Handle_PersistsCustomer()
{
    var db = new Mock<IAppDbContext>();
    db.Setup(d => d.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
    var handler = new CreateCustomerHandler(db.Object);

    var id = await handler.Handle(new CreateCustomerCommand("Ada"), CancellationToken.None);

    db.Verify(d => d.Customers.Add(It.Is<Customer>(c => c.Name == "Ada")), Times.Once);
}
7.2

Pipeline behaviors & validation

// Test ValidationBehavior throws before handler runs
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(CreateCustomerCommand).Assembly));
services.AddValidatorsFromAssembly(typeof(CreateCustomerCommand).Assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

var mediator = provider.GetRequiredService<IMediator>();

var act = () => mediator.Send(new CreateCustomerCommand(""));

await act.Should().ThrowAsync<ValidationException>();
08

Testing Repositories

Repository tests sit between unit and integration — they need a real database (Testcontainers) because LINQ translation and constraints can't be faked reliably with mocks.

8.1

Repository integration test

[Collection("db")]
public sealed class CustomerRepositoryTests(SqlContainerFixture fx)
{
    [Fact]
    public async Task GetByEmail_ReturnsCustomerWhenExists()
    {
        await using var db = CreateContext(fx.ConnectionString);
        var repo = new CustomerRepository(db);

        db.Customers.Add(new Customer { Name = "Ada", Email = "ada@test.com" });
        await db.SaveChangesAsync();

        var found = await repo.GetByEmailAsync("ada@test.com", CancellationToken.None);

        found.Should().NotBeNull();
        found!.Name.Should().Be("Ada");
    }
}
8.2

InMemory provider — pros & cons

// Quick unit-ish tests — NOT a SQL Server substitute
var options = new DbContextOptionsBuilder<AppDbContext>()
    .UseInMemoryDatabase(Guid.NewGuid().ToString())
    .Options;

// ❌ InMemory ignores FK constraints, raw SQL, transactions
// ❌ Different query translation — false positives
// ✅ Fine for testing pure EF mapping or simple CRUD logic
// ✅ Production path: Testcontainers SQL (section 6)
09

Snapshot & Approval Tests (Optional)

Snapshot testing captures output (JSON, HTML, SQL) to a file and fails on unexpected diffs. Useful for serializers and report generators — skip when output is non-deterministic.

9.1

Verify (snapshot) basics

// Package: Verify.Xunit

[Fact]
public Task SerializeOrder_ReturnsExpectedJson()
{
    var order = new OrderDto(1, "ORD-001", 99.50m, [new LineDto("Widget", 2)]);

    var json = JsonSerializer.Serialize(order, JsonOptions);

    return Verify(json);  // creates *.verified.txt on first run
}

// Update snapshots after intentional change:
// dotnet test -- Verify.DiffTool=VisualStudio
9.2

When to skip snapshot tests

// SKIP snapshot when:
//   • Output contains timestamps, GUIDs, random IDs
//   • Third-party HTML changes frequently
//   • Large binary or file outputs

// Scrub volatile fields before Verify:
var scrubbed = order with { CreatedAt = DateTimeOffset.MinValue };
await Verify(scrubbed);

// Or use [Fact(Skip = "Snapshot — optional for this module")]
[Fact(Skip = "Approval test optional — enable when output stabilizes")]
public Task GenerateReport_MatchesBaseline() => Task.CompletedTask;
10

Production CI — Pipelines & Quality Gates

10.1

GitHub Actions — test job

# .github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "9.0.x"
      - run: dotnet restore
      - run: dotnet build --no-restore -c Release
      - run: dotnet test --no-build -c Release --logger "trx;LogFileName=results.trx"
            --collect:"XPlat Code Coverage"
            --results-directory coverage
      - run: dotnet tool install -g dotnet-reportgenerator-globaltool
      - run: reportgenerator -reports:coverage/**/coverage.cobertura.xml
            -targetdir:coverage/report -reporttypes:Cobertura
10.2

Quality gates & test categories

// Category traits — run fast unit tests on every push
[Trait("Category", "Unit")]
public class FastTests { }

[Trait("Category", "Integration")]
public class SlowTests { }

// CI: dotnet test --filter "Category=Unit"           # PR checks (< 2 min)
// CI: dotnet test --filter "Category=Integration"    # nightly + Docker

// Coverage gate — fail below threshold (example: 70% line)
// Quality gate: zero failing tests, no skipped without issue link
Mastery Move — where to go next

1. EF Core In Depth — migrations and query patterns your integration tests exercise.

2. Blazor In Depth — bUnit component tests for UI logic.

3. Minimal API — build the API you'll test end-to-end.

4. How-To Cookbook — testing recipes and CI snippets.