A Test Strategy for an API

Decide what to unit test and what to integration test in an ASP.NET Core API, keep the suite fast and deterministic, and run it in CI.

Syntaxtests/ Shop.Domain.UnitTests/ pricing, state rules Shop.Api.IntegrationTests/ WebApplicationFactory + Testcontainers dotnet test --project tests/Shop.Api.IntegrationTests

An HTTP API is mostly wiring: routes, binding, validation, authorization, serialization, a database. Bugs live in that wiring as often as in the logic, which is why the classic pyramid of many unit tests and few integration tests fits APIs badly. A good API suite is shaped by where the risk is.

What goes where

LayerCoversTypical speed
Unit testsPricing, discounts, state machines, date rules, handlers with real branchingMicroseconds each; thousands per second
Integration tests (WebApplicationFactory, real database in Testcontainers)Every endpoint's status codes, validation, auth matrix, JSON contract, persistence and constraintsMilliseconds per request once the host and container are up
Contract testsThe OpenAPI document or the JSON your clients depend onOne request plus a file compare
Smoke tests after deployThe deployed service starts, reaches its database and answers /health/readySeconds, a handful of checks

For most APIs the integration layer carries the weight: at least one test per endpoint and outcome. Unit tests go where there is logic dense enough to need dozens of cases. Do not unit test what the framework does (that [Required] rejects null) or what an integration test covers better (a handler whose only logic is calling the repository).

Speed

The expensive parts are host startup and containers, not requests. Start each once and share them: one WebApplicationFactory per class or collection, one PostgreSQL container per run, unique data per test so classes can run in parallel. The usual slow-suite causes are WithWebHostBuilder inside every test, a container per test class, and sleeps. A pull request suite that finishes in a few minutes gets run; one that takes 40 minutes gets skipped locally.

Flakiness

A test that fails one run in twenty is a bug, either in the test or in the code. The causes are few and fixable: shared rows or static state between parallel classes (unique data per test), real time (TimeProvider), waits with fixed sleeps (poll with a deadline), dependence on test order, real network calls (stub the handler), and culture (CultureInfo.InvariantCulture). Automatic retries hide all of these, including the real race in the code that the flaky test was trying to tell you about. Quarantine a flaky test with an issue and an owner, fix it within days, and never let the quarantine list grow.

Running in CI

Run unit and integration tests on every pull request, as separate steps with the unit tests first, so the fast failures report first. The rest of the job (SDK pinning, locked restore, package cache, one Release build, timeout, uploading results) is the one in the Build and Test in CI lesson. Coverage is a useful map of untested endpoints, not a target: 90% line coverage from tests without assertions proves nothing. Treat a red main branch as an outage.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Split a 900-test suite so 700 domain unit tests report in 15 seconds and 200 integration tests share one host and one PostgreSQL container.
  • Replace a nightly retry-until-green job with a quarantine list where each flaky test has an issue, an owner and a fix date.
  • Fail a pull request that silently renames the total property of GET /orders/{id} by diffing the OpenAPI document against a committed snapshot.

More examples

Unit tests before integration tests in CI

Two steps instead of one <code>dotnet test</code> on the solution: the unit tests fail in seconds, and the integration step, with its host and containers, only runs when they pass. Both reuse the single Release build through <code>--no-build</code> and write TRX files to the same folder, so the upload step of the full job (which runs on <code>${{ !cancelled() }}</code>) shows every failing test by name.

Example · yaml
# The test steps of the job from the Build and Test in CI lesson; checkout, SDK setup,
# locked restore, the Release build and the TRX upload stay exactly as shown there.
- name: Unit tests
  run: dotnet test --project tests/Shop.Domain.UnitTests -c Release --no-build --results-directory TestResults --report-xunit-trx
- name: Integration tests
  run: dotnet test --project tests/Shop.Api.IntegrationTests -c Release --no-build --results-directory TestResults --report-xunit-trx

A flaky test and its deterministic fix

The flaky version shares a static instance between tests and compares against <code>DateTime.Now</code>, so it depends on test order, on the time the run happens and on the agent's time zone. The fix gives each test its own instance and its own clock, so the expected values are literals. It also exposes a real product question the flaky test hid: should the sequence reset at midnight UTC? The test now documents that it does not.

Example · csharp
using System.Globalization;
using Microsoft.Extensions.Time.Testing;
using Xunit;

public sealed class OrderNumbers(TimeProvider time)
{
    private int _sequence;

    // SO-20260302-0001: the date part comes from the injected clock, not DateTime.Now.
    public string Next() =>
        $"SO-{time.GetUtcNow():yyyyMMdd}-{Interlocked.Increment(ref _sequence).ToString("D4", CultureInfo.InvariantCulture)}";
}

public sealed class OrderNumberTests
{
    // Flaky version, kept as a warning:
    //   static readonly OrderNumbers Shared = new(TimeProvider.System);
    //   Assert.Equal($"SO-{DateTime.Now:yyyyMMdd}-0001", Shared.Next());
    // It fails when another test used Shared first, when the run crosses midnight,
    // and on agents whose local date differs from UTC.

    [Fact]
    public void First_number_of_the_day_is_0001()
    {
        var clock = new FakeTimeProvider(new DateTimeOffset(2026, 3, 2, 23, 59, 59, TimeSpan.Zero));
        var numbers = new OrderNumbers(clock);

        Assert.Equal("SO-20260302-0001", numbers.Next());
        clock.Advance(TimeSpan.FromSeconds(2));
        Assert.Equal("SO-20260303-0002", numbers.Next());
    }
}

An OpenAPI contract test that fails on accidental API changes

The document is generated from the running app, so it reflects routes, parameters, status codes and schemas as they really are. Comparing with <code>JsonNode.DeepEquals</code> ignores whitespace and property order. When a change is intended, the developer regenerates the file and the pull request shows the contract diff to reviewers, which is where a renamed property or a removed status code gets noticed before clients break.

Example · csharp
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public sealed class OpenApiContractTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    // Committed file; regenerate on purpose with UPDATE_OPENAPI_SNAPSHOT=1 dotnet test.
    private static readonly string SnapshotPath =
        Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "openapi", "v1.json");

    [Fact]
    public async Task Published_contract_matches_the_committed_snapshot()
    {
        using var client = factory.CreateClient();
        var actual = JsonNode.Parse(await client.GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken))!;
        var pretty = actual.ToJsonString(new JsonSerializerOptions { WriteIndented = true });

        if (Environment.GetEnvironmentVariable("UPDATE_OPENAPI_SNAPSHOT") == "1")
        {
            await File.WriteAllTextAsync(SnapshotPath, pretty, TestContext.Current.CancellationToken);
            return;
        }

        var expected = JsonNode.Parse(await File.ReadAllTextAsync(SnapshotPath, TestContext.Current.CancellationToken));
        Assert.True(JsonNode.DeepEquals(expected, actual),
            "The OpenAPI document changed. If the change is intended, regenerate the snapshot and review the diff in the pull request.");
    }
}

// Stand-in so this file compiles on its own. Delete it in a real test project: in .NET 10 the API's
// generated Program is already public, and a Program declared here would shadow it (CS0436).
public partial class Program;

Discussion

  • Be the first to comment on this lesson.