Architect
Standards
ReferenceNormative

Testing

Integration tests against real infrastructure are the gate; unit tests cover the rules that need no infrastructure. Mock-heavy adapter tests assert that the mocks were configured correctly.

Verified 2026-07-31 · Stack-independent. The Go acceptance tests and harness below were verified 2026-10-03 against a production Go monorepo.

Integration tests against real infrastructure are the gate; unit tests cover the rules that don't need infrastructure.

Mock-heavy "unit" tests of adapters assert that the mocks were configured correctly, and pass while production is broken — the classic green-suite, red-service failure. Containers made real dependencies cheap enough that mocking them is now a choice, and usually the wrong one.

The split

  • Integration tests — real database, real broker, real cache, via Testcontainers or the ecosystem equivalent. One shared base class or fixture wires the containers, an HTTP client, a test auth setup, a recording event publisher and a fake-data generator. Everything cross-process — HTTP contracts, persistence, transactions, events — is proven here.
  • Unit tests — domain rules, state transitions, validators, mappers, filters, serialisation, error branches. Fast, no container, and no mocking framework needed because the core has no I/O to mock. That is a payoff of hexagonal structure, not a separate discipline.
  • Functional / acceptance features (Gherkin) — written so a scenario reads without opening code. They belong to their own module or feature, boot that slice alone, and exercise only that slice's public API. No central e2e module — it becomes everyone's dumping ground and nobody's responsibility.

Go: acceptance tests on a fake app

In a Go service the integration and acceptance tests are one set: one acceptance_test.go per feature, run against the service booted in-process. The app has the production FX graph and routes, a clock stopped at a fixed instant, ids that count, and every outside dependency faked. Only storage switches: the test's in-memory stores apply when TEST_DATABASE_URL is unset or memory, and with a postgres:// URL they are left out and the real repositories run on a fresh temporary database, every migration applied. The test body never branches on the mode.

Adapted from a production service.

// newProjectsApp boots the service with one tenant, and returns the projects path of one of its
// workspaces. The table is in memory unless TEST_DATABASE_URL points at PostgreSQL, in which case
// the real repository runs instead.
func newProjectsApp(t *testing.T) (*testutil.FakeTestApp, string) {
	t.Helper()
	app := testutil.NewFakeTestApp(t,
		fx.Decorate(func(core.Repository) core.Repository { return memory.NewRepository() }),
	)
	app.CreateTenant(t, tenantID)
	return app, "/workspaces/wks_1/projects"
}

Adapted from a production service.

func TestProjects_AMemberCreatesAProjectStampedWithTheClockAndTheNextID(t *testing.T) {
	app, projects := newProjectsApp(t)
	token := testutil.TeamToken(t, app, tenantID, "member")
	want := map[string]any{
		"id":        "prj_00000000000000000001",
		"name":      "Website redesign",
		"country":   "PT",
		"status":    "active",
		"createdAt": "2026-01-02T03:04:05Z",
		"updatedAt": "2026-01-02T03:04:05Z",
	}

	e := app.HttpExpect(t)
	e.POST(projects, map[string]any{"name": "Website redesign", "country": "pt"}).
		WithTenant(tenantID).
		BearerAuth(token).
		Expect().IsCreated().
		JSON().Object().IsEqual(want)

	e.GET(projects + "/prj_00000000000000000001").
		WithTenant(tenantID).
		BearerAuth(token).
		Expect().IsOK().
		JSON().Object().IsEqual(want)
}
  • The name is the sentence a reviewer checks: TestProjects_ACountryThatIsNotTwoLettersIsRefusedAndNothingIsStored.
  • Exact asserts. The clock and the ids are injected (time and ids are dependencies), so the whole answer is compared with IsEqual, timestamps and ids as literals. The ids come from the counting generator, not a database sequence, so they are the same in both modes. A refusal asserts its code: JSON().Path("$.code").String().IsEqual("INVALID_COUNTRY").
  • Assert through the API, never by reading the store. A refusal test proves nothing was stored by listing through the API afterwards.
  • The in-memory store keeps the real repository's rules — scoping, uniqueness, ordering, soft delete — for everything a test asserts. It lives in outbound/memory, beside the real adapter, and no module provides it. A port the test did not replace reaches a data source that refuses every statement and fails the test naming the method.
  • What stays faked in both modes — email, file storage, push, identity, jobs, the message bus — belongs to the service's test wrapper, never to a test's stores.
  • Fast while building, real in the gate. make go-test pins memory and runs in seconds; the integration run passes a real URL and runs the same tests on PostgreSQL.

When not to: a rule with no I/O — a closed set's IsValid, a date calculation — gets a plain unit test with literal inputs. A behaviour only PostgreSQL has (a constraint race, a lock) is a database test of its own; the acceptance test may skip that case in memory mode, with the reason in the skip.

Rules that hold the line

  • A feature file is the specification, so its runner ships disabled. A scenario is enabled by the change that makes it pass — never before. Otherwise the suite is permanently red and everyone learns to ignore it.
  • Every endpoint needs coverage of four things: success, validation failure, authentication/authorisation failure where applicable, and each business conflict it can actually produce. A happy-path-only test is a smoke test wearing a suit.
  • Assert the generated API document, not just the responses. A documentation annotation is only safe to delete if a test demonstrably covers what replaced it.
  • Never infer a green build. Run it, read the output, and report what passed and what was skipped. See evidence.
  • Conventions get architecture tests, not documentation. ArchUnit, dependency-cruiser, eslint-boundaries, import-linter. Add the rule in the same commit as the convention.
  • A flaky test is a broken test. Quarantine and fix — never retry until green. Retries train the team to distrust every failure.

What the architecture lens adds

  • Every rule has an observable. The module reference states, for each requirement, what someone must be able to check. That sentence is the test's assertion, written before the test and in business language.
  • Tests cite requirement identifiers in their names. That citation is what makes coverage a query rather than an opinion, and it is the same join the work register uses.
  • Reversal cost decides depth. A test guarding something expensive to reverse — stored shape, money, confidentiality — earns real setup. One guarding something replaceable behind an interface you own does not.
  • One check that can fail is worth more than five that cannot. See verify in proportion to the blast radius.

Traps

Container startup dominating the suite → one reusable container set per run, not per class. Reuse mode locally, fresh in CI.

Test binaries owning infrastructure — per-package testcontainers with reuse-by-name, database-per-test cloning — produce provisioning races, stale reused state, and cached test results that hide real red.

The cure that held (measured 2026-08-12, Go + Postgres: 325s red → 30s green). One top-level harness owns the container and the single logical database, migrates once, and hands tests a URL. The fixture refuses Docker and refuses any non-test database by name.

  • Each test app gets its own temporary database on that server, every migration applied, dropped when the test ends. This replaced the transaction-per-test rollback (go-txdb) and private-schema split measured above.
  • Most feature tests never reach it: they run on in-memory stores until TEST_DATABASE_URL names a server — see acceptance tests on a fake app.

Two traps inside the cure: sequences do not roll back, so never assert literal serial ids; and concurrent packages speculatively inserting the same seed primary keys deadlock (40P01) — serialise database packages until seeds are pre-committed and a stress gate proves otherwise.

Tests sharing state through the database → each test owns its data and generates unique values. Do not rely on a seeded fixture nobody dares change.

Asserting on log output or wall-clock time → both are flake factories. Assert on recorded events and injected clocks instead.

Web (TanStack Start)

Verified 2026-10-03 · Against a production TanStack Start monorepo: vitest 4.1.11, happy-dom 20.11.6, @testing-library/react 16.3.0, @playwright/test 1.62.1, Biome 2.5.9. Excerpts are adapted, names changed. Blocks titled examples/web/… are those files in this handbook, which its gate type-checks and tests.

Components, hooks and routes are tested in vitest with happy-dom and Testing Library, with everything faked. No server, no database, no browser: what lies behind the feature's server functions is replaced with vi.mock, and the test uses a real QueryClient. A browser runs only in each app's Playwright journeys.

shared/web/ui/vitest.config.ts
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [viteReact()],
  test: { environment: "happy-dom" },
});

A package whose tests are mostly pure keeps environment: "node" and opts one file in with // @vitest-environment happy-dom on its first line.

A server-rendered route gets an SSR test beside it

Each route that renders data on the server has a -<name>-ssr.test.tsx beside it (the leading - keeps it out of the route tree). It runs the route's own loader, renders to a string, then dehydrates into a fresh client and proves the browser does not ask again:

examples/web/web/product/src/routes/projects/-projects-ssr.test.tsx
import {
  dehydrate,
  hydrate,
  QueryClient,
  QueryClientProvider,
  QueryObserver,
} from "@tanstack/react-query";
import { renderToString } from "react-dom/server";
import { expect, it, vi } from "vitest";
import { projectKeys, projectsQueryOptions } from "../../features/projects";
import { Route } from "./index";

const reads = vi.hoisted(() => ({
  listProjects: vi.fn(),
  listActivity: vi.fn(),
}));
// The real server functions run; Start's RPC layer, the session check and the service
// client are faked.
vi.mock("@tanstack/react-start", async () =>
  (await import("../../testing/fake-start")).fakeStart(),
);
vi.mock("@acme/auth/session.server", () => ({
  requireSession: async () => ({ user: { id: "usr_1" } }),
}));
vi.mock("../../lib/api/projects.server", () => ({
  sdk: () => ({
    projects: { list: reads.listProjects },
    activity: { list: reads.listActivity },
  }),
}));

type Loader = (match: {
  context: { queryClient: QueryClient };
}) => Promise<void>;
const runLoader = (queryClient: QueryClient) =>
  (Route.options.loader as unknown as Loader)({ context: { queryClient } });

function Page() {
  const Component = Route.options.component;
  return Component ? <Component /> : null;
}

it("renders the projects from the loader's cache and hydrates them without a second read", async () => {
  reads.listProjects.mockResolvedValue([
    { id: "prj_1", name: "Website relaunch" },
  ]);
  reads.listActivity.mockResolvedValue([]);
  const server = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  await runLoader(server);

  // 1. The loader seeded the exact key the screen reads.
  expect(server.getQueryData(projectsQueryOptions().queryKey)).toHaveLength(1);

  // 2. The data is in the FIRST server HTML.
  const html = renderToString(
    <QueryClientProvider client={server}>
      <Page />
    </QueryClientProvider>,
  );
  expect(html).toContain("Website relaunch");
  expect(reads.listProjects).toHaveBeenCalledTimes(1);

  // 3. Hydration reuses the server's copy instead of asking for it again.
  const browser = new QueryClient();
  hydrate(browser, dehydrate(server));
  const observer = new QueryObserver(browser, projectsQueryOptions());
  const stop = observer.subscribe(() => undefined);
  expect(observer.getCurrentResult().isFetching).toBe(false);
  expect(reads.listProjects).toHaveBeenCalledTimes(1);
  stop();
});

it("fails the loader when the projects cannot be read rather than rendering an empty list", async () => {
  reads.listProjects.mockRejectedValue(
    new Error("Projects service unavailable"),
  );
  const server = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  await expect(runLoader(server)).rejects.toThrow(
    "Projects service unavailable",
  );
  expect(server.getQueryData(projectKeys.list())).toBeUndefined();
});
  • Import the loader from the route module, never a copy — the test is about that loader.
  • Pre-seed the loader's other reads with setQueryData instead of mocking them: ensureQueryData serves what is cached, and exactly one live read is left for the test to count.
  • Assert nothing about a key you pre-seeded. It passes with the loader's read deleted, so it guards nothing.
  • Fake below the server functions, not the *.server.ts file itself, when a loader starts two reads at once: vitest 4.1 hands the real module to the second of two concurrent first imports of a mocked one. The example fakes Start's createServerFn, the session check and the service client, so the real server functions run.

When not to: a route with no loader data — a static page, a redirect — needs no SSR test; the sweep below still covers it.

A streamed section gets a streaming test

A route that streams a section proves four things beside the route (-<name>-streaming.test.tsx): the loader returns while the section is still reading; the server sends the frame and the section's skeleton first and its data later; the browser hydrates the section from the server's answer without a second request; and a failed section shows its own error while the frame stays.

examples/web/web/product/src/routes/projects/-projects-streaming.test.tsx (excerpt)
it("sends the frame and the section's skeleton first, then the section's data", async () => {
  const activity = deferred<{ id: string; text: string }[]>();
  reads.listProjects.mockResolvedValue([
    { id: "prj_1", name: "Website relaunch" },
  ]);
  reads.listActivity.mockReturnValue(activity.promise);
  const server = serverClient();
  await runLoader(server);

  const html = await stream(server);
  await tick();
  expect(html.sent()).toContain("Website relaunch");
  expect(html.sent()).toContain('aria-busy="true"');
  expect(html.sent()).not.toContain("Kickoff notes");

  activity.resolve([{ id: "act_1", text: "Kickoff notes" }]);
  expect(await html.finished()).toContain("Kickoff notes");
  expect(reads.listActivity).toHaveBeenCalledTimes(1);
});
examples/web/web/product/src/routes/projects/-projects-streaming.test.tsx (excerpt)
it("shows a failed section's error inside its boundary while the frame stays", async () => {
  const activity = deferred<{ id: string; text: string }[]>();
  reads.listProjects.mockResolvedValue([
    { id: "prj_1", name: "Website relaunch" },
  ]);
  reads.listActivity.mockReturnValue(activity.promise);
  vi.spyOn(console, "error").mockImplementation(() => undefined);
  const server = serverClient();
  await runLoader(server);
  const dehydrated = dehydrateAll(server);

  const html = await stream(server);
  activity.reject(new Error("Activity service unavailable"));
  const sent = await html.finished();
  // The server does not fail the page: the frame is there, the section keeps its skeleton
  // and React hands that boundary to the browser.
  expect(sent).toContain("Website relaunch");
  expect(sent).not.toContain("Recent activity could not be loaded");

  const browser = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  hydrate(browser, dehydrated);
  document.body.innerHTML = sent.slice(sent.indexOf('<div id="page">'));
  const container = document.body;
  await act(async () => {
    hydrateRoot(container, <Page client={browser} />, {
      onRecoverableError: () => undefined,
    });
  });
  await vi.waitFor(() =>
    expect(container.querySelector('[role="alert"]')?.textContent).toBe(
      "Recent activity could not be loaded.",
    ),
  );
  expect(container.textContent).toContain("Website relaunch");
  expect(reads.listActivity).toHaveBeenCalledTimes(1);
});
  • Hold the section's read open with a deferred promise, and read the stream as it arrives: what has been sent before the promise settles is what a person sees first.
  • Dehydrate every query, pending ones included (shouldDehydrateQuery: () => true), as the router's Query integration does; Query's default sends only finished ones and would hide a refetch.
  • The failed-section case hydrates the server's HTML in happy-dom: React shows error boundaries only in the browser, so a server render alone cannot prove the error appears.
  • Turn retry off in the browser client of that case: Query's default retries three times, calling the query function again, before the error shows.

When not to: a page with no streamed section is covered by its SSR test.

A mutation proves what it invalidates

features/task/hooks/cache-invalidation.test.tsx (excerpt)
// @vitest-environment happy-dom
vi.mock("../server/api.server", () => ({ createTask: vi.fn(async () => ({ id: "tsk_new" })) }));

test("creating a task refreshes the project's progress, not just the task list", async () => {
  const client = makeQueryClient();
  client.setQueryData(taskKeys.list("project-a"), []);
  client.setQueryData(progressKeys.summary("project-a"), null);
  const { result, unmount } = renderHook(() => useTasksController("project-a"), {
    wrapper: ({ children }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>,
  });
  try {
    await act(() => result.current.create.execute({ title: "Write the brief" }));
    expect(client.getQueryState(taskKeys.list("project-a"))?.isInvalidated).toBe(true);
    expect(client.getQueryState(progressKeys.summary("project-a"))?.isInvalidated).toBe(true);
  } finally {
    unmount();
    client.clear();
  }
});

The test uses the real makeQueryClient, so it proves the global mutation cache acted on meta.invalidates — not that the hook called a function. Toasts are replaced through a test seam on the notifier rather than rendered.

When not to: a mutation whose only effect is the list it changes is covered by the SSR and journey tests; add this test when it invalidates another feature's data.

Keys from different products never collide

A tooling test fetches two products' lookalike keys through one client and asserts each result stays its own:

tooling/query-isolation.test.ts (excerpt)
test.each([
  ["Projects/Billing categories", projectCategories.all, billingCategories.all],
])("%s retain independent results", async (_name, first, second) => {
  const client = makeQueryClient();
  const one = mock(async () => "first product");
  const two = mock(async () => "second product");
  expect(await client.fetchQuery({ queryKey: [...first], queryFn: one })).toBe("first product");
  expect(await client.fetchQuery({ queryKey: [...second], queryFn: two })).toBe("second product");
  expect(one).toHaveBeenCalledTimes(1);
  expect(two).toHaveBeenCalledTimes(1);
});

When not to: one product in one app has nothing to collide with.

An SSR sweep over every route

One command builds the apps, boots deterministic fixtures in place of the real services, and checks every route's server HTML. Each route lists the words that must be in it:

tooling/ssr-sweep/routes.ts (excerpt)
/**
 * An anchor is load-bearing, not decorative: the breadcrumb tail (does the shell
 * know where we are), the primary action (is the bar complete), and one piece of
 * the page's own data or its empty-state copy (did the content render on the
 * server, or is it waiting for JavaScript).
 */
export const ROUTES: RouteCase[] = [
  { path: "/projects", mode: "populated", anchors: ["Website relaunch", "New project"] },
  { path: "/projects", mode: "empty", anchors: ["No projects yet"] },
];

It also fails a page whose server render threw: React then answers 200 with a single <template data-msg="Switched to client rendering…">, and nothing on the screen says so. A browser pass over a few representative pages checks that hydration changes nothing a reader sees.

Fixtures, not the real stack: fixed ids, fixed names, dates far from now, so a difference is a defect and never a clock. The trade is explicit — the sweep does not prove sign-in; the end-to-end journeys do.

When not to: an SPA has no server HTML to sweep.

A Playwright journey per app

Each app has its own e2e/ with a Playwright config and journeys that run against the real stack, one person's task from start to end:

web/app/e2e/project.spec.ts (excerpt)
test("a member creates a project and finds it in the list", async ({ page, projectsApi }) => {
  const name = `Browser project ${randomUUID().slice(0, 8)}`;
  projectsApi.trackProjectName(name);

  await page.goto("/projects/new");
  await expect(page.getByRole("heading", { level: 1, name: "New project" })).toBeVisible();
  await expect(page.locator("form")).toHaveScreenshot("01-create.png");
  await page.getByRole("textbox", { name: "Name (required)" }).fill(name);
  await page.getByRole("button", { name: "Create project" }).click();

  await expect(page).toHaveURL(/\/projects\/prj_[^/]+\/?$/);
});
  • Clean up through the service's API, tracked by a fixture, never by hand.
  • Screenshot baselines are kept per platform ({platform} in the snapshot path): a baseline captured on macOS never matches Linux rendering.
  • The app's vitest config includes only *.test.* files, so unit runs never load a *.spec.ts journey.

When not to: anything a vitest render with fakes can prove. Journeys cover what crosses screens and the real server.

Focused and skipped tests fail lint

biome.integrity.json
{
  "root": true,
  "files": { "includes": ["**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts}"] },
  "linter": {
    "rules": {
      "recommended": false,
      "suspicious": { "noFocusedTests": "error", "noSkippedTests": "error" }
    }
  }
}

Adapted from a production repository, where biome lint --config-path=biome.integrity.json . runs as the gate's first step. An .only silently drops the rest of the file; a .skip is a red test nobody has to look at.

On this page