Testing Across the Stack
Learning Objectives
- You can choose a test boundary for a stated requirement.
- You can distinguish successful type checking, runtime tests, and a successful build.
- You can critique tests that execute code without detecting a realistic mistake.
We can now type-check an application, call a pure function, render a component, send a request directly to Hono, use a real database, or drive a browser. The question is no longer which tool is best in general. It is which boundary provides useful evidence for the behavior we care about.
Use the same notes workflow in practice-typed-http/. We now have controlled client tests,
a raw API rejection test, a database integration test, and the actual browser
page. Use these existing checks to choose a suitable boundary for each requirement.
Start with the requirement
For the notes workflow, compare these requirements:
| Requirement | A useful first boundary |
|---|---|
| An empty title is rejected | schema or pure-rule test |
| A duplicate title returns 409 | API/database integration test |
| Rejected creation leaves stored notes unchanged | real database integration test |
| A field message is shown after rejection | component test with controlled operation |
| A user can create a note and find it after reload | a small Playwright workflow |
| A response-field rename breaks its consumer | actual client type-check command |
These checks overlap deliberately, but they are not interchangeable. A fake repository does not implement PostgreSQL isolation. A browser test does not explore every possible invalid JSON body. A type check does not contact a running server.
Choose a title-rule test
0 / 5 points
You need rapid feedback that blank titles are rejected. Which fixture/action/result directly examines that rule?
Keep runtime tests independent of the preferred client
Hono’s app.request() lets a Deno test construct an ordinary request without opening a TCP port. Use it for callers that omit fields, submit malformed JSON, or send values that typed source would reject. The Hono testing guide documents this direct boundary.
A separate test can verify that hc constructs the intended request. That is integration convenience, not a replacement for adversarial or compatibility tests of the API itself.
Fakes help isolate a decision
A service test can use a fake repository to arrange a missing record or a business-rule failure. It is often clearer than preparing many database rows for a pure decision. Keep the fake small and aligned with the repository contract.
Do not conclude that a fake passing establishes that the real SQL query is correct. Pair important persistence behavior with an integration test. Similarly, a mock call-count assertion can be useful for an interaction contract, but may become brittle if it merely copies the implementation’s private sequence.
Choose a version-conflict test
0 / 5 points
An API uses conditional version writes: an update succeeds only when its expected version matches the stored version; otherwise it returns 409 and preserves the document. A document is stored at version 3. An update expects version 2. Which test directly checks this conflict rule?
Reuse the accepted-result check
Chapter 4.3’s NotesPanel.test.ts already submits Maps, supplies the returned
record MAPS, and checks that the list uses the returned value. The different
strings make the assertion detect a parent that mistakenly displays the proposal.
Keep that case as the accepted-result check in the evolving suite.
Chapter 4.4’s form check covers field feedback and retained input. Add only the new observation below: the interval while an operation is still pending.
Test delayed and rejected outcomes
A test must hold the promise open to observe pending state. An immediately resolved operation can finish before an assertion sees the temporary feedback. Copy this supplied helper into the test file; it lets the test choose when the operation settles without a fixed delay:
const deferred = <T>() => { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail; }); return { promise, resolve, reject };};Keep the helper and the next case in Chapter 4.4’s existing
client/src/lib/components/ValidatedNoteForm.test.ts file.
Add waitFor to its existing import from @testing-library/svelte; keep only one
set of imports and one afterEach(cleanup) call.
test("keeps the proposal while pending and after an unconfirmed save", async () => { const user = userEvent.setup(); const operation = deferred<{ id: number; title: string; version: number }>(); const saveNote = vi.fn().mockReturnValue(operation.promise); const accepted = vi.fn(); render(ValidatedNoteForm, { props: { saveNote, onaccepted: accepted } }); await user.type(screen.getByLabelText("Title"), " Maps "); const submit = screen.getByRole("button", { name: "Submit note" }); await user.click(submit); expect(submit).toBeDisabled(); expect(screen.getByRole("status")).toHaveTextContent("Submitting note..."); expect(screen.getByLabelText("Title")).toHaveValue(" Maps "); expect(saveNote).toHaveBeenCalledWith("Maps"); expect(accepted).not.toHaveBeenCalled(); await user.click(submit); expect(saveNote).toHaveBeenCalledTimes(1);
operation.reject(new TypeError("Connection lost")); await waitFor(() => expect(submit).toBeEnabled()); expect(screen.getByRole("status")).toHaveTextContent("The note could not be confirmed."); expect(screen.getByLabelText("Title")).toHaveValue(" Maps "); expect(accepted).not.toHaveBeenCalled();});Run the existing client-test command. This test observes the pending interval, prevents a duplicate submission, and checks recovery after rejection. The accepted result check retained from Chapter 4.3 covers the successful branch. Neither establishes what a real API committed; the API and browser checks provide that separate evidence.
Coverage and meaningful assertions
A coverage report shows which code ran during the tests. It cannot tell whether the assertions describe the right behavior.
Consider a title rule that accidentally accepts whitespace-only text. A test may
call every branch but check only that a result exists. Coverage is high, but the
test and implementation share the error. A useful assertion checks that
whitespace-only text is rejected, rather than merely checking that safeParse
returned an object.
Part 3’s regression exercise already practiced choosing inputs that distinguish a correct implementation from a faulty one. Apply that reasoning to the existing form and workflow tests.
Test note creation and reload in a browser
A browser assertion should wait for the relevant condition, not for a guessed delay. Playwright’s locator assertions retry until the expected condition or a timeout. Playwright’s assertion guide explains that distinction.
await page.getByRole("button", { name: "Submit note" }).click();
await expect(page.getByRole("status")).toHaveText("Accepted: Maps");This does not mean every operation should be retried automatically. A test retry and a repeated application mutation are different mechanisms. Fix data collisions and missing waits rather than hiding them behind unlimited test retries.
Keep the existing /lab page and its real API connection. Save
e2e-tests/tests/note-form.spec.ts:
import { expect, test } from "@playwright/test";
test("a created note survives reload and a rejected duplicate preserves input", async ({ page }) => { const title = `Browser note ${crypto.randomUUID()}`; await page.goto("/lab"); await page.getByLabel("Title", { exact: true }).fill(` ${title} `); const posted = page.waitForResponse((response) => response.url().endsWith("/api/notes") && response.request().method() === "POST" ); await page.getByRole("button", { name: "Submit note" }).click(); const response = await posted; expect(response.status()).toBe(201); const { note } = await response.json(); expect(note.title).toBe(title); expect(note.id).toBeGreaterThan(0); await expect(page.getByRole("status")).toHaveText(`Accepted: ${title}`); await expect(page.getByRole("listitem").filter({ hasText: title })).toHaveCount(1); await expect(page.getByLabel("Title", { exact: true })).toHaveValue(` ${title} `);
await page.reload(); await expect(page.getByRole("listitem").filter({ hasText: title })).toHaveCount(1); await page.getByLabel("Title", { exact: true }).fill(title); await page.getByRole("button", { name: "Submit note" }).click(); await expect(page.getByText("Choose a different title", { exact: true })).toBeVisible(); await expect(page.getByLabel("Title", { exact: true })).toHaveValue(title); await expect(page.getByRole("listitem").filter({ hasText: title })).toHaveCount(1);});Keep development data out of test expectations. Give a test its own fixture, close pools, and remove the records it owns. The API and browser suites below share the isolated project’s disposable database, so run them serially. Parallel suites need separate databases or schemas.
From practice-typed-http/, apply the migration and run the API and browser
checks. The API and client are rebuilt from the current source:
docker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --rm database-migrationsdocker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --build --rm api deno test --cached-only --allow-env --allow-net tests/notes-routes_test.ts tests/note-form-bypass_test.tsdocker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml up -d --build api clientdocker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --build --rm e2e-tests npm test -- tests/note-form.spec.tsdocker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml downThis browser test makes actual HTTP requests and uses PostgreSQL through the API. Its unique title avoids collisions; stopping this isolated test project discards its temporary database. The POST observation, visible result, and subsequent GET support different parts of the requirement. Keep the narrower tests too: they locate failures and probe inputs this browser journey does not cover.
This is the final notes workflow check. Reuse the client suite and type/build checkpoint already established in Chapter 4.5; rerun them if the added tests or application changes affect them. No separate command report is required. If a suite cannot start, resolve its setup failure before interpreting its assertions.
Check Your Understanding
- Which failures require a real API or database test even when type checks and tests with a fake repository pass?
- Why does 100% coverage not establish correctness?
- How does waiting for a condition differ from sleeping for a fixed time?
- Why does running tests in parallel require more than enabling a runner option?