Steven's Knowledge

Testing

xUnit, parameterized tests, mocking with NSubstitute and Moq, FluentAssertions, and integration testing with WebApplicationFactory and Testcontainers

Testing

The .NET testing story has converged on a small, opinionated stack: xUnit as the runner, a mocking library (Moq or NSubstitute) for the seams you genuinely need to fake, and FluentAssertions for readable assertions. None of these ship in the box, but together they are so dominant that a new ASP.NET Core project without them looks out of place. The interesting work for a senior engineer is not learning the syntax -- it is deciding what to test in-process, what to mock, and where to spin up real infrastructure. The libraries below are means to that end.

xUnit Basics

xUnit is the de facto standard. A [Fact] marks a single, parameterless test case; [Theory] paired with [InlineData] gives you parameterized tests -- C#'s answer to Go's table-driven tests, where each row is a distinct case with its own pass/fail status. Reach for [Theory] whenever you find yourself copy-pasting a test body and tweaking one value.

public class ShippingCalculatorTests
{
    [Theory]
    [InlineData(150, "NZ", 1, 0)]      // free over $100
    [InlineData(50, "NZ", 1, 5)]       // light NZ
    [InlineData(50, "NZ", 3, 10)]      // heavy NZ
    [InlineData(50, "AU", 1, 25)]      // Australia
    public void CalculatesShippingCost(decimal total, string country, decimal weight, decimal expected)
    {
        var order = new Order(total, new Destination(country), weight);
        var result = new ShippingCalculator().Calculate(order);
        result.Should().Be(expected);
    }
}

[InlineData] only takes compile-time constants. When a case needs a real object -- a constructed Order, a DateTime, a record -- switch to [MemberData] (a static property or method returning IEnumerable<object[]>) or [ClassData] (a class implementing IEnumerable<object[]>), which let you build arbitrary inputs in code.

One xUnit behaviour trips up engineers coming from NUnit or JUnit: xUnit constructs a fresh instance of the test class for every single test method, so there is no shared mutable state between tests by design. That makes the constructor the natural place for per-test setup, and IDisposable (or IAsyncLifetime.DisposeAsync for async cleanup) the place for teardown. There is no [SetUp]/[TearDown] attribute pair as in NUnit -- the language's own object lifecycle is the setup mechanism, which is cleaner once it clicks.

Mocking with NSubstitute

When a unit under test depends on something awkward to construct -- a payment gateway, an email sender -- you substitute it. NSubstitute leans into C# extension-method syntax for a low-ceremony feel: Substitute.For<T>() creates the double, .Returns(...) arranges return values, and the DidNotReceive()/Received() matchers assert on calls after the fact. Arg.Any<T>() and Arg.Is<T>(...) express argument matching.

[Fact]
public async Task ProcessOrder_WhenPaymentFails_DoesNotNotify()
{
    var payments = Substitute.For<IPaymentGateway>();
    payments.ChargeAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
        .Returns(Task.FromException(new PaymentDeclinedException()));
    var notifications = Substitute.For<INotificationService>();
    var service = new OrderService(payments, notifications);

    await Assert.ThrowsAsync<PaymentDeclinedException>(
        () => service.ProcessOrderAsync("order-1", default));

    await notifications.DidNotReceive()
        .SendAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}

Moq is the more popular alternative and does everything NSubstitute does, just with a more verbose lambda-based setup syntax (mock.Setup(x => x.ChargeAsync(...)).ReturnsAsync(...) and mock.Verify(...)). The choice is largely taste; teams pick one and stay consistent. Whichever you use, the discipline matters more than the tool: mock roles and interfaces you own, not types you don't (mocking HttpClient or DbContext directly is usually a smell), and prefer a real object whenever it is cheap to construct. Every mock is an assumption about how a collaborator behaves, and assumptions drift from reality.

FluentAssertions

FluentAssertions replaces the bare Assert.Equal(expected, actual) with a chainable, readable form that produces far better failure messages -- it tells you what differed and where, not just that two values weren't equal. .Should().Be() covers scalar equality, .Should().ThrowAsync<T>() asserts on exceptions from async code, and .Should().BeEquivalentTo() does deep structural comparison.

var actual = await _repository.GetOrderAsync("order-1");

actual.Should().BeEquivalentTo(new OrderDto
{
    Id = "order-1",
    Total = 150m,
    Status = "Paid"
});

Func<Task> act = () => _service.ProcessOrderAsync("missing", default);
await act.Should().ThrowAsync<OrderNotFoundException>()
    .WithMessage("*missing*");

BeEquivalentTo is the workhorse for DTOs and records: it compares member-by-member by value, so it sidesteps reference equality entirely and doesn't care whether the two objects are the same instance. That makes it ideal for asserting that a mapped or deserialized object matches an expected shape without overriding Equals everywhere.

Integration Testing with WebApplicationFactory

Unit tests prove your domain logic; they say nothing about whether routing, model binding, middleware, the DI container, and JSON serialization actually line up. Microsoft.AspNetCore.Mvc.Testing closes that gap. WebApplicationFactory<Program> boots your entire application in-memory and hands you a real HttpClient wired to it -- no socket, no port, no network -- so a test exercises the full request pipeline end to end. Implementing IClassFixture<WebApplicationFactory<Program>> shares one booted app across the test class for speed.

public class OrdersApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client = factory.CreateClient();

    [Fact]
    public async Task GetOrder_ReturnsNotFound_ForUnknownId()
    {
        var response = await _client.GetAsync("/orders/does-not-exist");
        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }
}

These are the highest-confidence tests you can write for an ASP.NET Core app, because they run the same code path production does. When you need to isolate one boundary -- swap the real database for a test double, or stub a flaky third party -- override the registration with factory.WithWebHostBuilder(b => b.ConfigureTestServices(services => { ... })). ConfigureTestServices runs after the app's own registrations, so a services.RemoveAll<T>() plus a fresh AddScoped<T> cleanly replaces a dependency without touching production wiring.

Real Dependencies with Testcontainers

The temptation in integration tests is to point EF Core at its in-memory provider. Don't, for anything that exercises real SQL: the in-memory provider doesn't speak SQL, ignores relational constraints, and silently tolerates queries that a real database would reject -- it lies about the very semantics you are trying to verify. Testcontainers is the better answer. It starts a throwaway, real database in Docker for the duration of a test run, so your EF Core queries, migrations, transactions, and constraints all hit genuine Postgres or SQL Server.

public class OrderRepositoryTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
        .WithImage("postgres:16")
        .Build();

    private OrderDbContext _context = null!;

    public async Task InitializeAsync()
    {
        await _db.StartAsync();
        var options = new DbContextOptionsBuilder<OrderDbContext>()
            .UseNpgsql(_db.GetConnectionString())
            .Options;
        _context = new OrderDbContext(options);
        await _context.Database.MigrateAsync();
    }

    public async Task DisposeAsync()
    {
        await _context.DisposeAsync();
        await _db.DisposeAsync();
    }
}

IAsyncLifetime gives you the async start/stop hooks the container lifecycle needs. The only real cost is that Docker must be available on the machine and in CI, and the first image pull is slow -- a price worth paying for tests that actually catch the SQL-level bugs the in-memory provider hides. Combine it with WebApplicationFactory by overriding the connection string to point the booted app at the container.

Testing Strategy

Shape the suite as a pyramid with intent. Write a large base of fast unit tests for domain logic -- pure functions, calculators, state transitions -- where mocks are rarely needed at all. Add a focused band of WebApplicationFactory integration tests over the API surface to prove routing, serialization, and middleware behave, and lean on Testcontainers for the data layer so the persistence code meets a real database. Reserve mocks for genuine external boundaries you can't run locally: payment gateways, email and SMS providers, third-party APIs. The failure mode to avoid is over-mocking -- a test that fakes every collaborator verifies only that your mocks were configured the way you configured them, not that the system works. When in doubt, use a real object.

On this page