# ADR: The tests, explained for a new developer

Status: accepted, 2026-09-02, shipped as 1.0.0.27. The third record written
at Steve's request for a developer new to the stack: the first two walk
Program.cs and the React configuration; this one walks the three test
suites, how each is built, and why there are three.

## In plain words

There are three kinds of tests, and this page says which one to reach for: unit tests for plain rules, integration tests for anything that needs the running server, and browser tests for anything a user clicks. Every push runs all three, and nothing reaches the live site without them.

What that is worth: a developer knows where a new test goes and can trust a green build, and the organization ships changes without a manual test pass.

## Context

The repository carried 139 xunit tests under `api/TheYard.Tests`, 36
Vitest tests beside the code they test under `src/`, and 25 Playwright
tests under `tests/e2e` when this was written; the README carries the
counts as they stand, held to the suites by a test, and the addendum below
says how the wall of them runs today. Every push runs all three in CI, and nothing
ships to the live site without them (ADR: Docs and testing). A newcomer
sees three runners, three folders and three vocabularies, and the
question is which one to reach for when something changes. The short
answer: the suite is chosen by what the code depends on. Pure rules get
unit tests. Anything that needs the host gets an integration test through
the real host. Anything that needs a browser gets a browser.

## The walk

### xunit: a Fact is one case, a Theory is a table

A `[Fact]` is a test with no parameters. A `[Theory]` takes rows of
`[InlineData]` and runs once per row, which is how a rule with tiers is
covered without six copies of the same test. Assertions are the static
`Assert` class; the test's name is a sentence with underscores, so a
failing run reads like a report. The domain tests need no host and no
file: `BidRules` is a static class of pure functions, so the test hands it
values and checks the answer.

```csharp
// A Theory runs once per InlineData row: six tiers, one test. The rule under
// test is a pure function in the Domain project, so no host, no file and no
// clock are needed; the Fact below covers the two edges of the same rule.
[Theory]
[InlineData(3500, 100)]
[InlineData(4999, 100)]
[InlineData(5000, 250)]
[InlineData(19999, 250)]
[InlineData(20000, 500)]
[InlineData(77000, 500)]
public void Increments_are_tiered(int currentBid, int expected)
{
    Assert.Equal(expected, BidRules.Increment(currentBid));
}

[Fact]
public void Min_next_bid_adds_the_increment_or_is_the_opening_ask()
{
    Assert.Equal(23_300, BidRules.MinNextBid(TestData.Vehicle(currentBid: 22_800)));
    Assert.Equal(14_500, BidRules.MinNextBid(TestData.Vehicle(currentBid: null)));
}
```
*Live from [`api/TheYard.Tests/BidRulesTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/BidRulesTests.cs#L26-L46), region `tiers`, read from this build at 1d6b50b.*

### Builders instead of fixtures files

Every test that needs a vehicle asks `TestData.Vehicle(...)` for one and
overrides only the fields it cares about. A test reads as its intent
(`currentBid: null`, `bodyStyle: "van"`) instead of a forty-line object,
and a new required field on `Vehicle` is added in one place. The clock
helper does the same for time: an `AuctionClock` anchored to a chosen
midnight, so a test never depends on when it runs.

```csharp
// Builders, not fixture files: a test asks for a Vehicle and overrides only
// the fields it cares about, so the test reads as its intent and a new
// required field on the record is added here once. The clock helper anchors
// time to a chosen midnight, so no test depends on when it runs.
/// <summary>An AuctionClock anchored at midnight of <paramref name="now"/>'s own day and offset.</summary>
public static AuctionClock ClockAt(DateTimeOffset now) =>
    new(
        now.ToUnixTimeMilliseconds(),
        new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, now.Offset).ToUnixTimeMilliseconds());

/// <summary>A valid vehicle with overridable fields tests care about.</summary>
public static Vehicle Vehicle(
    string id = "test-id",
    string make = "Ford",
    string bodyStyle = "SUV",
    IReadOnlyList<string>? images = null,
    int? currentBid = 22800) => new()
    {
        Id = id,
        Vin = "TRD7L1KS0HNB5X3K3",
        Year = 2023,
        Make = make,
        Model = "Bronco",
        Trim = "Big Bend",
        BodyStyle = bodyStyle,
        ExteriorColor = "Burgundy",
        InteriorColor = "Beige",
        Engine = "2.7L EcoBoost V6",
        Transmission = "automatic",
        Drivetrain = "4WD",
        OdometerKm = 47731,
        FuelType = "gasoline",
        ConditionGrade = 3.8,
        ConditionReport = "Average condition.",
        DamageNotes = ["Scratch on liftgate"],
        TitleStatus = "clean",
        Province = "Ontario",
        City = "Toronto",
        AuctionStart = "2026-04-05T14:00:00",
        StartingBid = 14500,
        ReservePrice = 25000,
        BuyNowPrice = null,
        Images = images ?? ["https://placehold.co/800x600"],
        SellingDealership = "King City Auto",
        Lot = "A-0043",
        CurrentBid = currentBid,
        BidCount = 16,
    };
```
*Live from [`api/TheYard.Tests/TestData.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/TestData.cs#L9-L56), region `builders`, read from this build at 1d6b50b.*

### Fakes at the ports

`InventoryService` takes an `IVehicleSource` and an `IPhotoManifestSource`.
In production those are the JSON file adapters; in a test they are two
tiny classes declared in the test file with the `file` modifier, which
keeps them invisible to every other file. That is the onion architecture
paying for itself: the service is tested with no filesystem, and the
`LoadCalls` counter proves the dataset is read once.

```csharp
// Fakes at the ports. The `file` modifier keeps these two classes private to
// this file; each stands in for a JSON adapter, so the service is tested with
// no filesystem, and LoadCalls proves the dataset is read exactly once.
file sealed class FakeVehicles(params Vehicle[] vehicles) : IVehicleSource
{
    public int LoadCalls { get; private set; }

    public Task<IReadOnlyList<Vehicle>> LoadAsync()
    {
        LoadCalls++;
        return Task.FromResult<IReadOnlyList<Vehicle>>(vehicles);
    }
}

file sealed class FakeManifest(params PhotoEntry[] photos) : IPhotoManifestSource
{
    public Task<IReadOnlyList<PhotoEntry>> LoadAsync() => Task.FromResult<IReadOnlyList<PhotoEntry>>(photos);
}

/// <summary>A source that is down for its first call and up after: the store at startup, sometimes.</summary>
file sealed class FlakyVehicles(params Vehicle[] vehicles) : IVehicleSource
{
    public int LoadCalls { get; private set; }

    public Task<IReadOnlyList<Vehicle>> LoadAsync()
    {
        LoadCalls++;
        return LoadCalls == 1
            ? Task.FromException<IReadOnlyList<Vehicle>>(new IOException("the store is not answering"))
            : Task.FromResult<IReadOnlyList<Vehicle>>(vehicles);
    }
}

/// <summary>Holds the inventory service: image rewriting, retrying a failed load, lookup by id, loading once, filtered and paged search, and facets.</summary>
public class InventoryServiceTests
{
    [Fact]
    public void Rewrites_images_to_the_configured_prefix()
    {
        var service = new InventoryService(
            new FakeVehicles(TestData.Vehicle(bodyStyle: "SUV")),
            new FakeManifest(TestData.SuvPool),
            "/api/images");

        var vehicle = Assert.Single(service.GetAll());
        Assert.Equal(PhotoGallery.GallerySize, vehicle.Images.Count);
        Assert.All(vehicle.Images, url => Assert.StartsWith("/api/images/suv-", url));
    }
```
*Live from [`api/TheYard.Tests/InventoryServiceTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/InventoryServiceTests.cs#L8-L55), region `fakes`, read from this build at 1d6b50b.*

### Integration tests boot the real host in memory

`WebApplicationFactory<Program>` starts Program.cs inside the test process
with an in-memory server, so `_client.GetAsync("/api/vehicles")` runs the
real routing, binding, services and serializer with no port and no
process. `IClassFixture` shares one host across a class, which is why the
bid lifecycle has a class of its own: its bids would otherwise leak into
the read-only tests. The `public partial class Program;` line at the end
of Program.cs exists for this factory (ADR: Program.cs, explained).

```csharp
// WebApplicationFactory<Program> boots Program.cs inside the test process with
// an in-memory server: real routing, binding, services and serializer, no port.
// IClassFixture shares that one host across the class.
/// <summary>
/// The bid lifecycle through the real host. Its own fixture class, so the
/// mutable bid state can't leak into the read-only integration tests.
/// </summary>
public class BidFlowIntegrationTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
    // Bidding needs an account now (ADR: Accounts and per-user bids). A field
    // initialiser cannot await, so registering happens in InitializeAsync,
    // which xunit runs once before the first test in the class. That keeps
    // these tests about the bidding rules rather than about registration.
    private HttpClient _client = null!;

    public async Task InitializeAsync() => _client = await Buyers.SignedIn(factory);

    public Task DisposeAsync()
    {
        _client.Dispose();
        return Task.CompletedTask;
    }

    private async Task<JsonDocument> GetAsync(string url) =>
        JsonDocument.Parse(await _client.GetStringAsync(url));

    [Fact]
    public async Task Bid_lifecycle_place_verify_buy_now_and_reset()
    {
        // One clock for the whole test, and it is the server's (ADR: Three
        // readers with no memory of the project, the addendum on the clock).
        // Pick a live vehicle sorted by most bids, because its window ends hours or
        // days out, so it cannot flip to ended mid-test.
        using var live = await GetAsync("/api/vehicles?status=live&sort=most-bids&limit=50");
        // And not one anybody has bought: a sold vehicle takes no bid (ADR:
        // Accounts and per-user bids, the addendum on the second buyer), and on
        // the document store the test containers remember yesterday's runs.
        var target = live.RootElement.GetProperty("vehicles").EnumerateArray()
            .First(vehicle => !vehicle.GetProperty("sold").GetBoolean());
        string id = target.GetProperty("id").GetString()!;
        int min = target.GetProperty("min_next_bid").GetInt32();
        int bidCount = target.GetProperty("bid_count").GetInt32();

        // Place a bid at the minimum.
        var placed = await _client.PostAsJsonAsync($"/api/vehicles/{id}/bids",
            new { amount = min });
        Assert.Equal(HttpStatusCode.OK, placed.StatusCode);
        using var placedJson = JsonDocument.Parse(await placed.Content.ReadAsStringAsync());
        Assert.Equal("accepted", placedJson.RootElement.GetProperty("kind").GetString());
        Assert.Equal(bidCount + 1,
            placedJson.RootElement.GetProperty("bid").GetProperty("bid_count").GetInt32());

        // The single-vehicle read reflects the bid and a raised minimum.
        using var after = await GetAsync($"/api/vehicles/{id}");
        Assert.Equal(min, after.RootElement.GetProperty("current_bid").GetInt32());
        Assert.True(after.RootElement.GetProperty("min_next_bid").GetInt32() > min);

        // Rebidding below the new minimum is rejected server-side.
        var tooLow = await _client.PostAsJsonAsync($"/api/vehicles/{id}/bids",
            new { amount = min });
        Assert.Equal(HttpStatusCode.BadRequest, tooLow.StatusCode);
```
*Live from [`api/TheYard.Tests/BidFlowIntegrationTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/BidFlowIntegrationTests.cs#L9-L70), region `lifecycle`, read from this build at 1d6b50b.*

Two habits in that test are worth copying. The anchor is captured once and
sent with every request, so the whole test sees one clock. And the target
vehicle is chosen by sorting live auctions by most bids: its window ends
hours or days out, so it cannot flip to ended halfway through the test.

### Vitest: the browser's logic without a browser

`src/lib` is plain TypeScript, so its tests run in Node in milliseconds.
The interesting ones are the cache tests: `vi.stubGlobal('fetch', ...)`
replaces the network with a counter, and fake timers let a five-minute
TTL expire in a line. Vitest is configured in `vite.config.ts` (ADR: The
React configuration, explained), and the include pattern keeps it to
`src/**/*.test.ts`.

```ts
// The network is a counter here: vi.stubGlobal replaces fetch with a mock that
// answers one page, and fake timers let the five-minute TTL expire in a line.
// Each test asserts how many times the "network" was touched.
describe('fetchVehicles caching', () => {
  const okResponse = () =>
    ({
      ok: true,
      json: async () => ({ total: 1, vehicles: [{ id: 'v1' }] }),
    }) as unknown as Response;

  beforeEach(() => {
    clearVehicleCache();
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-08-16T12:00:00'));
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.unstubAllGlobals();
  });

  it('serves a repeated query from the cache', async () => {
    const fetchMock = vi.fn().mockImplementation(async () => okResponse());
    vi.stubGlobal('fetch', fetchMock);

    await fetchVehicles();
    await fetchVehicles();

    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it('caches per query-parameter combination, including sort', async () => {
    const fetchMock = vi.fn().mockImplementation(async () => okResponse());
    vi.stubGlobal('fetch', fetchMock);

    await fetchVehicles({ ...EMPTY_FILTERS, make: 'Ford' });
    await fetchVehicles({ ...EMPTY_FILTERS, make: 'Ford' }, { sort: 'price-asc' });
    await fetchVehicles({ ...EMPTY_FILTERS, make: 'Ford' });

    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it('expires entries after the TTL', async () => {
    const fetchMock = vi.fn().mockImplementation(async () => okResponse());
    vi.stubGlobal('fetch', fetchMock);

    await fetchVehicles();
    vi.advanceTimersByTime(5 * 60 * 1000 + 1);
    await fetchVehicles();

    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it('asks the API with the query string and never with the cache key', async () => {
    // The cache key carries the UTC day in front of the query string, and
    // the first cut of that sent the key as the URL: every listing request
    // went out as /api/vehicles?20706:offset=100, the server ignored what it
    // could not read, and Load more appended the first page again (the
    // 1.0.0.112 gate, take one).
    const fetchMock = vi.fn().mockImplementation(async () => okResponse());
    vi.stubGlobal('fetch', fetchMock);

    await fetchVehicles();
    await fetchVehicles({ ...EMPTY_FILTERS, make: 'Ford' }, { sort: 'price-asc', offset: 100 });

    expect(fetchMock.mock.calls[0][0]).toBe('/api/vehicles');
    expect(fetchMock.mock.calls[1][0]).toBe('/api/vehicles?make=Ford&sort=price-asc&offset=100');
  });

  it('forgets everything cached before UTC midnight once the day turns', async () => {
    const fetchMock = vi.fn().mockImplementation(async () => okResponse());
    vi.stubGlobal('fetch', fetchMock);

    vi.setSystemTime(new Date('2026-08-16T23:59:30Z'));
    await fetchVehicles();
    vi.setSystemTime(new Date('2026-08-17T00:00:30Z'));
    await fetchVehicles();

    expect(fetchMock).toHaveBeenCalledTimes(2);
  });
```
*Live from [`src/lib/data.test.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/data.test.ts#L60-L139), region `cache-tests`, read from this build at 1d6b50b.*

### Playwright: the real stack, one test at a time

The end-to-end suite drives the real Chrome against both servers, which
the config starts itself, so `npm run test:e2e` needs nothing running
first. Bids mutate shared API state, so the suite runs serially and resets
the bids before every test. Locators are by role and accessible name
(`getByRole('button', { name: 'Load more vehicles' })`), which tests what
a visitor sees and keeps the suite honest about accessibility. The URL is
an assertion surface of its own, because the address bar is the app's
state (ADR: The React configuration, explained).

```ts
// Locators are by role and accessible name, so the test sees what a visitor
// sees; the URL is asserted at every step because the address bar is the
// app's state, and page.goBack() is the real Back button.
test('tile clicks are GET navigation: URL updates, Back works, deep links restore', async ({
  page,
}) => {
  await openTheYard(page, '/?status=live&sort=most-bids');
  await page.waitForSelector('article');

  // Opening a tile pushes a history entry with ?vehicle={id}.
  await page.locator('article h3 button').first().click();
  await expect(page).toHaveURL(/vehicle=/);
  await expect(page.getByText('Specifications')).toBeVisible();
  // The catalogue is synthetic and the photographs are vendored stock chosen
  // for the body style, so the page says so where somebody looking at a Tesla
  // listing with another manufacturer's SUV in it would otherwise draw their
  // own conclusion.
  await expect(page.getByText('Not photographs of this vehicle.')).toBeVisible();
  const detailUrl = page.url();

  // The browser's Back button returns to the filtered list.
  await page.goBack();
  await expect(page).not.toHaveURL(/vehicle=/);
  await expect(page).toHaveURL(/status=live/);
  await expect(page.locator('article').first()).toBeVisible();

  // A cold load of the detail URL deep-links straight into the detail view.
  await openTheYard(page, detailUrl);
  await expect(page.getByText('Specifications')).toBeVisible();

  // The in-app back control from a deep link swaps to the list without exiting.
  await page.getByRole('button', { name: 'Back to inventory' }).click();
  await expect(page).not.toHaveURL(/vehicle=/);
  await expect(page.locator('article').first()).toBeVisible();
});
```
*Live from [`tests/e2e/smoke.spec.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/tests/e2e/smoke.spec.ts#L54-L88), region `get-navigation`, read from this build at 1d6b50b.*

```ts
// Playwright starts both servers itself, so `npm run test:e2e` needs nothing
// running first, and reuses ones already up, so it also works mid-development.
// Each server is considered ready when a real URL answers, not after a sleep.
webServer: [
  {
    // The ship gate has just built the API, so it says so and the server
    // starts without building it again, ten seconds twice per gate
    // (ADR: The five-minute gate). Everywhere else the build is wanted.
    command: process.env.YARD_API_PREBUILT
      ? 'dotnet run --no-build --project api/TheYard.Api'
      : 'npm run api',
    // The simulated room waits twenty seconds before answering a bid
    // (ADR-027). Zero here so the outbid test watches a lead change hands
    // instead of watching a clock. Nothing else sets this.
    // And a key for the visitor rows on the Admin tab, so the browser
    // suite can prove the table exists behind it and nowhere else
    // (ADR: Site activity, and the line an address does not cross).
    env: {
      Market__GraceSeconds: '0',
      Admin__Key: 'e2e-admin-key',
      Admin__VisitorRows: 'true',
      Logs__DrainSeconds: '2',
    },
    url: 'http://localhost:5210/api/facets',
    reuseExistingServer: true,
    timeout: 120_000,
  },
  {
    // The ship gate has just run vite build, so it serves that bundle with
    // vite preview: one script and one sheet per page instead of Vite
    // compiling and serving the module graph file by file to every page the
    // suite opens (ADR: The five-minute gate). Everywhere else, the dev server.
    command: process.env.YARD_WEB_PREVIEW
      ? 'npx vite preview --port 5173 --strictPort'
      : 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: true,
    timeout: 120_000,
  },
  // The rendering service (ADR: A rendering service beside the API), when its build is made
  // (npm run build:render) and asked for: in front of the built site above, which stands in for
  // the API it reads and for the edge, so a page it draws is taken over by the bundle that site
  // serves. drawn.spec.ts opens it on 5220 and skips those pages when it is not running.
  ...(process.env.YARD_RENDER
    ? [
        {
          command: 'node render/server.mjs',
          env: {
            PORT: '5220',
            YARD_SITES: 'sql=http://localhost:5173',
            YARD_PASS_ORIGIN: 'http://localhost:5173',
            // The suite's machine runs four browsers, the API and the site at once, and a list read
            // there can pass the live deadline; the pages here are drawn with every read in.
            YARD_DEADLINE_MS: '20000',
          },
          url: 'http://localhost:5220/readyz',
          reuseExistingServer: true,
          timeout: 120_000,
        },
      ]
    : []),
],
```
*Live from [`playwright.config.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/playwright.config.ts#L70-L131), region `web-servers`, read from this build at 1d6b50b.*

### The same three, in CI and before every ship

Five CI jobs on every push (the live block below shows them as they are):
the style checks, the frontend suite, the .NET suite with its coverage
count, the database project's build, and the browser suite, which installs
Chrome first. The frontend job also runs `tsc -b` and the production build,
so a type error fails the run before any test does.

```yaml
# Five jobs on a pull request: style first, then one per suite and the SQL
# project behind it. The suites are separate so a failing
# browser test does not hide a failing unit test, and so the slowest one (the
# end-to-end job, which installs Chrome) does not gate the other two. The
# frontend job also typechecks and builds: a type error should fail before a
# test does.
jobs:
  # #region style-job
  # Format drift fails the build rather than getting argued about in review.
  # It runs first and on its own because it is the cheapest job here: a style
  # failure should not cost the time of a browser suite to discover.
  #
  # dotnet format checks three things at once against .editorconfig: whitespace,
  # using order, and unused usings. Prettier owns the TypeScript and the CSS.
  # oxlint owns the rules a formatter cannot see. The typecheck in the frontend
  # job already enforces noUnusedLocals and noUnusedParameters, so the linter is
  # not asked to repeat it (ADR-028).
  style:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x
      - run: npm ci
      - run: npm run format:check
      - run: npm run lint
      - run: npm run format:api:check
  # #endregion style-job

  frontend:
    # The style job's comment says it runs first so a missing semicolon does not
    # cost a browser suite to discover. That was a description of an intention
    # until this line existed: without `needs` these all start at once and the
    # claim was decoration (the staff review, 2026-09-03).
    needs: style
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx tsc -b
      - run: npm test
      - run: npm run build

  api:
    needs: style
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x
      # #region coverage
      # The suites are counted as well as run.
      #
      # A repository can say "276 tests" and mean very little by it. What a
      # reader wants to know is what they touch, and coverlet is already a
      # dependency of the test project, so the number costs one flag.
      #
      # The floor is a ratchet rather than a target. It sits just under where
      # the suites actually are, so a change that quietly stops testing
      # something fails here, and it is not set so high that somebody is
      # tempted to write a test that executes a line without asserting on it.
      # A number chased for its own sake buys nothing.
      # Output kept, and named on a failure, for the same reason the browser
      # suite does it: a red job whose only public word is "exit code 1" costs
      # somebody a session, and the full log needs a sign-in this repository
      # cannot assume its reader has. This job went red once on a commit that
      # changed nothing but documentation, and there was no way to say which
      # test without one.
      # The transcript goes to the runner's temp directory, not into the
      # checkout. A test in this suite reads every text file in the repository
      # and holds it to the house rule about em dashes; the console logger this
      # job asks for prints each test's name with its parameters, one of which
      # is the em dash that rule forbids, so a transcript written beside the
      # source made the suite fail on itself, on the runs where the line was
      # flushed before the scan reached it and not on the others
      # (ADR: Where a gate lives, addendum).
      # Everything except the tests that need the real Cosmos DB account. This
      # runner has no Azure credential and is not getting one, so those run on
      # the ship gate on Steve's machine as the signed-in principal; here they
      # are filtered out by their trait rather than skipped, because a skipped
      # test is a green check that checked nothing (ADR: A second store on
      # Cosmos DB, and what it costs).
      # And the coverage floor measures what this runner runs. The document
      # store's adapters are exercised by those filtered tests and by the whole
      # suite booted on the real account on the ship gate, none of which can
      # happen here, so that one assembly is left out of the number this job
      # holds to its floor. The first run with it counted went red at 83.2 per
      # cent of lines for code that was tested an hour earlier on a machine
      # with a credential. Lowering the floor would have hidden the same gap in
      # every other project; naming the assembly hides only the one this runner
      # cannot reach, with the reason beside it.
      # And code a source generator writes is left out by its GeneratedCode
      # attribute: the regular expressions and the OpenAPI comment readers are
      # written by the compiler at build time, nobody here writes or reviews
      # them, and they were a third of every branch counted. Counted, they held
      # the number down; dropped, the floor measures the code people write
      # (ADR: Counting what the tests cover, the addendum on generated code).
      - name: Run the .NET suites
        run: |
          set -o pipefail
          dotnet test api --collect:"XPlat Code Coverage" --results-directory TestResults \
            --filter "Store!=cosmos" \
            --logger 'console;verbosity=normal' \
            -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[TheYard.Infrastructure.Cosmos]*" \
               DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute="GeneratedCodeAttribute" \
            2>&1 | tee "$RUNNER_TEMP/dotnet-output.txt"
      - name: Say which test failed, on the run page
        if: failure()
        run: |
          if [ ! -f "$RUNNER_TEMP/dotnet-output.txt" ]; then
            echo "::error::the .NET suites left no output to report"
            exit 0
          fi
          # Three lines after each marker, because the marker is the label and
          # the detail is underneath it: "Assert.Contains() Failure" says which
          # assertion and the line below it says what was actually in the
          # collection, and that second line is the one somebody needs.
          grep -E -A 3 '^[[:space:]]*(Failed |Error Message:)' "$RUNNER_TEMP/dotnet-output.txt" \
            | grep -v '^--$' \
            | head -n 24 \
            | while IFS= read -r line; do
                printf '::error::%s\n' "$(printf '%s' "$line" | tr -d '\r' | cut -c1-400)"
              done
          # dotnet writes its totals two different ways depending on the
          # logger: one 'Passed! - Failed: 0, Passed: 281 ...' line, or a
          # four-line 'Test Run Failed. / Total tests: ...' block. The
          # console logger this job asks for produces the second, so a
          # pattern that only knew the first printed nothing at all.
          grep -E '^[[:space:]]*(Passed!|Failed!|Test Run (Failed|Succeeded)\.|Total tests:|(Passed|Failed|Skipped):[[:space:]]+[0-9]+)' "$RUNNER_TEMP/dotnet-output.txt" \
            | head -n 6 | while IFS= read -r line; do
            printf '::error::%s\n' "$(printf '%s' "$line" | tr -d '\r' | cut -c1-400)"
          done
          {
            echo '## The .NET suites failed'
            echo
            echo '```'
            grep -E -A 6 '^[[:space:]]*Failed ' "$RUNNER_TEMP/dotnet-output.txt" | head -n 80
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"
      - name: Count what the tests cover
        if: always()
        run: python3 .github/coverage.py TestResults 90.0 75.0

  # #region database-job
  # The database is compiled like everything else. api/TheYard.Database is the
  # authority for the SQL Server schema (ADR: Data first, and the database in
  # source control), and Microsoft.Build.Sql turns its .sql files into a DACPAC,
  # so a column with a type that does not exist or a foreign key pointing at
  # nothing fails here rather than during a publish.
  #
  # It is a job of its own and not a step in the api job, because `dotnet build`
  # on the solution does not build a SQL project. That was found the hard way:
  # a solution build reported success, the DACPAC on disk was the previous one,
  # and a schema change was published that did not contain the change.
  database:
    needs: style
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x
      - run: dotnet build api/TheYard.Database/TheYard.Database.sqlproj -c Debug
      - name: The build has to have produced a package
        run: test -f api/TheYard.Database/bin/Debug/TheYard.Database.dacpac
  # #endregion database-job

  e2e:
    needs: style
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x
      - run: npm ci
      - run: npx playwright install --with-deps chrome
      # #region e2e-evidence
      # Keep the output, and on a failure put it on the run page.
      #
      # The report artifact below is the full evidence, and downloading it needs
      # a GitHub login. The run page does not. A red build whose only public
      # words are "Process completed with exit code 1" costs somebody a session
      # to find out what broke, which is the same problem the html reporter was
      # added to solve, one step further out.
      #
      # pipefail so that tee does not swallow the exit code and turn a failing
      # suite into a passing step.
      - name: Run the browser suite
        run: |
          set -o pipefail
          npx playwright test 2>&1 | tee "$RUNNER_TEMP/playwright-output.txt"
      - name: Say what failed, on the run page
        if: failure()
        run: |
          # Two places, because they are readable by different people.
          #
          # The step summary is the nice one and it renders only for somebody
          # signed in. Annotations are the ones that survive: they come back
          # from the public API and show at the top of the run page, which is
          # what a person with no login to this repository can actually read.
          # The first attempt at this wrote only a summary and taught us that.
          if [ ! -f "$RUNNER_TEMP/playwright-output.txt" ]; then
            echo "::error::the browser suite left no output to report"
            exit 0
          fi
          # '[Tt]imeout of' rather than 'Timeout of': a test that runs out
          # of time says 'Test timeout of 90000ms exceeded.', which the
          # capital-only spelling missed, and that line is the whole
          # finding when the failure is a hang.
          grep -E '^[[:space:]]+[0-9]+\) |Error: |[Tt]imeout of |Timeout: |Locator: |Expected: |Received' "$RUNNER_TEMP/playwright-output.txt" \
            | head -n 20 \
            | while IFS= read -r line; do
                # One annotation per line, trimmed: a control character or a
                # newline inside one silently truncates the whole annotation.
                printf '::error::%s\n' "$(printf '%s' "$line" | tr -d '\r' | cut -c1-400)"
              done
          tail -n 4 "$RUNNER_TEMP/playwright-output.txt" | while IFS= read -r line; do
            printf '::error::%s\n' "$(printf '%s' "$line" | tr -d '\r' | cut -c1-400)"
          done
          {
            echo '## The browser suite failed'
            echo
            echo '```'
            tail -n 150 "$RUNNER_TEMP/playwright-output.txt"
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"
      # #endregion e2e-evidence
      - uses: actions/upload-artifact@v7
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
```
*Live from [`.github/workflows/ci.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/ci.yml#L46-L292), region `ci-jobs`, read from this build at 1d6b50b.*

## What to change when

- **A new business rule:** a domain test first, pure, with a Theory if the
  rule has tiers or boundaries; then the rule.
- **A new endpoint:** an integration test through the factory that asserts
  the status code and the wire shape, including the 400 path.
- **A new piece of browser logic:** put the logic in `src/lib` and unit
  test it; components render and are covered by the browser suite.
- **A new page behavior:** one Playwright test, by role, asserting the URL
  where the URL is the state; reset shared state in `beforeEach`.
- **A flaky test:** the cause is almost always time or shared state; anchor
  the clock, or give the test its own fixture class.
- **A slow test:** read the runner's per-test timings before touching
  anything; the first test in a class carries the class's boot, and a boot
  is a thousand vehicles here, not the site's hundred thousand, for the
  reason in ADR: The five-minute gate.

## Addendum, 2026-09-09: the wall runs in under five minutes

Every test application now boots a thousand vehicles instead of the site's
hundred thousand, the one class about the size asks for the full catalogue
by name, the accessibility scans run in parallel, and the ship gate runs the
node side and the .NET side at the same time, each suite once per store.
The measurements and the reasoning are ADR: The five-minute gate; nothing
here asserts less.

## Where it sits

The tests sit outside the onion, in TheYard.Tests and beside the front end code, and check each ring at its own level: Domain with fixed clocks, Application with hand-written fakes, and the host through WebApplicationFactory. The fakes at the ports show dependency inversion and Liskov substitution: InventoryService depends on IVehicleSource, so any implementation can stand in for another and a tiny fake replaces the JSON adapter with no surprises; OnionTests also holds the rings' inward direction in the gate. The cost is three runners and their vocabularies for a newcomer to learn, and browser tests that must keep their bids apart, because four browser workers share one set of bids. A mocking framework would earn its place only if the ports grew too wide to fake by hand.

## Files

- [`api/TheYard.Tests/TheYard.Tests.csproj`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/TheYard.Tests.csproj): xunit, the test SDK, `Microsoft.AspNetCore.Mvc.Testing` for the factory, and a global `using Xunit`.
- [`api/TheYard.Tests/TestData.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/TestData.cs): the builders.
- [`api/TheYard.Tests/BidRulesTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/BidRulesTests.cs), [`VehicleFilterTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/VehicleFilterTests.cs), [`AuctionScheduleTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/AuctionScheduleTests.cs), [`PhotoGalleryTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/PhotoGalleryTests.cs): the domain, pure.
- [`api/TheYard.Tests/InventoryServiceTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/InventoryServiceTests.cs) and [`BidServiceTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/BidServiceTests.cs): the application layer with fakes at the ports.
- [`api/TheYard.Tests/JsonFileSourceTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/JsonFileSourceTests.cs) and [`SyntheticVehicleSourceTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/SyntheticVehicleSourceTests.cs): the infrastructure, against the real files.
- [`api/TheYard.Tests/ApiIntegrationTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ApiIntegrationTests.cs), [`BidFlowIntegrationTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/BidFlowIntegrationTests.cs), [`AdminEndpointTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/AdminEndpointTests.cs), [`ProblemDetailsTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ProblemDetailsTests.cs), [`CacheHeaderTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/CacheHeaderTests.cs), [`DocumentationCatalogTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/DocumentationCatalogTests.cs), [`LiveSamplesTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/LiveSamplesTests.cs), [`DiagramPageTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/DiagramPageTests.cs), [`ChangelogTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ChangelogTests.cs): through the real host.
- [`src/lib/data.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/data.test.ts), [`inventory.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/inventory.test.ts), [`auction.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/auction.test.ts), [`format.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/format.test.ts), [`src/styles/colors.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.test.ts): Vitest.
- [`tests/e2e`](https://github.com/SteveStout/TheYard/tree/main/tests/e2e) and [`playwright.config.ts`](https://github.com/SteveStout/TheYard/blob/main/playwright.config.ts): the browser suite and the two servers it starts.
- [`.github/workflows/ci.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/ci.yml): the three jobs.
