Testing
What earns a test, how to write it, and where it lives.
Philosophy
All development work should be completed in a QA-oriented manner, which means being precise about requirements, verifying your work thoroughly, and not solely outsourcing that responsibility to a test suite. This should actually limit the number of tests that are needed in a project.
When an agent is writing all of the code, you become the QA. Neither you nor the agent should reach for tests automatically. Un-targeted tests add noise, erode the feedback loop, and create false confidence without catching real bugs.
The goal isn't to chase coverage metrics but test what actually matters! Before writing a test, ask: would this prevent a future bug or make refactoring safer? If yes, write it.
Co-location
Tests live next to the code they test in a __tests__/ subfolder at the same
directory level (with the exception of e2e-tests and contract-tests)
| Type | Location |
|---|---|
| Utility | utils/__tests__/ |
| Store | store/__tests__/ |
| Hook | hooks/__tests__/ |
| Component | components/common/__tests__/ (or features/) |
| Service | classes/services/<domain>/__tests__/ |
| e2e | e2e-tests/tests/ (project root) |
| Contract | contract-tests/ (project root) |
Vitest config files
There are multiple vitest.*.ts files at the project root - each exists because
different test classes need genuinely different runtime environments and can't
share a single config without compromising correctness or feedback speed.
| File | Purpose |
|---|---|
vitest.config.ts | Main unit suite. jsdom environment for component tests, T3 Env stubs, server-only alias. Default for pnpm test. |
vitest.tz.config.ts | Timezone tests only. Uses node environment - jsdom caches the system timezone at startup and ignores runtime TZ changes, so timezone-sensitive date tests must run here. |
vitest.contract.config.ts | Contract tests. node environment with a generous timeout for live network calls. Excluded from the main suite to keep pnpm test fast. |
vitest.setup.ts | Global setup file - registers jest-dom matchers and runs RTL cleanup after each test. Referenced by vitest.config.ts. |
vitest.server-only-stub.ts | Empty module that stubs the server-only package. Prevents vitest from throwing when importing server-layer code. |
These files live at the project root because Vitest discovers
vitest.config.ts there by default - moving them to a subfolder would require
passing --config explicitly to every script.
Test-only exports
Expose internals for testing under a single _forTests named export at the
bottom of the file.
// public API
export const exampleItemService = new ExampleItemService();
// test-only - grouped under one export, never imported outside __tests__/
export const _forTests = { ExampleItemService };Never export internal classes or functions individually - keep the public API clean.
Mocking
Mock at the boundary closest to the thing you're testing. For components that
depend on a hook or context you don't control (e.g. useTheme from
next-themes), mock the hook and drive the component with a known value - don't
try to render the full provider tree.
const setTheme = vi.fn();
let activeTheme = 'system';
vi.mock('next-themes', () => ({
useTheme: () => ({ theme: activeTheme, setTheme }),
}));
afterEach(() => {
setTheme.mockClear();
activeTheme = 'system';
});Zustand stores
Stores are module singletons - reset to default state at the start of each test
to stay independent of execution order. Use getState / setState to drive the
store without rendering a React tree.
describe('useCounterStore', () => {
const resetStore = () => {
useCounterStore.setState(defaultCounterState);
};
it('increments and decrements by one', () => {
resetStore();
useCounterStore.getState().increment();
useCounterStore.getState().increment();
expect(useCounterStore.getState().count).toBe(2);
useCounterStore.getState().decrement();
expect(useCounterStore.getState().count).toBe(1);
});
});Running tests
The full unit test suite runs automatically on pre-commit whenever .ts /
.tsx files are staged. See Git for
the full hook breakdown.
pnpm test # vitest run (single pass)
pnpm test:watch # vitest watch mode
pnpm test:contract # contract tests - live network, run separately
pnpm e2e # Playwright end-to-end
pnpm e2e:ui # Playwright UI modeWhat earns a test?
The one-line filter: if you can break it by changing a branch, a prop's effect, a timer, or state - test it. If the only thing to assert is that markup rendered, skip it.
Three tiers:
1. Pure logic - always test.
Functions, stores, validation, reducers. This is where bugs hide and tests are
cheap and stable. Covers utils/, store/, validation/.
// ✅ formatErrorMessage has branching logic - worth testing
it('returns the message from an Error instance', () => {
expect(formatErrorMessage(new Error('boom'))).toBe('boom');
});
it('falls back to a generic message for an unknown value', () => {
expect(formatErrorMessage(null)).toBe('An unknown error occurred');
});2. Components with branches, state, or effects - test the decision. Conditional rendering, event handlers, timers - anything that makes a decision.
// ✅ tests the branch (which icon shows) and the behaviour (setTheme called)
it('shows the icon matching the active theme', () => {
activeTheme = 'dark';
render(<ThemeToggle />);
expect(screen.getByTestId('dark-theme-icon')).toBeInTheDocument();
expect(screen.queryByTestId('light-theme-icon')).not.toBeInTheDocument();
});
it('calls setTheme with the chosen option', async () => {
const user = userEvent.setup();
render(<ThemeToggle />);
await user.click(screen.getByRole('button'));
await user.click(screen.getByText('Dark'));
expect(setTheme).toHaveBeenCalledExactlyOnceWith('dark');
});See src/components/common/__tests__/theme-toggle.test.tsx for the full
reference shape including the useTheme mock pattern.
3. Pure presentational components - do not test.
Takes props, renders markup, no branching. Asserting "the markup rendered" tests
that React works - it catches no bugs and breaks on every cosmetic change.
error, page-not-found, toast, auth get no unit test for this reason.
The tiers above cover unit tests but testing doesn't stop here! This doc also covers:
Test Driven Development (TDD)
TDD can be a contentious topic, some developers apply it to everything, others never touch it. The pragmatic approach I find works well is that TDD pays off for small, isolated functions with a clear contract - pure utilities, validation schemas, well-defined logic. Write the test first, watch it fail, make it pass.
Where I feel it doesn't really pay off is larger integration work where the spec is still being discovered. As the module boundaries and interface shapes evolve, tests written against them become liable to break for structural reasons rather than logic errors and they often end up being rewritten or discarded.
Worked example - timezone-safe date parsing
Let's look at an actual practical example in this template that shows the benefits of TDD.
JavaScript's date handling is notoriously fiddly (zero-based month indexes, timezone offsets, locale differences), but present us with a good opportunity to utilise TDD. The contract for a date utility is clear upfront, making it easy to write precise tests before the implementation exists.
The parseDateSafe utility in src/utils/dates.ts was written test-first.
The bug it solves: new Date('2025-03-15') parses as UTC midnight, which
rolls back to March 14 for any user in a negative-offset timezone (UTC-7, UTC-8
etc.). The fix is to parse date-only strings as local midnight using date-fns
parse() instead.
Committing failing tests will be blocked by the pre-commit hook. Use
SKIP_CHECKS=1 git commit -m "..." to checkpoint intentionally broken state
during the red phase. This flag exists precisely for TDD workflows. Lint and
style checks still run; only tsc, tests, and the build are skipped.
Integration tests
Integration tests verify that multiple layers of the application work correctly together e.g. a server action calling a service calling the database. They catch bugs that unit tests miss because those operate in isolation with mocked dependencies.
This template doesn't ship integration test infrastructure because the right setup is highly project-specific: you need a test database, migrations, seed/teardown between runs, and isolation that fits your deployment environment.
When to reach for them:
- A data flow crosses two or more layers and mocking feels like it's hiding the real risk
- You've had a bug where each layer was individually correct but the wiring between them was wrong
- A critical mutation (payment, auth, data deletion) needs end-to-end confidence that no unit test or e2e test can give cheaply
Practical setup pointers:
- Use a separate test database (a local Postgres instance or a Neon branch)
- Run migrations before the suite and truncate tables between tests, not between files
- Keep them out of the pre-commit hook - they're slow and require infrastructure
- A dedicated
pnpm test:integrationscript keeps them easy to run on demand or in CI
The e2e tests in this template provide some integration-level confidence for the example-items feature - they exercise the full stack against a running app with a real database. For many projects that coverage is sufficient; dedicated integration tests earn their place when the data flows grow complex enough that e2e tests become too slow or brittle to cover every case.
End-to-end tests
E2e tests verify user-facing flows against a running app - they're the last line of defence before shipping, not a substitute for unit tests. Use them for critical paths that cross multiple layers (routing, server actions, client state) and can't be meaningfully tested in isolation.
What earns an e2e test:
- Critical user flows (sign-in, core feature interactions)
- Client-state behaviour that's difficult to verify without a real browser (e.g. Zustand store mutations reflected in the UI)
- Smoke checks that the app starts and key pages render
What doesn't:
- Logic already covered by a unit test - don't duplicate coverage
- Presentation details - if a unit test already verifies the decision, an e2e test asserting the same outcome in the browser adds friction without value
Running
pnpm e2e # headless Playwright run
pnpm e2e:ui # Playwright UI mode (step through tests visually)
pnpm e2e:report # open the HTML report from the last runPlaywright spins up next dev automatically via webServer in playwright.config.ts
and reuses an already-running server when one is detected - so if you have pnpm dev
running, tests start instantly.
The suite runs with a single worker (workers: 1). Parallel workers all hit
the dev server cold simultaneously, causing a CPU spike that throttles the
server and makes tests flaky. Increase workers once the suite is large enough
that wall-clock time becomes a problem - ideally after switching to next start (production build) as the e2e target.
Authentication setup
The app is gated by Clerk, so authenticated tests need a real Clerk user and the
@clerk/testing SDK to sign in programmatically via the Backend API (no browser
password flow).
Reference specs
The template ships these specs as reference coverage for the core patterns:
| Spec | What it covers |
|---|---|
home.spec.ts | Smoke checks - unauthenticated redirect to sign-in, Clerk component renders |
counter.spec.ts | Zustand client state (authenticated) - increment, decrement, and reset in the browser |
roles.spec.ts | Two-tier role model - page gate, API authz (403 vs 201), and the action toast |
Contract tests
Contract tests verify that a third-party API / service still returns the shape your code depends on. They make live network calls against the real provider, so they run separately from the standard test suite - on a schedule or before a deploy, not on every commit.
What earns a contract test: any external API whose response shape you consume directly and don't control. If the provider renames a field, changes a type, or removes a required property, a contract test catches it before it reaches production.
What doesn't: your own API routes - if the shape drifts, your Zod schema drifts with it by definition. Contract tests are for boundaries you don't own.
How it works
Each contract test hits the live endpoint and parses the response through the same Zod schema that drives the rest of the app. A parse failure means the provider's contract has changed and the local type definition needs updating.
// src/contract-tests/jsonplaceholder.contract.test.ts
const PostSchema = z.object({
id: z.number(),
userId: z.number(),
title: z.string(),
body: z.string(),
});
it('GET /posts returns an array matching the Post schema', async () => {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts?_limit=3',
);
const data: unknown = await response.json();
const result = PostArraySchema.safeParse(data);
expect(
result.success,
result.success ? '' : `Schema mismatch: ${result.error.message}`,
).toBe(true);
});Running
pnpm test:contractContract tests live in contract-tests/ at the project root and follow the naming convention
*.contract.test.ts. They run under a dedicated vitest config (vitest.contract.config.ts)
with a generous timeout (15s) to accommodate real network latency.
Contract tests are excluded from pnpm test and the pre-commit hook by
design. Run them manually before a deploy or wire them to a scheduled CI job -
not the fast feedback loop.