Skip to content

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.

  • Use Vitest for front-end, backend, and shared-package unit and integration tests.
  • Use jsdom for front-end unit and integration tests.
  • Use node for 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.

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.

Use the existing mock helpers before inventing a new harness:

NeedPreferred Surface
Front-end client env defaultsinstallClientEnvTestDefaults() in test/unit-and-integration/mocks/clientEnv.ts.
Next route statesetNextNavigationMockState(...) or renderWithAppProviders({ pathname, params, searchParams }).
Front-end i18n copysetI18nMockNamespaceTranslations(namespace, translations).
React Query statecreateTestQueryClient() and queryClient.setQueryData(...).
App provider compositionrenderWithAppProviders(...).
Backend server env defaultsinstallServerEnvTestDefaults() in mocked request setup.
Mocked backend DB shapecreateMockDatabase(...) and focused query/transaction mocks.
Backend request formattingformatRequestJSON(...) and makeAppRequest(...).
Backend response parsingparseJSONResponse(...) plus APIEnvelopeOk(schema) or APIErrorEnvelope.
DB-backed authcreateDBBackedAuthorizationHeaders() and seeded user fixtures.
Browser route/page interactionsSuite-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.

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.ts and route composition importable from src/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:

  1. Mock the module with vi.mock("...", () => ({ ... })).
  2. Import the production symbol normally.
  3. 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.

Use mocked request tests for the behavior at the Hono app boundary:

  1. Arrange only the external seams the route needs.
  2. Send a realistic request through makeAppRequest(...).
  3. Parse the response body as unknown.
  4. Validate the envelope with a shared DTO or a tiny local schema.
  5. 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.

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: false so 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) or APIErrorEnvelope, 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.

Use the DB-backed suite when a real row or relationship is the test subject:

  1. Reseed once for the file or describe group.
  2. Create the smallest extra fixture needed for this behavior.
  3. Build real authorization headers when auth is part of the behavior.
  4. Send the request through the app boundary.
  5. Parse the response through the route DTO.
  6. 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 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 jsdom cannot 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.

Use this layer when the component behavior depends on app context:

  1. Seed only the translation strings the test will read.
  2. Create a query client or use renderWithAppProviders(...) defaults.
  3. Pass route state, current user, hydration state, and namespace preloads explicitly.
  4. Interact through the DOM.
  5. 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.

  • 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.ts and *.test.tsx for Vitest files.
  • Use *.integration.test.ts and *.integration.test.tsx when 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.ts and *.e2e.test.tsx for Playwright files.
  • Use sentence case for describe, it, and test descriptions.
  • 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.

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 existing
RegistrationForm 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 app
through makeAppRequest, mock only useDB and Resend, parse with APIErrorEnvelope, and assert the
messageKey/type/code. Run the backend mocked-request test file.
Add a DB-backed integration test for artist media ordering. Use reseedDatabase once, create a
real artist fixture, send the request through makeAppRequest with DB-backed auth headers, parse
the response DTO, and query the database for the persisted ordering. Do not duplicate request
schema 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?

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.