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.
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)
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
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() { }
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);
}
}
// ✅ 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);
}
FluentAssertions replaces cryptic Assert.Equal failures with readable messages. Chain assertions for collections, exceptions, and HTTP responses.
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();
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", _ => { });
});
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.
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)
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.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