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.
tests/
Shop.Domain.UnitTests/ pricing, state rules
Shop.Api.IntegrationTests/ WebApplicationFactory + Testcontainers
dotnet test --project tests/Shop.Api.IntegrationTestsAn 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
| Layer | Covers | Typical speed |
|---|---|---|
| Unit tests | Pricing, discounts, state machines, date rules, handlers with real branching | Microseconds each; thousands per second |
Integration tests (WebApplicationFactory, real database in Testcontainers) | Every endpoint's status codes, validation, auth matrix, JSON contract, persistence and constraints | Milliseconds per request once the host and container are up |
| Contract tests | The OpenAPI document or the JSON your clients depend on | One request plus a file compare |
| Smoke tests after deploy | The deployed service starts, reaches its database and answers /health/ready | Seconds, 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
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.
# 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-trxA 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.
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.
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