Testing Authoring Patterns
Use this page when writing or reviewing tests. It covers the test harness contract, mocking boundaries, backend request recipes, DB-backed patterns, front-end integration patterns, naming, and LLM prompt shape.
Tooling Contract
Section titled “Tooling Contract”- Use Vitest for front-end, backend, and shared-package unit and integration tests.
- Use
jsdomfor front-end unit and integration tests. - Use
nodefor backend and shared-package tests. - Use Playwright only for browser-level confidence.
- Keep test runner configs package-local.
- Keep browser E2E package-scoped.
- Prefer Hono
app.request(...)before introducing a full HTTP-server test harness. - Prefer the Docker-backed Postgres path before adding a separate persistence test framework.
- Prefer mocks first for external adapters, then add local-service-backed tests where provider behavior itself matters.
Do not introduce another mainstream test runner unless a concrete limitation appears.
Mocking Boundaries
Section titled “Mocking Boundaries”Mock at the boundary whose behavior is not under test.
Front-end tests should default to API-module or external-boundary mocks instead of mocking deep internal helpers. Reserve broader service virtualization for a future point where direct module mocks become harder to maintain than the abstraction they avoid.
Backend request tests should mock outbound boundaries, database/query seams, or provider adapters as narrowly as possible. Do not mock the route, middleware, or handler composition when that composition is the thing being tested.
Browser E2E should stay close to real behavior. Playwright route mocking is available, but it should not become a way to turn E2E into another component test layer.
Mock Catalog
Section titled “Mock Catalog”Use the existing mock helpers before inventing a new harness:
| Need | Preferred Surface |
|---|---|
| Front-end client env defaults | installClientEnvTestDefaults() in test/unit-and-integration/mocks/clientEnv.ts. |
| Next route state | setNextNavigationMockState(...) or renderWithAppProviders({ pathname, params, searchParams }). |
| Front-end i18n copy | setI18nMockNamespaceTranslations(namespace, translations). |
| React Query state | createTestQueryClient() and queryClient.setQueryData(...). |
| App provider composition | renderWithAppProviders(...). |
| Backend server env defaults | installServerEnvTestDefaults() in mocked request setup. |
| Mocked backend DB shape | createMockDatabase(...) and focused query/transaction mocks. |
| Backend request formatting | formatRequestJSON(...) and makeAppRequest(...). |
| Backend response parsing | parseJSONResponse(...) plus APIEnvelopeOk(schema) or APIErrorEnvelope. |
| DB-backed auth | createDBBackedAuthorizationHeaders() and seeded user fixtures. |
| Browser route/page interactions | Suite-local *PageTestInfo helpers and route constants from @wavemap/api-contracts. |
Prefer stateful mocks only when the test needs stateful behavior. For example, the i18n mock intentionally keeps a translation map that tests can seed per namespace. The Next navigation mock intentionally keeps mutable route state so route hooks and router methods agree with each other. A one-off return value does not need a shared stateful helper.
For module replacement, keep the production import path visible:
const mockedSendRegistrationEmail = vi.hoisted(() => vi.fn())
vi.mock("@/utils/resend", () => ({ default: { emails: { send: mockedSendRegistrationEmail, }, },}))Then import the production symbol normally and use vi.mocked(...) where you need typed mock methods:
import useDB from "@/db"
const mockedUseDB = vi.mocked(useDB)mockedUseDB.mockReturnValue(createMockDatabase({ transaction: transactionMock }))This pattern protects the import path while keeping test setup explicit. Use vi.hoisted(...) when a mocked factory must
close over a test-controlled mock before regular imports run.
Backend Request Patterns
Section titled “Backend Request Patterns”Request tests should answer: does the real app boundary do the right thing when this request reaches the mounted route?
Current conventions:
- Import the testable app boundary through
createApp(...). - Exercise routes with
app.request(...). - Keep server-start concerns in
src/index.tsand route composition importable fromsrc/app.ts. - Seed minimum required environment through package-local mocked-request setup before env-sensitive imports run.
- Keep small request helpers under
apps/wavemap-back-end/test/mocked-requests/utils. - Parse JSON responses through helper utilities so the unsafe
Response.json()boundary stays explicit.
When a route has a shared success DTO, parse the response through that contract. When it does not, use a small local Zod
schema for only the response portion the test needs. That keeps unknown response bodies honest without re-specifying an
entire route contract in every test file.
For module replacement, the repo has settled on:
- Mock the module with
vi.mock("...", () => ({ ... })). - Import the production symbol normally.
- Use
vi.mocked(importedSymbol)for a typed mocked handle.
This keeps the runtime import path aligned with production code while giving tests access to mockResolvedValue(...),
mockReturnValue(...), and related helpers.
Mocked Request Recipe
Section titled “Mocked Request Recipe”Use mocked request tests for the behavior at the Hono app boundary:
- Arrange only the external seams the route needs.
- Send a realistic request through
makeAppRequest(...). - Parse the response body as
unknown. - Validate the envelope with a shared DTO or a tiny local schema.
- Assert the route-level side effects that distinguish the branch.
Example:
mockedUseDB.mockReturnValue( createMockDatabase({ transaction: transactionMock, }),)mockedSendRegistrationEmail.mockResolvedValue({ error: null } as never)
const response = await makeAppRequest({ path: ROUTE_API_V1__REGISTRATION, init: formatRequestJSON({ init: { method: "POST" }, body: { username: "neon-tide", email: "neon@example.com", password: "Password123!", passwordConfirmation: "Password123!", }, }),})
const responseBody = await parseJSONResponse(response)const parsedResponse = APIEnvelopeOk(RegistrationResponseDTO).parse(responseBody)
expect(response.status).toBe(201)expect(parsedResponse.data.user.email).toBe("neon@example.com")expect(transactionMock).toHaveBeenCalledTimes(1)expect(mockedSendRegistrationEmail).toHaveBeenCalledTimes(1)For error paths, assert the stable error identity first:
const parsedResponse = APIErrorEnvelope.parse(responseBody)
expect(response.status).toBe(409)expect(parsedResponse.error.type).toBe(ERROR_TYPE_CODE__CONFLICT)expect(parsedResponse.error.messageKey).toBe("auth.user.email-not-unique")The translated message can be asserted when localization is part of the behavior. Otherwise, prefer messageKey,
type, and code as the durable contract.
DB-Backed Integration Patterns
Section titled “DB-Backed Integration Patterns”The DB-backed backend suite exists because some behavior is only meaningful against real persistence.
Current conventions:
- Use
apps/wavemap-back-end/vitest.db-backed.config.ts. - Keep
fileParallelism: falseso test files do not race while reseeding the shared database. - Call
await reseedDatabase()once per file or describe group, not before every test by default. - Use unique fixtures or known seeded rows so tests can run against the shared dev baseline.
- Build auth headers from real JWT generation against seeded dev user credentials when the auth chain is under test.
- Parse response bodies through
APIEnvelopeOk(schema)orAPIErrorEnvelope, matching the mocked request style.
Use DB-backed tests for:
- Mutation outcomes and relationship changes.
- Transaction rollback behavior.
- Real not-found behavior through query plus handler plus response.
- One successful-auth and one auth-failure path per mutation route when the real auth chain is the concern.
Do not duplicate at the DB-backed layer:
- Request schema validation errors that fail before the DB is touched.
- Every auth permutation already covered in mocked request tests.
- Exhaustive response shape coverage when a smaller envelope parse proves the route wiring.
Targeted unit tests remain appropriate for failure modes that a real database cannot produce deterministically, such as a mocked transaction returning an impossible empty insert result after storage upload succeeds.
DB-Backed Recipe
Section titled “DB-Backed Recipe”Use the DB-backed suite when a real row or relationship is the test subject:
- Reseed once for the file or describe group.
- Create the smallest extra fixture needed for this behavior.
- Build real authorization headers when auth is part of the behavior.
- Send the request through the app boundary.
- Parse the response through the route DTO.
- Query the DB for the persisted proof.
Example shape:
beforeAll(async () => { await reseedDatabase()})
const authorizationHeaders = await createDBBackedAuthorizationHeaders()const artist = await createDBBackedArtistFixture({ name: `Upload Fixture ${randomUUID()}` })
const response = await makeAppRequest({ path: formatArtistMediaPath(artist.publicId), init: { method: "POST", headers: authorizationHeaders, body: formData, },})
const responseBody = await parseJSONResponse(response)const parsedResponse = APIEnvelopeOk(UploadArtistMediaResponseDTO).parse(responseBody)const updatedArtist = await getDBBackedArtistByID(artist.ID)
expect(response.status).toBe(201)expect(updatedArtist?.media).toEqual( expect.arrayContaining([expect.objectContaining({ publicId: parsedResponse.data.media.publicId })]),)Spies are still allowed in DB-backed tests when they prove a boundary call that persistence alone cannot show. For example, a storage-adapter spy can prove an upload was delegated with the expected entity ID and artifact kind, while the database query proves the returned storage locator was persisted.
Keep DB-backed fixture helpers honest. They should create domain state a user could really create, not bypass validation in a way that makes the test pass against impossible data. If a helper must insert directly for setup speed, validate the insert shape with the same schema production uses where practical.
Front-End Integration Patterns
Section titled “Front-End Integration Patterns”Front-end integration tests should make app composition boring.
Current conventions:
- Keep setup in
apps/wavemap-front-end/test/unit-and-integration/vitest.setup.tsx. - Install minimum client env defaults through
test/unit-and-integration/mocks/clientEnv.ts. - Mock framework/runtime edges that
jsdomcannot provide, such as Next navigation,next/image,matchMedia, Node crypto, and i18n client wiring. - Use
renderWithAppProviders(...)when route, auth, locale, query client, hydration, or app-shell composition matters. - Use smaller rendering helpers for component tests that need only a narrow wrapper surface.
Treat route state, search params, auth state, and query hydration as first-class test inputs rather than incidental setup.
Front-End Integration Recipe
Section titled “Front-End Integration Recipe”Use this layer when the component behavior depends on app context:
- Seed only the translation strings the test will read.
- Create a query client or use
renderWithAppProviders(...)defaults. - Pass route state, current user, hydration state, and namespace preloads explicitly.
- Interact through the DOM.
- Assert visible state, submitted payload, route mutation, query-cache state, or API-module call.
Example:
beforeEach(() => { setEnglishRegistrationTranslations() setEnglishCommonFormValidationTranslations()})
const onSubmit = vi.fn()const { container } = renderWithAppProviders( <RegistrationForm mode={REGISTRATION_FORM_MODE__STANDARD} onSubmit={onSubmit} onNavigateToLogin={vi.fn()} />, { locale: LOCALE__EN, preloadNamespaces: REGISTRATION_NAMESPACES },)
fillSharedRegistrationFields()fireEvent.submit(container.querySelector("form")!)
await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ username: "fixture-user", email: "fixture@example.com", }), )})For hook-style integration tests, use a small probe component that renders the hook state. Seed route state and query state directly:
const queryClient = createTestQueryClient()queryClient.setQueryData([QUERY_KEY__CURRENT_USER], createTestUser([ROLE_CODE__USER]))
render(<RequireAuthProbe />, { wrapper: createWrapper(queryClient) })
await waitFor(() => { expect(routerReplaceSpy).toHaveBeenCalledWith("/en/access-denied")})This keeps the test focused on the hook contract while still exercising React Query and route behavior.
Avoid making renderWithAppProviders(...) the default for every test. A leaf input, button, formatter, or class toggle is
usually clearer with direct render(...). Reach for the app helper when the missing provider would be part of the
behavior, not just because the helper exists.
Naming And Placement
Section titled “Naming And Placement”- Prefer colocated unit and small integration tests near the source under
src/**/__tests__. - Use package-local
test/directories for layer-wide suites such as backend request tests, DB-backed integration, browser E2E, shared harnesses, env setup, and reusable fixtures. - Prefer
*.test.tsand*.test.tsxfor Vitest files. - Use
*.integration.test.tsand*.integration.test.tsxwhen a Vitest file crosses from unit/component behavior into provider, route-state, query-cache, auth-state, form-composition, or other app-composition behavior. This filename marker is the only meaningful semantic separation currently used for colocated Vitest tests; there is not a separate integration-test directory, runner, or project boundary for those files yet. - Prefer
*.e2e.test.tsand*.e2e.test.tsxfor Playwright files. - Use sentence case for
describe,it, andtestdescriptions. - Keep test names behavior-focused rather than implementation-focused.
- Prefer one clear behavior per test unless several assertions are the cleanest way to describe one outcome.
Asking LLMs For Help With Writing Tests
Section titled “Asking LLMs For Help With Writing Tests”LLMs do best here when the request names the layer, the boundary, and the existing examples to imitate. A good prompt should include:
- The behavior to protect in one sentence.
- The intended test layer and why.
- The source file and any neighboring test files to follow.
- The helpers to use, such as
renderWithAppProviders,makeAppRequest,createMockDatabase,createDBBackedAuthorizationHeaders, or a suite-local Playwright page object. - The mocks that are allowed and the production code that should remain real.
- The narrow verification command to run.
Example prompt shapes:
Add a front-end integration test for the registration form admin mode. Follow the existingRegistrationForm integration tests. Use renderWithAppProviders, seed only the needed i18n keys,and assert the submitted payload rather than implementation state. Run the front-end Vitest file.Add a mocked backend request test for duplicate registration email. Exercise the real Hono appthrough makeAppRequest, mock only useDB and Resend, parse with APIErrorEnvelope, and assert themessageKey/type/code. Run the backend mocked-request test file.Add a DB-backed integration test for artist media ordering. Use reseedDatabase once, create areal artist fixture, send the request through makeAppRequest with DB-backed auth headers, parsethe response DTO, and query the database for the persisted ordering. Do not duplicate requestschema validation branches already covered in mocked request tests.Review AI-written tests with this checklist:
- Did it choose the cheapest useful layer?
- Did it mock only boundaries outside the behavior under test?
- Did it use existing helpers instead of inventing a new harness?
- Did it parse unknown response bodies through contracts or small local schemas?
- Did it avoid broad snapshots, arbitrary waits, and copy-heavy assertions?
- Did it run the narrowest verification command for the touched behavior?
Low-Value Coverage
Section titled “Low-Value Coverage”Do not chase coverage for icons, constants, types, styles, generated assets, or trivial wrappers. Do not duplicate the same confidence at multiple layers unless the regression risk justifies it.
For reusable UI components, cover the developer-facing prop surface, meaningful states, and basic interactions. Skip overly mechanical tests for calibration helpers when they only restate implementation details. If the component or logic under test is itself a wrapper around some more base implementation, avoid testing aspects of the functionality which are already covered by that lower layer. These implementation details should be left to be verified by the maintainer of that resource, or better compositional resources should be chosen if they aren’t meeting the mark as expected.