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: trueandrestoreMocks: 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'
}| Pattern | Maps to | Why |
|---|---|---|
^@/(.*)$ | <rootDir>/$1 | The @/ alias resolves to the repo root, so @/libs/utils and@/components/ui/button work in tests exactly as in the app. |
| CSS/Sass imports | identity-obj-proxy | Style imports return a proxy where every class name maps to itself, so importing a stylesheet never breaks a test. |
| Image imports | __mocks__/fileMock.js | Static 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.
| Script | Command | What it runs |
|---|---|---|
npm run test | jest | All Jest tests (unit + integration; e2e is ignored by config). |
npm run test:unit | jest --testPathIgnorePatterns=tests/e2e --testPathIgnorePatterns=tests/integration | Unit tests only, excluding both e2e and integration folders. |
npm run test:integration | jest tests/integration | Only the integration suites under tests/integration. |
npm run test:watch | jest --watch | Re-runs affected tests as you edit. |
npm run test:coverage | jest --coverage | Full run with a coverage report in /coverage. |
npm run test:coverage:unit | jest --coverage --testPathIgnorePatterns=tests/e2e --testPathIgnorePatterns=tests/integration | Unit coverage only. |
npm run test:coverage:integration | jest --coverage tests/integration | Integration coverage only. |
npm run test:ci | jest --coverage --watchAll=false --passWithNoTests | CI-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:downThe 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()andawaiteach interaction. The integrationLoginFormtest, for example, types into fields and useswaitForto 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.tsand!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 thetests/__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.htmlIf 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
- End-to-end testing, the Playwright specs in
tests/e2e. - Testing overview, how the suites fit together.
- Dev tooling, linting, the devtool, and Husky hooks.
