Jest is the JavaScript test runner you should keep using when you have inherited a working test suite, when your team is already trained on it, or when a library you depend on hard-binds to Jest's runtime. For a solo founder or technical co-founder pre-PMF, the right move is almost never "rip out Jest and switch to Vitest because Twitter said so." The migration cost is real (1 to 3 founder-days for a 50-test suite, 5 to 10 days for 200 tests, plus subtle behavior differences in mocks, timers, and ESM that bite weeks later), and Jest 29+ on Node 20 is fast enough to ship a startup. This guide is the founder playbook: when Jest is the right call, the 5-test rule that protects revenue before launch, real Jest 29+ configs for React Testing Library, Next.js App Router, Stripe webhook verification, and an honest Jest-to-Vitest migration assessment so you stop only when staying actually costs more than switching.
When Jest is the right call for a founder, and when Vitest is
Most "Jest tutorial" pages spend zero sentences on the strategic decision and 3,000 sentences on describe/it/expect. That is backwards for an entrepreneur. The first question is not "how do I write a Jest test." It is "should this codebase use Jest at all?" Get that wrong and you will waste two founder-weeks on a migration that did not need to happen, or you will fight a tool that does not fit your stack for the next year.
The honest entrepreneur framing is short: Jest is correct when the codebase already uses it, the team already knows it, the framework you depend on integrates with it cleanly, or you need a battle-tested runner with the broadest plugin ecosystem in JavaScript. Vitest is correct when you are starting greenfield in Vite (or any modern bundler), you want sub-second watch-mode feedback, your team is new to JS testing and the API is identical anyway, or you have a pure-ESM codebase where Jest's experimental VM modules are still a sharp edge.
Here is the founder decision matrix. If you can answer "yes" to any row in column A, keep or pick Jest. If you can only answer "yes" in column B, pick Vitest and read our sibling guide on Vitest for Entrepreneurs instead.
| Situation | Column A: Keep Jest | Column B: Pick Vitest |
|---|---|---|
You inherited a codebase with jest.config.js and 50+ tests | Yes (migration cost > runway you have) | |
| You are starting a brand-new Vite + React project today | Yes (zero install friction with vite-plugin-test) | |
| Your CI already runs Jest under 90 seconds and is green | Yes (do not fix what is not broken) | |
You hire engineers from create-react-app or Next pre-13 backgrounds | Yes (they know Jest, train cost = 0) | |
| You use Next.js App Router (Next 14+) | Yes (next/jest is first-party) | Possible (works, but no first-party support) |
| You use Storybook + Chromatic | Yes (Jest is the supported story-test runner) | |
| You hate slow watch loops more than anything else | Yes (Vitest watch is 5-10x faster on cold change) | |
| You want native ESM with no flags | Yes (Vitest is ESM-native; Jest still uses --experimental-vm-modules) | |
| Your team is one person and you have never written a JS test | Either works | Slightly preferred for greenfield |
The asymmetric trap to avoid: founders who migrate Jest to Vitest pre-PMF almost always regret it. The migration is technically straightforward and emotionally satisfying (the watch loop feels faster!), and it routinely consumes 1 to 3 founder-days that could have shipped a feature, fixed three onboarding bugs, or unblocked a customer who is about to churn. If your Jest suite is green and your CI is under 90 seconds, do not migrate. Spend the time on revenue.
When you should migrate (the only honest list): your test suite is now 4+ minutes in CI and Jest's parallelism is the bottleneck, your codebase has gone fully ESM and --experimental-vm-modules is causing weekly broken builds, you have hired a team that exclusively writes Vitest and the context-switch cost dominates the migration cost, or you are starting a brand-new package inside your monorepo where setting up Jest is more work than setting up Vitest. Outside those four, stay on Jest.
This page is written for the first column. If you are in column B, the Vitest page is genuinely better for your situation, and the two pages are deliberately complementary, not duplicates.
Who this page is for, in three founder personas
- The acqui-hire inheritor. You bought (or were hired into) a codebase with a working
jest.config.js, a green CI, and zero appetite to retrain the engineer who built it. Your job is to layer the founder-critical tests (money path, data path, auth) on top of what exists, not to rewrite the runner. - The bootstrap technical co-founder. You wrote the MVP in Next.js using
npx create-next-app, which scaffolds Jest vianext/jest. You have 20 tests, your CI is 45 seconds, and you are about to ship paid plans. You need the Stripe webhook test and the auth flow test before any customer card is charged. - The "still on Jest because nothing is broken" founder. Your suite has run for 18 months. You see Vitest tweets weekly. You wonder if you are leaving 15% velocity on the table. The answer is no, you are not, and this page tells you exactly when to revisit that decision (with a checklist) so the thought stops costing you weekly attention.
This page is not for: engineers at 50+ headcount with a QA function, library authors who need to support both Jest and Vitest runtimes simultaneously (you have different constraints), or developers comparing the two runners on raw benchmarks (that comparison lives in a head-to-head review, not a founder playbook). If you are not in the founder pattern above, skim and leave. Your time is the most expensive thing you own.
The pre-PMF testing pyramid and the 5-test rule before launch
Generic test-pyramid advice tells founders to write 70% unit tests, 20% integration tests, and 10% end-to-end tests. That advice is shaped for engineering teams of 5+ at companies past Series A. It is wrong for a pre-PMF bootstrap. The founder pyramid is steeper, almost vertical, and the math is dollar-driven rather than coverage-driven.
The founder math: a test you write before launch costs roughly 8 to 20 minutes of your time. A bug that ships and reaches a paying customer costs roughly 30 minutes of support response, plus a churn-probability-weighted revenue loss that is small per bug but compounds across a quarter. The asymmetric question is which 1% of code paths, if broken, will lose you a customer or a payment. Concentrate your test budget there. Skip everything else until the metric tells you to look.
The two surfaces a pre-PMF founder must test, no exceptions: the money path (anything between "user clicks pay" and "subscription row written, entitlement granted, receipt emailed") and the data path (anything that writes, reads, exports, deletes, or shares a customer's own data). These two surfaces account for over 80% of churn-inducing bugs in early SaaS. Everything else, including marketing-site components, helper functions with one branch, and pretty error pages, can ship untested for now.
The 5-test rule: the founder protocol before any paid launch
Before you flip your Stripe account from test mode to live mode, write exactly five Jest tests. Not six, not coverage thresholds, not a snapshot of every component. Five. They are:
- Sign-up creates an authenticated session. Hit the sign-up endpoint with a valid email and password, assert the session cookie is set (or the JWT is returned), and assert the user row is written to the database with the correct default plan.
- Stripe
checkout.session.completedwebhook grants entitlement. Send a signed webhook payload, assert signature verification passes, assert the user's plan row is updated to the paid tier, assert idempotency (replaying the same event does not double-grant). - Auth-gated route rejects anonymous and allows authenticated. Hit your dashboard or app route once without a session cookie (expect 401 or redirect), once with a valid session (expect 200 and the payload).
- Primary CRUD write succeeds and is read-isolated. Create the canonical "thing" your product makes (a project, a document, a board, an analysis), assert it is readable by its owner, assert a second user cannot read it.
- Primary error path renders without crash. Hit the most likely error condition (invalid input, network failure, unauthorized resource), assert the UI renders a non-empty error state instead of a blank screen or React error boundary.
Five tests. If you cannot write all five in a day with the configs below, your application architecture is the problem, not your testing budget. After your first ten paying customers, expand: add tests for any bug that reached a customer (those are the high-ROI tests, because the path was real enough to break in production). After your first 100 paying customers, hire help and write coverage as policy, not by feel.
The founder pyramid in proportions
| Layer | Founder share | What it tests | Time budget (your suite, total) | When you run it |
|---|---|---|---|---|
| Unit (Jest, pure functions) | 50% | Money/data helpers, validation, parsers, business rules | Sub-2 seconds | Watch mode, every save |
| Integration (Jest + React Testing Library + msw) | 40% | Component + network, Stripe webhook handler, auth flow, server actions | Under 10 seconds | Pre-push hook |
| E2E (Playwright, optional) | 10% | Money path end-to-end (sign-up → first paid event) | Under 60 seconds | CI on deploy-to-main only |
Why 50/40/10 instead of the classic 70/20/10? Because pre-PMF you have very little pure logic and a lot of integration glue. Your real bugs live at the seams (Stripe webhook signature, session cookie domain, database constraint, RLS policy), not in helper functions. Test where the bugs actually live. The 10% Playwright slice is optional and most pre-PMF founders should defer it until they have a paying customer, then ship one Playwright test for the money path before customer #10.
A founder rule of thumb that has survived every codebase: if a test would take more than 60 seconds in CI, it is the wrong test or the wrong layer. Either split it, mock its slow dependency with msw or jest.mock, or move its scenario to a manual checklist you run before each Friday deploy.
Install Jest 29+ in a real React + Next.js codebase in 10 minutes
This section assumes Next.js 14+ App Router because that is what most pre-PMF SaaS bootstraps run today. If you are on plain Vite + React, jump to the Vitest sibling guide; Vitest is the right choice there. If you are on Create React App (deprecated since 2023), you already have Jest configured and you can skip to the next section.
The 10-minute path assumes Node 20+, pnpm (npm/yarn work, swap commands), and a clean git status so you can roll back if needed.
Minute 0-2: Install Jest and the founder companions
pnpm add -D jest@^29 jest-environment-jsdom@^29 \
@testing-library/react @testing-library/jest-dom \
@testing-library/user-event @types/jest \
ts-node @swc/core @swc/jest msw whatwg-fetchYou are installing eight things. Jest 29+ itself, the jsdom environment (which was extracted from core Jest in 28+ and now ships as a separate package — installing it explicitly is required), React Testing Library plus the jest-dom custom matchers, user-event for interaction testing, @types/jest for TypeScript types (or @jest/globals if you prefer explicit imports), ts-node so Jest can read a TypeScript config file, @swc/jest as the SWC-based transformer (3-5x faster than babel-jest and what Next.js uses internally), msw for HTTP-level mocking of Stripe and any third-party API, and whatwg-fetch polyfill for environments where the global fetch is not available in tests.
Pin the major versions exactly. Jest minor bumps occasionally change matcher behavior, and a founder cannot afford a Tuesday morning where npm install silently flips a green suite to red. Use --save-exact or commit your pnpm-lock.yaml and treat it like the contract it is.
Minute 2-4: Write jest.config.js
For a Next.js 14+ App Router codebase, use the official next/jest helper. It wires up SWC, CSS module mocks, the right module resolver, and the Next.js test environment without you fighting Babel:
// jest.config.js
const nextJest = require('next/jest')
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files
dir: './',
})
/** @type {import('jest').Config} */
const customJestConfig = {
setupFilesAfterEach: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
'!src/**/types.ts',
'!src/app/**/layout.tsx',
],
coverageThreshold: {
global: {
branches: 60,
functions: 70,
lines: 70,
statements: 70,
},
},
testMatch: ['**/__tests__/**/*.{ts,tsx}', '**/*.test.{ts,tsx}'],
testPathIgnorePatterns: ['/node_modules/', '/.next/', '/e2e/'],
}
module.exports = createJestConfig(customJestConfig)If you are on plain React (Vite) and stuck with Jest because of an inherited config, drop the next/jest wrapper and use this instead:
// jest.config.js (plain React + Jest, no Next.js)
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterEach: ['<rootDir>/jest.setup.ts'],
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest', {
jsc: {
parser: { syntax: 'typescript', tsx: true },
transform: { react: { runtime: 'automatic' } },
},
}],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
testMatch: ['**/*.test.{ts,tsx}'],
}Minute 4-5: Write jest.setup.ts
// jest.setup.ts
import '@testing-library/jest-dom'
import 'whatwg-fetch'
import { server } from './src/test/msw-server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())Two things matter here. First, the '@testing-library/jest-dom' import is what unlocks matchers like toBeInTheDocument(), toHaveTextContent(), and toBeDisabled() — without it, your tests will compile but the matchers will not exist at runtime. Second, the onUnhandledRequest: 'error' setting is the founder safety net: if a test accidentally hits the real network (e.g. forgot to mock Stripe), it fails loudly instead of quietly racing your wallet.
Minute 5-6: Write src/test/msw-server.ts
// src/test/msw-server.ts
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
export const server = setupServer(
// Default handler for Stripe API (override per-test as needed)
http.post('https://api.stripe.com/v1/*', () =>
HttpResponse.json({ error: 'no msw handler defined' }, { status: 500 })
),
)This is the founder pattern: msw fails fast by default on unmocked Stripe calls. Each test that interacts with Stripe overrides this handler explicitly. That single safety pattern has saved more late-night production fires than every coverage threshold combined.
Minute 6-7: Update package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "jest --ci --coverage --maxWorkers=2 --silent",
"test:money": "jest --testPathPattern='(webhook|auth|stripe|billing)'"
}
}The test:money script is the founder shortcut: when you are about to push to main, run only the tests that protect revenue. Sub-5-second feedback on the money path beats a 90-second full-suite run every time.
Minute 7-9: Write your first real test
// src/components/SignupForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from '../test/msw-server'
import SignupForm from './SignupForm'
describe('SignupForm', () => {
it('creates an account on valid submit and routes to /onboarding', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/signup', async ({ request }) => {
const body = await request.json() as any
if (!body.email || !body.password) {
return HttpResponse.json({ error: 'missing fields' }, { status: 400 })
}
return HttpResponse.json({ userId: 'u_123', plan: 'free' }, { status: 200 })
})
)
const onSuccess = jest.fn()
render(<SignupForm onSuccess={onSuccess} />)
await user.type(screen.getByLabelText(/email/i), '[email protected]')
await user.type(screen.getByLabelText(/password/i), 'correct horse battery staple')
await user.click(screen.getByRole('button', { name: /sign up/i }))
expect(await screen.findByText(/welcome/i)).toBeInTheDocument()
expect(onSuccess).toHaveBeenCalledWith({ userId: 'u_123', plan: 'free' })
})
it('shows an error and does not call onSuccess on 400', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/signup', () =>
HttpResponse.json({ error: 'email already in use' }, { status: 400 })
)
)
const onSuccess = jest.fn()
render(<SignupForm onSuccess={onSuccess} />)
await user.type(screen.getByLabelText(/email/i), '[email protected]')
await user.type(screen.getByLabelText(/password/i), 'whatever1234')
await user.click(screen.getByRole('button', { name: /sign up/i }))
expect(await screen.findByText(/email already in use/i)).toBeInTheDocument()
expect(onSuccess).not.toHaveBeenCalled()
})
})Minute 9-10: Run it
pnpm test:watchYou should see two passing tests in under 2 seconds. If not, the most common failures are: forgot the 'use client' directive on a client component (Next.js App Router specific), forgot to install jest-environment-jsdom (Jest 28+ split it out), or moduleNameMapper paths drift from tsconfig.json (keep them in sync via pathsToModuleNameMapper from ts-jest/utils).
The Stripe webhook test: the highest-ROI test in your suite
If you only ever write one Jest test, write this one. The Stripe checkout.session.completed webhook is the single most expensive line of code in a SaaS app: when it breaks silently, customers pay and never get access, you find out from a support ticket three days later, and refund probability is roughly 60% conditioned on detection lag. Every minute you protect this surface with a test is one of the highest-leverage minutes of your founder year.
What the test must prove
A correct Stripe webhook test in Jest must prove four things, in this order: signature verification rejects unsigned and wrongly-signed requests, idempotency prevents double-grants when Stripe replays the event (and Stripe will replay), entitlement is written to the database for a valid event, and unknown event types are gracefully ignored without throwing 500s (or Stripe disables your webhook endpoint after enough failures).
Real code: the handler
Here is the handler under test (Next.js App Router route handler, but the pattern is identical for Express, Fastify, or any framework):
// src/app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-11-20.acacia' })
export async function POST(req: NextRequest) {
const body = await req.text()
const signature = req.headers.get('stripe-signature')
if (!signature) return NextResponse.json({ error: 'missing sig' }, { status: 400 })
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return NextResponse.json({ error: 'invalid sig' }, { status: 400 })
}
// Idempotency: store every event_id we have seen
const alreadyProcessed = await db.webhookEvent.findUnique({ where: { stripeEventId: event.id } })
if (alreadyProcessed) return NextResponse.json({ received: true, replayed: true }, { status: 200 })
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const userId = session.metadata?.userId
if (!userId) return NextResponse.json({ error: 'no userId in metadata' }, { status: 400 })
await db.user.update({ where: { id: userId }, data: { plan: 'pro', stripeCustomerId: session.customer as string } })
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
await db.user.updateMany({ where: { stripeCustomerId: sub.customer as string }, data: { plan: 'free' } })
break
}
default:
// ignore other event types but ack
break
}
await db.webhookEvent.create({ data: { stripeEventId: event.id, type: event.type } })
return NextResponse.json({ received: true }, { status: 200 })
}Real code: the Jest test
// src/app/api/webhooks/stripe/route.test.ts
import { POST } from './route'
import { NextRequest } from 'next/server'
import Stripe from 'stripe'
import { db } from '@/lib/db'jest.mock('@/lib/db', () => ({ db: { user: { update: jest.fn(), updateMany: jest.fn() }, webhookEvent: { findUnique: jest.fn(), create: jest.fn() }, }, }))
const TEST_SECRET = 'whsec_test_dummy_secret' const stripe = new Stripe('sk_test_x', { apiVersion: '2024-11-20.acacia' })
function buildSignedRequest(eventPayload: object) { const body = JSON.stringify(eventPayload) const header = stripe.webhooks.generateTestHeaderString({ payload: body, secret: TEST_SECRET, }) return new NextRequest('http://localhost/api/webhooks/stripe', { method: 'POST', headers: { 'stripe-signature': header, 'content-type': 'application/json' }, body, }) }
describe('Stripe webhook', () => { beforeEach(() => { jest.clearAllMocks() process.env.STRIPE_WEBHOOK_SECRET = TEST_SECRET ;(db.webhookEvent.findUnique as jest.Mock).mockResolvedValue(null) ;(db.webhookEvent.create as jest.Mock).mockResolvedValue({ id: 'evt_row_1' }) })
it('rejects requests without a signature', async () => { const req = new NextRequest('http://localhost/api/webhooks/stripe', { method: 'POST', body: JSON.stringify({ id: 'evt_1', type: 'checkout.session.completed' }), }) const res = await POST(req) expect(res.status).toBe(400) })
it('rejects requests with an invalid signature', async () => { const body = JSON.stringify({ id: 'evt_1', type: 'checkout.session.completed' }) const req = new NextRequest('http://localhost/api/webhooks/stripe', { method: 'POST', headers: { 'stripe-signature': 't=1,v1=wrongsignature' }, body, }) const res = await POST(req) expect(res.status).toBe(400) })
it('grants pro entitlement on checkout.session.completed', async () => { const event = { id: 'evt_test_grant', type: 'checkout.session.completed', data: { object: { metadata: { userId: 'u_abc' }, customer: 'cus_xyz' } }, } const req = buildSignedRequest(event) const res = await POST(req) expect(res.status).toBe(200) expect(db.user.update).toHaveBeenCalledWith({ where: { id: 'u_abc' }, data: { plan: 'pro', stripeCustomerId: 'cus_xyz' }, }) expect(db.webhookEvent.create).toHaveBeenCalledWith({ data: { stripeEventId: 'evt_test_grant', type: 'checkout.session.completed' }, }) })
it('is idempotent on replay (does not double-grant)', async () => { ;(db.webhookEvent.findUnique as jest.Mock).mockResolvedValue({ id: 'evt_row_existing' }) const event = { id: 'evt_test_grant', type: 'checkout.session.completed', data: { object: { metadata: { userId: 'u_abc' }, customer: 'cus_xyz' } }, } const req = buildSignedRequest(event) const res = await POST(req) expect(res.status).toBe(200) expect(db.user.update).not.toHaveBeenCalled() expect(db.webhookEvent.create).not.toHaveBeenCalled() })
it('downgrades to free on customer.subscription.deleted', async () => { const event = { id: 'evt_test_cancel', type: 'customer.subscription.deleted', data: { object: { customer: 'cus_xyz' } }, } const req = buildSignedRequest(event) const res = await POST(req) expect(res.status).toBe(200) expect(db.user.updateMany).toHaveBeenCalledWith({ where: { stripeCustomerId: 'cus_xyz' }, data: { plan: 'free' }, }) })
it('acks unknown event types without 500', async () => { const event = { id: 'evt_unknown', type: 'invoice.upcoming', data: { object: {} } } const req = buildSignedRequest(event) const res = await POST(req) expect(res.status).toBe(200) expect(db.user.update).not.toHaveBeenCalled() }) })
That single test file proves: signature verification works in both directions, idempotency is real, entitlement writes the right row, and unknown events do not crash the endpoint. If a future refactor breaks any of those four properties, Jest fails in CI before the bug reaches a paying customer. This is the highest-ROI test you will ever write.
A common founder mistake to avoid: do not test the Stripe SDK itself. Do not mock `stripe.webhooks.constructEvent`. The Stripe SDK is rock-solid, and mocking it removes the signature verification proof. Generate a real signed header with `stripe.webhooks.generateTestHeaderString` and let the real verification run. That is what the snippet above does.
## The auth flow test: your second-highest-ROI test
The second test that pays for itself a hundred times is the auth flow test. Any auth bug (session not set, session not cleared on logout, session leaks across tabs, expired tokens not rejected) erodes customer trust in ways that take 10x longer to rebuild than revenue. Test it once with Jest and never think about it again until your auth provider changes.
For a Next.js 14+ App Router app using Auth.js (NextAuth v5), Supabase Auth, or a hand-rolled session-cookie auth, the test pattern is the same: render the gated route handler (or page), assert the unauthenticated response is a redirect or 401, then attach a session and assert the authenticated response is a 200 with the expected payload.
```typescript
// src/app/dashboard/page.test.tsx
import { GET as DashboardLoader } from '@/lib/dashboard-loader'
import { getSession } from '@/lib/auth'
jest.mock('@/lib/auth', () => ({
getSession: jest.fn(),
}))
describe('dashboard auth gate', () => {
beforeEach(() => jest.clearAllMocks())
it('redirects anonymous visitors to /login', async () => {
;(getSession as jest.Mock).mockResolvedValue(null)
const res = await DashboardLoader()
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe('/login?next=/dashboard')
})
it('returns 200 and the user payload for an authenticated visitor', async () => {
;(getSession as jest.Mock).mockResolvedValue({
user: { id: 'u_abc', email: '[email protected]', plan: 'pro' },
expires: '2099-01-01T00:00:00Z',
})
const res = await DashboardLoader()
expect(res.status).toBe(200)
const json = await res.json()
expect(json.user.id).toBe('u_abc')
expect(json.user.plan).toBe('pro')
})
it('rejects expired sessions with 401', async () => {
;(getSession as jest.Mock).mockResolvedValue({
user: { id: 'u_abc', email: '[email protected]' },
expires: '2000-01-01T00:00:00Z',
})
const res = await DashboardLoader()
expect(res.status).toBe(401)
})
it('does not leak user-A data to user-B', async () => {
;(getSession as jest.Mock).mockResolvedValue({
user: { id: 'u_bob', email: '[email protected]', plan: 'free' },
expires: '2099-01-01T00:00:00Z',
})
const res = await DashboardLoader({ requestedUserId: 'u_alice' })
expect(res.status).toBe(403)
})
})The last assertion (u_bob cannot read u_alice) is the cross-tenant leak test. It is the single test that prevents the worst class of pre-PMF security bug, and it takes 8 lines to write. Founders who skip this test are gambling their first enterprise sale; founders who write it sleep better.
React Testing Library + Jest: the founder UI test pattern
The principle that took me five startups to internalize: test what the user sees and does, not what the component renders internally. React Testing Library enforces this principle by exposing queries that match the DOM the way a user would read it (by accessible role, by label, by visible text), and refusing to expose queries that match internal implementation (no find by classname, no find by component type).
A founder pattern that works:
// src/components/PricingCard.test.tsx
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PricingCard from './PricingCard'
describe('PricingCard', () => {
it('renders plan, price, and CTA', () => {
render(<PricingCard plan="Pro" price={29} ctaHref="/checkout?plan=pro" />)
expect(screen.getByRole('heading', { name: /pro/i })).toBeInTheDocument()
expect(screen.getByText('$29')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /get started/i })).toHaveAttribute('href', '/checkout?plan=pro')
})
it('marks the recommended plan with an accessible badge', () => {
render(<PricingCard plan="Pro" price={29} ctaHref="/checkout?plan=pro" recommended />)
expect(screen.getByText(/recommended/i)).toBeInTheDocument()
})
it('fires onSelect when the CTA is activated by keyboard', async () => {
const user = userEvent.setup()
const onSelect = jest.fn()
render(<PricingCard plan="Pro" price={29} ctaHref="/checkout?plan=pro" onSelect={onSelect} />)
await user.tab()
await user.keyboard('{Enter}')
expect(onSelect).toHaveBeenCalledWith('Pro')
})
})Three queries to remember (and three to avoid). Prefer getByRole, getByLabelText, and getByText. Avoid getByTestId unless there is genuinely no accessible alternative; if you reach for data-testid constantly, your DOM is not accessible, and your tests are quietly papering over a real UX problem you should fix.
A second founder pattern: use userEvent for every interaction, not the older fireEvent. userEvent simulates the entire browser event sequence (mousedown, mouseup, focus, click) that a real user produces, which catches bugs fireEvent.click cannot.
Jest 29+ config gotchas that bite founders (and how to fix them)
A short list of the configuration issues that consume founder hours, in descending order of frequency.
1. jest-environment-jsdom not installed. Jest 28+ extracted jsdom from the core package. If you see Test environment jest-environment-jsdom cannot be found, install it explicitly with pnpm add -D jest-environment-jsdom. This is the #1 upgrade pain.
2. ESM imports throwing Cannot use import statement outside a module. Jest's default transformer chokes on ESM-only packages (e.g. nanoid, uuid v9+, some @octokit/* packages, modern chalk). Three fixes, in order of preference. First, swap to a CommonJS fork if one exists. Second, add the package to transformIgnorePatterns: transformIgnorePatterns: ['node_modules/(?!(nanoid|uuid)/)']. Third, switch the whole project to Jest's experimental ESM mode with --experimental-vm-modules and "type": "module" in package.json. The third option is the cleanest long-term but is still flagged experimental in Jest 29.
3. Cannot find module '@/...' from .... Your TypeScript path aliases are not propagated to Jest. Add a moduleNameMapper entry that mirrors your tsconfig.json paths exactly. For an entrepreneur tip: use pathsToModuleNameMapper from ts-jest/utils so you maintain one config, not two.
4. React Testing Library act() warnings everywhere. You are calling setState inside a useEffect and Testing Library is racing. Wrap the assertion in await waitFor(() => ...) or use findBy* queries (which already wait). If the warning persists, you have a real bug in your component (state update after unmount); fix the component, do not silence the warning.
5. Module resolution slow on first run. Add "jest": { "watchPathIgnorePatterns": ["/.next/", "/dist/", "/coverage/"] } to package.json. Jest scanning .next is the silent killer of watch-mode performance.
6. setupFilesAfterEach vs setupFiles. In Jest 29, the canonical option is setupFilesAfterEach. If you copy an older config snippet that uses setupTestFrameworkScriptFile or just setupFiles, your matchers will not be available in tests. Use setupFilesAfterEach and import @testing-library/jest-dom there.
7. Coverage thresholds blocking the deploy of a hotfix. Founder rule: coverage thresholds should be advisory, not blocking, until you have 100 customers. Set coverageThreshold in jest.config.js and --coverage in test:ci, but do not gate merge to main on threshold pass until you have stabilized the product. Otherwise you will, at the worst possible moment, find yourself unable to ship a customer-facing fix because a snapshot threshold dropped 1%.
The Jest-to-Vitest migration assessment: when to stay, when to switch
The honest scorecard. Run through the eight rows below. Score 0 (no), 1 (yes), or 2 (strong yes). Tally.
| Row | Question | 0 | 1 | 2 |
|---|---|---|---|---|
| 1 | Is your CI suite over 4 minutes? | No | Yes, mildly | Yes, painfully |
| 2 | Are you blocked on ESM-only dependencies more than once per month? | No | Sometimes | Constantly |
| 3 | Is your team starting a new package or monorepo workspace? | No | Maybe | Yes, this quarter |
| 4 | Have you hired engineers in the last six months who exclusively know Vitest? | No | One | Two or more |
| 5 | Is your watch-mode round-trip over 5 seconds and slowing your dev loop? | No | Sometimes | Daily friction |
| 6 | Are you on Vite as your dev/build tool? | No | Mixed | Vite-only |
| 7 | Is your Jest config drifting (transforms, polyfills, mocks) into maintenance pain? | No | Mildly | Major time sink |
| 8 | Do you have at least 2 founder-days of slack runway this quarter to migrate? | No | Maybe | Yes |
Tally. 0-5 points: stay on Jest. Migration cost dominates benefit at your scale. 6-10 points: migrate one workspace as a pilot, learn the differences in your real codebase, decide based on lived experience, not Twitter. 11+ points: migrate fully. You have hit the inflection point where staying costs more than switching.
What changes in migration, mechanically. jest.fn() becomes vi.fn(). jest.mock(...) becomes vi.mock(...). jest.useFakeTimers() becomes vi.useFakeTimers(). Module-mocking hoisting works differently (Vitest hoists vi.mock automatically; Jest hoists jest.mock only when at the top of the file). Snapshot serializers move from jest.config.js to vitest.config.ts. jest-dom matchers work in both via @testing-library/jest-dom/vitest.
What does not change. describe, it, test, expect, beforeEach, afterEach, beforeAll, afterAll, toBe, toEqual, toHaveBeenCalled, toHaveBeenCalledWith, every React Testing Library query, every msw handler. The API surface is roughly 95% identical, which is exactly why most founders correctly conclude that migrating is not worth the founder-days at their stage.
For the full sibling guide on what greenfield Vitest looks like, see Vitest for Entrepreneurs. For a head-to-head feature-by-feature comparison, see our JavaScript Testing Best Practices overview when it ships.
How this fits the broader Solomon Signal founder testing playbook
Three other pieces of the same puzzle are worth linking when you have ten minutes after shipping the 5-test rule:
- Vitest for Entrepreneurs — the greenfield-Vite-project sibling page. Read it if you are starting from scratch this week.
- JavaScript Test Coverage Best Practices — when coverage thresholds help and when they destroy founder runway.
- Stripe Webhook Testing in Production — the production-side companion to the Jest test in this guide (replay testing, observability, alerting on missed events).
For external authority, the canonical Jest documentation lives at jestjs.io and is updated regularly; the Stripe testing reference for webhook signing keys, replay protection, and the stripe.webhooks.generateTestHeaderString helper used above is on docs.stripe.com.
FAQs
Is Jest still relevant in 2026?
Yes. Jest is the most widely deployed JavaScript test runner in production, and Next.js, Storybook, React Native, and a long tail of enterprise React codebases continue to ship with Jest as the default. The Jest project shipped 29.x with meaningful performance improvements in 2024-2025 and continues to release. "Vitest is winning Twitter" and "Jest is dying in production" are not the same statement. If you have a Jest codebase that works, you can ship a startup on Jest in 2026 without compromise.
Should I use Jest or Vitest for a new SaaS project?
If you are on Vite, use Vitest. If you are on Next.js, use Jest via next/jest. If you are on plain Node, either works and the team-knowledge tiebreaker wins. The general rule for entrepreneurs: pick the runner that integrates with your framework's first-party setup. Fighting your framework to use a different runner is the worst pre-PMF time sink in JavaScript testing.
How do I test Stripe webhooks with Jest?
Generate a real signed header with stripe.webhooks.generateTestHeaderString({ payload, secret: 'whsec_test_dummy' }), send it to your route handler, and let the real stripe.webhooks.constructEvent run inside your handler. Mock the database, not the Stripe SDK. The full pattern is in the "Stripe webhook test" section above, with five assertions that cover signature, idempotency, entitlement, downgrade, and unknown-type behavior.
What is the minimum number of tests I should write before launching a paid product?
Five. Sign-up + session, Stripe webhook + entitlement, auth-gated route, primary CRUD write with read isolation, primary error path renders. After your first ten paying customers, add a test for every bug that reached a customer. That bug-driven discipline alone outperforms 80% coverage chased blindly.
How do I set up Jest with Next.js App Router?
Use the official next/jest helper. It wires SWC, CSS module mocks, the right module resolver, and the Next.js test environment. Full config snippet is in the "Install Jest 29+ in a real React + Next.js codebase" section above. If you are on Next.js 14+ and you are not using next/jest, you are likely fighting transforms unnecessarily.
How do I migrate from Jest to Vitest, and should I?
Score the 8-row matrix in the "Jest-to-Vitest migration assessment" section. If you score 0-5, stay. If 6-10, pilot one workspace. If 11+, migrate fully. Mechanically the migration is mostly jest.* to vi.* and config-file translation; the API surface is roughly 95% identical. The decision is not technical; it is about founder-days and team training cost.
Do I need React Testing Library if I am using Jest?
Yes, for React/Next.js codebases. Jest is the test runner; React Testing Library is the rendering and querying layer. They are complements, not alternatives. Install both, plus @testing-library/jest-dom for matchers and @testing-library/user-event for interaction.
How fast should my Jest suite be?
Sub-2 seconds for unit tests in watch mode, under 10 seconds for the integration layer pre-push, under 90 seconds for the full CI run. If any layer exceeds these budgets, you have a test architecture problem (slow dependency unmocked, test discovery scanning too much, or you have moved past pre-PMF and should hire help). Founder rule: a test that takes 60+ seconds in CI is in the wrong layer.