ShipVeryFastShipVeryFast
Documentation

Unit & integration tests (Jest)

ShipVeryFast ships a single jest.config.js that powers both unit and integration suites with ts-jest, a jsdom environment, and React Testing Library. This page walks through that config, the npm scripts, the optional integration database, and the patterns the existing tests follow.

The Jest config

Everything lives in one CommonJS config at jest.config.js. The important pieces:

  • preset: 'ts-jest', TypeScript is compiled on the fly, so no separate build step is needed for tests.
  • testEnvironment: 'jsdom', gives you a browser-like DOM so React components can render and you can query the document.
  • setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], runs once per test file after the framework is set up (more on this below).
  • testTimeout: 30000, a generous 30-second timeout, sized for the slower integration tests.
  • clearMocks: true and restoreMocks: true, mocks are cleared and restored between tests automatically, so suites stay isolated.

One subtlety worth knowing: the project tsconfig.json usesjsx: "preserve" (required by next build), but ts-jest must emit real JS for tests. The transform block overrides JSX to"react-jsx" just for Jest:

transform: {
  '^.+\\.(ts|tsx)$': ['ts-jest', {
    tsconfig: { jsx: 'react-jsx' }
  }]
}

The setup file

jest.setup.ts makes the test environment hermetic. It assigns dummy values for every variable validated by libs/config.ts (using||= so a real value still wins) before any import, so the top-levelenvSchema.parse() in libs/config.ts passes without a real.env. It then imports @testing-library/jest-dom, stubsglobal.fetch, and registers default mocks for next/router,next/navigation, next/server, next-auth,stripe, and @supabase/supabase-js. That means most tests can render components without wiring those services up by hand.

moduleNameMapper & asset mocks

The moduleNameMapper rewrites imports so Jest can resolve the same paths your app uses. Three rules matter:

moduleNameMapper: {
  '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  '\\.(png|jpg|jpeg|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
  '^@/(.*)$': '<rootDir>/$1'
}
PatternMaps toWhy
^@/(.*)$<rootDir>/$1The @/ alias resolves to the repo root, so @/libs/utils and@/components/ui/button work in tests exactly as in the app.
CSS/Sass importsidentity-obj-proxyStyle imports return a proxy where every class name maps to itself, so importing a stylesheet never breaks a test.
Image imports__mocks__/fileMock.jsStatic assets resolve to the string 'test-file-stub' instead of trying to load a binary.

The image stub itself is a one-liner, __mocks__/fileMock.js just doesmodule.exports = 'test-file-stub'.

Unit vs integration scripts

Both suites run through the same Jest config; the npm scripts simply select which paths to include or ignore. The base test script runs everything; test:unitignores tests/e2e and tests/integration; andtest:integration targets the tests/integration folder.

ScriptCommandWhat it runs
npm run testjestAll Jest tests (unit + integration; e2e is ignored by config).
npm run test:unitjest --testPathIgnorePatterns=tests/e2e --testPathIgnorePatterns=tests/integrationUnit tests only, excluding both e2e and integration folders.
npm run test:integrationjest tests/integrationOnly the integration suites under tests/integration.
npm run test:watchjest --watchRe-runs affected tests as you edit.
npm run test:coveragejest --coverageFull run with a coverage report in /coverage.
npm run test:coverage:unitjest --coverage --testPathIgnorePatterns=tests/e2e --testPathIgnorePatterns=tests/integrationUnit coverage only.
npm run test:coverage:integrationjest --coverage tests/integrationIntegration coverage only.
npm run test:cijest --coverage --watchAll=false --passWithNoTestsCI-friendly run that never hangs and passes with no tests.

Note that jest.config.js only ignores tests/e2e/ intestPathIgnorePatterns, the Playwright end-to-end specs live there and are run separately. See E2E testing for those.

# run the whole Jest suite
npm run test

# just the fast unit tests
npm run test:unit

# integration suites (see the database note below)
npm run test:integration

# watch mode while developing
npm run test:watch

# a single file or a name filter
npm run test:unit -- tests/components/Button.test.tsx
npm run test:unit -- -t "handles click events"

The integration test database

For integration tests that need a real Postgres, the repo includes a Docker Compose service. Bring it up before running test:integration and tear it down when you are done:

# start the throwaway Postgres 15 container
npm run db:test:up

# run the integration suites
npm run test:integration

# stop and remove the container
npm run db:test:down

The db:test:up script runs docker-compose up -d db-test. Perdocker-compose.yml, the db-test service uses thepostgres:15-alpine image and maps host port 5433to the container's5432. The database is named shipveryfast_test with user/passwordtest/test. db:test:down runsdocker-compose down.

Many integration suites mock their services

Most files under tests/integration use simplified in-memory mocks for Stripe, Mailgun, and Supabase (the defaults from jest.setup.ts), so they run without Docker. Spin up db:test:up when a suite actually exercises a live Postgres connection.

React Testing Library patterns

Component tests use React Testing Library and @testing-library/user-event. The convention across tests/components/ is to query by accessible role, then drive the component with realistic user interactions and assert on the DOM via@testing-library/jest-dom matchers. Here is a trimmed version oftests/components/Button.test.tsx:

import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import Button from '@/components/ui/button';

describe('Button', () => {
  it('renders button with default props', () => {
    render(<Button>Click me</Button>);
    const button = screen.getByRole('button', { name: /click me/i });
    expect(button).toBeInTheDocument();
    expect(button).toHaveTextContent('Click me');
  });

  it('handles click events', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();
    render(<Button onClick={handleClick}>Clickable Button</Button>);
    await user.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('prevents click when disabled', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();
    render(<Button onClick={handleClick} disabled>Disabled</Button>);
    await user.click(screen.getByRole('button'));
    expect(handleClick).not.toHaveBeenCalled();
  });
});

A few patterns worth copying from the existing suites:

  • Query by role and accessible name, screen.getByRole('button', { name: /log in/i }) keeps tests resilient and accessibility-aware.
  • Drive with userEvent, callconst user = userEvent.setup() and await each interaction. The integration LoginForm test, for example, types into fields and useswaitFor to assert the submit button becomes enabled before clicking.
  • Spy with jest.fn(), pass mock handlers as props and assert ontoHaveBeenCalledWith(...) / toHaveBeenCalledTimes(...).

Mocking modules

Because jest.setup.ts registers global mocks (including a hand-rolled mock for@/libs/utils), some unit tests opt back into the real implementation. Thecn test in tests/libs/utils.test.ts starts withjest.unmock('@/libs/utils') so it exercises the realclsx + tailwind-merge behaviour it asserts on:

// Opt out of the global @/libs/utils mock for this suite.
jest.unmock('@/libs/utils');
import { cn } from '@/libs/utils';

it('merges duplicate Tailwind classes', () => {
  expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
});

For per-test environment control, follow tests/libs/admin.test.ts: it saves and restores process.env.ADMIN_EMAILS around each test so changing it does not leak into other suites. The rule of thumb, set global defaults in jest.setup.ts, and use jest.mock, jest.unmock, or local process.envoverrides when a specific suite needs something different.

Coverage thresholds & exclusions

Coverage is off by default (collectCoverage: false) and only collected when you pass --coverage. collectCoverageFrom includesapp, components, libs, and models, then deliberately excludes code that is presentational or thin glue over an SDK, there is no logic worth unit-testing there. Notable exclusions:

  • !**/middleware.ts and !app/layout.tsx, complex / integration-tested.
  • !components/providers/**, !components/landing/**,!components/auth/**, !components/dashboard/**,!components/admin/**, presentational shells and marketing sections.
  • !libs/ai/** and !app/api/ai/**, thin Anthropic/OpenAI SDK adapters and the streaming AI route, which are exercised via integration with real keys rather than unit tests.
  • !**/*.d.ts, !**/types.ts, !**/*.config.{js,ts}, and the tests / __mocks__ trees.

The thresholds are intentionally low and act as a ratchet, CI fails if coverage drops below them, and you raise the numbers as real coverage grows:

coverageThreshold: {
  global: {
    branches: 10,
    functions: 5,
    lines: 10,
    statements: 10
  }
}

Reports are written to <rootDir>/coverage in several formats (text, text-summary, lcov, html,json, cobertura). To browse the HTML report after a coverage run, there is a helper script:

npm run test:coverage
npm run coverage:open  # opens coverage/lcov-report/index.html

If you add a genuinely untestable presentational component or SDK wrapper, extendcollectCoverageFrom with another !-prefixed glob rather than writing hollow tests just to satisfy the threshold.

Next steps