Forms & Mutations

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:

RequirementA useful first boundary
An empty title is rejectedschema or pure-rule test
A duplicate title returns 409API/database integration test
Rejected creation leaves stored notes unchangedreal database integration test
A field message is shown after rejectioncomponent test with controlled operation
A user can create a note and find it after reloada small Playwright workflow
A response-field rename breaks its consumeractual 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:

Terminal window
docker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --rm database-migrations
docker 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.ts
docker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml up -d --build api client
docker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --build --rm e2e-tests npm test -- tests/note-form.spec.ts
docker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml down

This 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

  1. Which failures require a real API or database test even when type checks and tests with a fake repository pass?
  2. Why does 100% coverage not establish correctness?
  3. How does waiting for a condition differ from sleeping for a fixed time?
  4. Why does running tests in parallel require more than enabling a runner option?