EEPNext.js Template
Conventions

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)

TypeLocation
Utilityutils/__tests__/
Storestore/__tests__/
Hookhooks/__tests__/
Componentcomponents/common/__tests__/ (or features/)
Serviceclasses/services/<domain>/__tests__/
e2ee2e-tests/tests/ (project root)
Contractcontract-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.

FilePurpose
vitest.config.tsMain unit suite. jsdom environment for component tests, T3 Env stubs, server-only alias. Default for pnpm test.
vitest.tz.config.tsTimezone 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.tsContract tests. node environment with a generous timeout for live network calls. Excluded from the main suite to keep pnpm test fast.
vitest.setup.tsGlobal setup file - registers jest-dom matchers and runs RTL cleanup after each test. Referenced by vitest.config.ts.
vitest.server-only-stub.tsEmpty 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 mode

What 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:integration script 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 run

Playwright 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:

SpecWhat it covers
home.spec.tsSmoke checks - unauthenticated redirect to sign-in, Clerk component renders
counter.spec.tsZustand client state (authenticated) - increment, decrement, and reset in the browser
roles.spec.tsTwo-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:contract

Contract 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.


On this page