Testing with xUnit, mocking and integration tests

Write focused unit tests, share fixtures correctly, fake dependencies, and exercise the real HTTP pipeline in memory.

xUnit structure and data-driven tests

using Xunit;
using FluentAssertions;

public sealed class OrderServiceTests
{
    [Fact]
    public async Task PlaceAsync_RejectsNonPositiveQuantity()
    {
        var svc = new OrderService(new FakeRepository(), new FakePricing(), NullLogger<OrderService>.Instance, TimeProvider.System);

        Func<Task> act = () => svc.PlaceAsync("ABC-1", 0, CancellationToken.None);

        await act.Should().ThrowAsync<ArgumentOutOfRangeException>()
                 .WithMessage("*quantity*");
    }

    [Theory]
    [InlineData("ABC-1", 1, true)]
    [InlineData("", 1, false)]
    [InlineData("ABC-1", 0, false)]
    public void Validate_ChecksInput(string sku, int qty, bool expected)
    {
        bool valid = OrderValidator.IsValid(sku, qty);
        Assert.Equal(expected, valid);
    }

    [Theory]
    [MemberData(nameof(Cases))]
    public void Computes_Total(string sku, decimal expected)
        => Assert.Equal(expected, Pricing.Total(sku));

    public static TheoryData<string, decimal> Cases => new() { { "A", 9.99m }, { "B", 19.98m } };
}

// shared, expensive setup for a class of tests
public sealed class DatabaseFixture : IAsyncLifetime
{
    public string ConnectionString { get; private set; } = "";
    public Task InitializeAsync() { ConnectionString = "Data Source=:memory:"; return Task.CompletedTask; }
    public Task DisposeAsync() => Task.CompletedTask;
}

public sealed class RepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;
    public RepositoryTests(DatabaseFixture fixture) => _fixture = fixture;

    [Fact]
    public void UsesSharedSetup() => Assert.NotNull(_fixture.ConnectionString);
}
  • xUnit creates a new instance of the test class for every test method, so fields are per-test and require no cleanup. Shared state belongs in a fixture.
  • IClassFixture<T> shares one instance across the class; ICollectionFixture<T> shares across several classes, which is the right place for a container that is expensive to start.
  • Tests within a class run sequentially; different classes run in parallel by default. Do not rely on ordering, and avoid shared mutable statics.
  • Name tests as behaviour plus condition plus expectation. A failing test name should tell you what broke without opening the file.

Fakes, mocks and what to prefer

// a hand-written fake: explicit, readable, no framework needed
public sealed class FakeOrderRepository : IOrderRepository
{
    private readonly Dictionary<Guid, Order> _store = new();
    public List<Order> Added { get; } = [];

    public Task<Order?> FindAsync(Guid id, CancellationToken ct)
        => Task.FromResult(_store.GetValueOrDefault(id));

    public Task AddAsync(Order order, CancellationToken ct)
    {
        _store[order.Id] = order;
        Added.Add(order);
        return Task.CompletedTask;
    }
}

// with a mocking library, when the interface has many members
var pricing = Substitute.For<IPricingClient>();
pricing.GetPriceAsync("ABC-1", Arg.Any<CancellationToken>()).Returns(9.99m);

var repo = Substitute.For<IOrderRepository>();
repo.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
    .Returns(Task.CompletedTask);

// verify the interaction only when the interaction IS the behaviour
await repo.Received(1).AddAsync(Arg.Is<Order>(o => o.Sku == "ABC-1"), Arg.Any<CancellationToken>());
Test doublePurposeCost
StubReturn a canned valueLow, but asserts nothing
FakeA working in-memory implementationReal behaviour, some maintenance
MockAssert on interactionsBrittle when overused
SpyRecord calls for later assertionCouples the test to the implementation
Real dependencyIn-memory database, TimeProviderHighest confidence when it is fast enough

Assert on outcomes before interactions. A test that verifies a specific sequence of calls breaks whenever the implementation is refactored, even if the observable behaviour is unchanged — which is the opposite of what a test is for.

Integration tests with WebApplicationFactory

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

public sealed class ApiFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");
        builder.ConfigureServices(services =>
        {
            // replace the real database with an in-memory one
            services.RemoveAll<DbContextOptions<ShopContext>>();
            services.AddDbContext<ShopContext>(o => o.UseSqlite("DataSource=:memory:"));

            services.RemoveAll<IEmailSender>();
            services.AddSingleton<IEmailSender, RecordingEmailSender>();
        });
    }
}

public sealed class OrdersApiTests : IClassFixture<ApiFactory>
{
    private readonly ApiFactory _factory;
    public OrdersApiTests(ApiFactory factory) => _factory = factory;

    [Fact]
    public async Task Post_CreatesOrder()
    {
        using HttpClient client = _factory.CreateClient();
        var response = await client.PostAsJsonAsync("/api/orders", new CreateOrderRequest("ABC-1", 2));

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        var body = await response.Content.ReadFromJsonAsync<OrderResponse>();
        body!.Sku.Should().Be("ABC-1");
        response.Headers.Location.Should().NotBeNull();
    }
}
💡
The factory runs the real middleware pipeline, routing, model binding, serialisation and filters in memory, so an integration test catches the wiring mistakes a unit test cannot see. Keep a small number of them covering the endpoints and the container configuration, and push the detail into fast unit tests.

FAQ

How much coverage is enough?
Coverage measures which lines ran, not whether the assertions are meaningful. Aim to cover the branching logic of your domain code and every endpoint once, then use coverage to find whole areas with no test rather than chasing a percentage.
Why does my test pass locally and fail in CI?
Different culture, different time zone, parallelism, or a shared resource such as a fixed port. Set the culture explicitly in the test project and inject TimeProvider so the test controls time.

Data access with Entity Framework Core Modern C# 12 to 14 features and performance

Last refreshed 2026-09-18.