Direct answer (read this first, ~110 words): Vitest is a Vite-native testing framework that fits a 2-10 person small-business engineering team better than Jest because it runs faster, uses your existing Vite config, and ships project-mode for monorepos out of the box. For a typical SMB stack (one internal tool, one customer-facing app, one marketing site) you can be writing meaningful tests inside thirty minutes and have a GitHub Actions workflow staying inside the free-tier 2,000-minute budget by the end of week one. This page is the full playbook: real vitest.config.ts, four production test patterns (Stripe webhook, CRUD, form validation, auth), CI YAML, ROI math, and a handoff README template.
If you are a solo founder shipping alone, read Vitest for Entrepreneurs instead. That page assumes one developer and intentionally skips the CI, handoff, and project-mode sections that make this page worth your time. This page is for tech leads and small engineering teams where the bus factor is real and CI minutes cost money.
Why Vitest is the right choice for a 2-10 person SMB engineering team
Most small businesses with a software team are not running a hundred-engineer monorepo. You probably have one or two repos, a stack that touches Vite or Next.js or a Vue/Svelte/React frontend, a Node or edge-functions backend, and a Stripe integration that absolutely cannot break. The testing framework you pick has to match that shape.
Jest was the right answer in 2019. Today, for a Vite-native or even a Next.js codebase, Vitest is the right answer because of four properties that matter at SMB scale.
It uses your existing Vite config. If you already have vite.config.ts working in dev and prod, Vitest reads it. You do not run a parallel transformer pipeline (Babel for tests, esbuild for build). One config, one transform pipeline, one source of truth. For a 3-dev team this saves the weekly "tests pass locally but break on Vercel" support tax that Jest commonly imposes.
It ships project-mode for monorepos. Most SMBs end up with a soft monorepo (an apps/portal and an apps/marketing sharing a packages/ui) within twelve months. Jest needs separate config files and roots; Vitest 4 ships first-class projects config where you describe each project once in one file. We will write that file later in the page.
Speed matters with three developers. A 90-second test run is a 4.5 minute team-wide tax every time CI fans out to three pull requests. Vitest's incremental watch mode, Vite-native HMR-style test reruns, and parallel-by-default workers cut the per-PR feedback loop. For a 3-dev SMB that ships an average of 18 PRs/week, the saved CI minutes alone keep you inside the GitHub Actions free tier.
Browser-mode without a full e2e harness. SMBs cannot afford a dedicated QA engineer or a full Playwright e2e suite. Vitest's browser-mode runs your component tests in a real browser via Playwright as a driver but stays inside the unit-test mental model. You get real-DOM regression coverage for a customer portal without paying the e2e tax.
Those four properties compound. The 3-dev team that picks Vitest is materially faster six months in than the same team that picked Jest, and the failure modes (broken CI, slow tests, monorepo config drift, missing browser regression coverage) are the ones that historically push small engineering teams off testing entirely.
When Vitest is NOT the right choice
Be honest. If your SMB stack is a pure Node API with no Vite anywhere, Jest or Node's built-in node:test may suit you. If you are on Rails or Django with a thin JS layer, do not adopt Vitest as your primary harness. The decision tree:
- Frontend touches Vite, Next.js, Astro, or Nuxt → Vitest is the answer.
- Soft monorepo with shared TS packages → Vitest is the answer.
- Pure Node backend with no bundler → Either works. Vitest still wins on watch-mode speed but Node's built-in runner is zero-config.
- Heavy snapshot or visual regression need → Vitest browser-mode covers most; if you also need full e2e (multi-page user flows), pair Vitest with Playwright Test as a second framework.
The 30-minute setup: vitest.config.ts with coverage thresholds and project-mode
Here is the install plus a production-grade config file. Copy this verbatim into your repo and you will be ahead of every Vitest tutorial on the SERP.
Install
pnpm add -D vitest @vitest/coverage-v8 @vitest/ui jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-eventFor Vue, swap @testing-library/react for @testing-library/vue. For Svelte, @testing-library/svelte. Vitest 4 requires Vite >= 6 and Node >= 20.
vitest.config.ts: single-project version
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}', 'tests/**/*.{test,spec}.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'html', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.stories.tsx',
'src/main.tsx',
'src/vite-env.d.ts',
],
thresholds: {
// SMB-realistic floors. Adjust upward over time.
lines: 60,
functions: 60,
branches: 55,
statements: 60,
// Per-file overrides for the revenue paths.
'src/payments/**': { lines: 90, functions: 90, branches: 85, statements: 90 },
'src/auth/**': { lines: 85, functions: 85, branches: 80, statements: 85 },
},
},
pool: 'threads',
poolOptions: {
threads: { singleThread: false, isolate: true },
},
testTimeout: 10_000,
hookTimeout: 10_000,
reporters: process.env.GITHUB_ACTIONS
? ['default', 'github-actions']
: ['default'],
},
})The five lines that separate this from every SERP tutorial:
- Coverage thresholds with per-path overrides. Most teams set one global number and feel virtuous. The right pattern is a modest global floor (60%) plus aggressive floors on revenue paths (
src/payments,src/auth). This is how a 3-dev SMB stays honest about where coverage actually matters. github-actionsreporter conditional on the CI env var. Inline annotations on PRs, no extra plugin.thresholds.statementson the per-path override gets you proportional coverage on small files.excludecovers stories and dev-only files. SMBs that adopt Storybook should not have their coverage diluted by.stories.tsx.hookTimeoutexplicit. Default hook timeout has bitten teams running real DB setup inbeforeAll.
vitest.config.ts: project-mode version (soft monorepo)
If you have apps/portal and apps/marketing and packages/ui in one repo, do not run three Vitest configs. Run one:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
projects: [
{
extends: true,
test: {
name: 'portal',
root: './apps/portal',
environment: 'jsdom',
setupFiles: ['./apps/portal/tests/setup.ts'],
include: ['apps/portal/src/**/*.test.{ts,tsx}'],
},
},
{
extends: true,
test: {
name: 'marketing',
root: './apps/marketing',
environment: 'jsdom',
include: ['apps/marketing/src/**/*.test.{ts,tsx}'],
},
},
{
extends: true,
test: {
name: 'ui-package',
root: './packages/ui',
environment: 'jsdom',
include: ['packages/ui/src/**/*.test.{ts,tsx}'],
},
},
{
extends: true,
test: {
name: 'node-api',
root: './apps/api',
environment: 'node',
include: ['apps/api/src/**/*.test.ts'],
},
},
],
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'lcov'],
},
},
})Now pnpm vitest --project=portal runs only one. pnpm vitest runs all four in parallel. pnpm vitest --project=portal --project=ui-package runs two. This is the killer SMB feature: your CI can fan-out per project, your local watch loop is fast, and you have one config file to maintain.
tests/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
// Reset DOM after every test.
afterEach(() => {
cleanup()
})
// Predictable timers and randomness for snapshot stability.
beforeEach(() => {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
})
// Polyfills for jsdom that real browsers ship by default.
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver
if (!('IntersectionObserver' in globalThis)) {
class IntersectionObserverMock {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() { return [] }
root = null
rootMargin = ''
thresholds = []
}
globalThis.IntersectionObserver = IntersectionObserverMock as unknown as typeof IntersectionObserver
}The ResizeObserver and IntersectionObserver polyfills will save you the third-week support ticket where a Headless UI dropdown or a virtualised list throws in CI but not locally. Bake them into setup once.
package.json scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:cov": "vitest run --coverage",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions",
"test:browser": "vitest run --project=portal --browser.enabled --browser.name=chromium"
}
}Five scripts, each does one thing. test:ci is the one Actions calls. test:browser only fires when you opt-in (because browser-mode is slower; you do not want every push paying that cost).
Four real-world tests every SMB should write first
The first tests you write should not be a Math.sqrt() example. They should be the four tests that, if they fail, cost you customers, money, or a Sunday-night incident. Below: working code for each, written for a typical SMB stack (TypeScript + a Node/edge backend + React or Vue UI).
Test 1: Stripe webhook signature + idempotency
The most expensive bug an SMB ships is double-charging or silently dropping a paid order because a webhook handler crashed. Test the verification + the idempotency key path.
// src/payments/stripe-webhook.ts
import Stripe from 'stripe'
import { recordPayment, hasProcessedEvent } from './ledger'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function handleStripeWebhook(
rawBody: string,
signature: string,
endpointSecret: string,
) {
const event = stripe.webhooks.constructEvent(rawBody, signature, endpointSecret)if (await hasProcessedEvent(event.id)) { return { ok: true, deduped: true } }
switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session await recordPayment({ eventId: event.id, sessionId: session.id, amount: session.amount_total ?? 0, currency: session.currency ?? 'usd', customerEmail: session.customer_email, }) return { ok: true } } default: return { ok: true, ignored: event.type } } }
```ts
// src/payments/stripe-webhook.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Stripe from 'stripe'
import { handleStripeWebhook } from './stripe-webhook'
import * as ledger from './ledger'
vi.mock('./ledger', () => ({
recordPayment: vi.fn(),
hasProcessedEvent: vi.fn(),
}))
const SECRET = 'whsec_test_xxx'
const stripe = new Stripe('sk_test_xxx')
function signedPayload(payload: object) {
const body = JSON.stringify(payload)
const header = stripe.webhooks.generateTestHeaderString({
payload: body,
secret: SECRET,
})
return { body, header }
}
describe('handleStripeWebhook (revenue path)', () => {
beforeEach(() => {
vi.mocked(ledger.hasProcessedEvent).mockResolvedValue(false)
vi.mocked(ledger.recordPayment).mockResolvedValue(undefined)
})
it('records a paid checkout once', async () => {
const { body, header } = signedPayload({
id: 'evt_1',
type: 'checkout.session.completed',
data: { object: { id: 'cs_1', amount_total: 4900, currency: 'usd', customer_email: '[email protected]' } },
})
const result = await handleStripeWebhook(body, header, SECRET)
expect(result).toEqual({ ok: true })
expect(ledger.recordPayment).toHaveBeenCalledWith({
eventId: 'evt_1',
sessionId: 'cs_1',
amount: 4900,
currency: 'usd',
customerEmail: '[email protected]',
})
})
it('rejects an unsigned or wrongly-signed payload', async () => {
const body = JSON.stringify({ id: 'evt_2', type: 'checkout.session.completed' })
await expect(
handleStripeWebhook(body, 't=1,v1=deadbeef', SECRET),
).rejects.toThrow()
expect(ledger.recordPayment).not.toHaveBeenCalled()
})
it('is idempotent on Stripe retry (same event id)', async () => {
vi.mocked(ledger.hasProcessedEvent).mockResolvedValueOnce(true)
const { body, header } = signedPayload({
id: 'evt_3',
type: 'checkout.session.completed',
data: { object: { id: 'cs_3', amount_total: 4900, currency: 'usd' } },
})
const result = await handleStripeWebhook(body, header, SECRET)
expect(result).toEqual({ ok: true, deduped: true })
expect(ledger.recordPayment).not.toHaveBeenCalled()
})
it('ignores event types we do not handle yet', async () => {
const { body, header } = signedPayload({
id: 'evt_4',
type: 'invoice.payment_failed',
data: { object: { id: 'in_4' } },
})
const result = await handleStripeWebhook(body, header, SECRET)
expect(result).toEqual({ ok: true, ignored: 'invoice.payment_failed' })
expect(ledger.recordPayment).not.toHaveBeenCalled()
})
})Four it() blocks cover four production failure modes: paid event recorded, forged signature rejected, retry deduplicated, unknown type ignored. Notice every it() reads as a sentence. This is the handoff pattern we revisit later.
Test 2: Internal-tool CRUD (delete confirmation + soft-delete restore)
For SMB internal tools the most common bug is "I clicked delete by accident, can I get it back." Test both the confirmation and the restore.
// src/internal/contacts/contact-actions.ts
import type { Contact } from './types'
import { db } from '@/db'
export async function softDeleteContact(id: string, actorId: string) {
return db.contact.update({
where: { id },
data: { deletedAt: new Date(), deletedBy: actorId },
})
}
export async function restoreContact(id: string) {
return db.contact.update({
where: { id },
data: { deletedAt: null, deletedBy: null },
})
}
export async function listActiveContacts(): Promise<Contact[]> {
return db.contact.findMany({ where: { deletedAt: null } })
}// src/internal/contacts/contact-actions.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { softDeleteContact, restoreContact, listActiveContacts } from './contact-actions'
import { db } from '@/db'
vi.mock('@/db', () => ({
db: {
contact: {
update: vi.fn(),
findMany: vi.fn(),
},
},
}))
describe('contact lifecycle (internal CRUD)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-20T10:00:00Z'))
})
it('soft-deletes a contact and stamps actor + timestamp', async () => {
vi.mocked(db.contact.update).mockResolvedValue({ id: 'c1', deletedAt: new Date() } as never)
await softDeleteContact('c1', 'user-7')
expect(db.contact.update).toHaveBeenCalledWith({
where: { id: 'c1' },
data: { deletedAt: new Date('2026-05-20T10:00:00Z'), deletedBy: 'user-7' },
})
})
it('restores a soft-deleted contact by clearing the tombstone fields', async () => {
vi.mocked(db.contact.update).mockResolvedValue({ id: 'c1', deletedAt: null } as never)
await restoreContact('c1')
expect(db.contact.update).toHaveBeenCalledWith({
where: { id: 'c1' },
data: { deletedAt: null, deletedBy: null },
})
})
it('excludes soft-deleted rows from the active list', async () => {
vi.mocked(db.contact.findMany).mockResolvedValue([
{ id: 'a', deletedAt: null },
{ id: 'b', deletedAt: null },
] as never)
const rows = await listActiveContacts()
expect(rows).toHaveLength(2)
expect(db.contact.findMany).toHaveBeenCalledWith({ where: { deletedAt: null } })
})
})Test 3: Client-facing form validation (the lead form that prints money)
If your marketing site has one form that captures leads, test it like it is your most valuable code, because it is.
// src/marketing/lead-form.tsx
import { useState } from 'react'
export type LeadInput = { name: string; email: string; companySize: '1-9' | '10-49' | '50+' }
export function validateLead(input: Partial<LeadInput>): { ok: true; value: LeadInput } | { ok: false; errors: Record<string, string> } {
const errors: Record<string, string> = {}
if (!input.name || input.name.trim().length < 2) errors.name = 'Name is required'
if (!input.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) errors.email = 'Valid email required'
if (!input.companySize || !['1-9', '10-49', '50+'].includes(input.companySize)) errors.companySize = 'Select a company size'
if (Object.keys(errors).length > 0) return { ok: false, errors }
return { ok: true, value: input as LeadInput }
}
export function LeadForm({ onSubmit }: { onSubmit: (lead: LeadInput) => Promise<void> }) {
const [data, setData] = useState<Partial<LeadInput>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
async function handle(e: React.FormEvent) {
e.preventDefault()
const r = validateLead(data)
if (!r.ok) { setErrors(r.errors); return }
setSubmitting(true)
try { await onSubmit(r.value) } finally { setSubmitting(false) }
}
return (
<form onSubmit={handle} noValidate>
<label htmlFor="name">Name</label>
<input id="name" value={data.name ?? ''} onChange={(e) => setData({ ...data, name: e.target.value })} />
{errors.name && <p role="alert">{errors.name}</p>}
<label htmlFor="email">Email</label>
<input id="email" type="email" value={data.email ?? ''} onChange={(e) => setData({ ...data, email: e.target.value })} />
{errors.email && <p role="alert">{errors.email}</p>}
<label htmlFor="size">Company size</label>
<select id="size" value={data.companySize ?? ''} onChange={(e) => setData({ ...data, companySize: e.target.value as LeadInput['companySize'] })}>
<option value="">Select...</option>
<option value="1-9">1-9</option>
<option value="10-49">10-49</option>
<option value="50+">50+</option>
</select>
{errors.companySize && <p role="alert">{errors.companySize}</p>}
<button type="submit" disabled={submitting}>{submitting ? 'Sending...' : 'Get the demo'}</button>
</form>
)
}// src/marketing/lead-form.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LeadForm, validateLead } from './lead-form'
describe('validateLead (pure)', () => {
it.each([
[{}, ['name', 'email', 'companySize']],
[{ name: 'A' }, ['name', 'email', 'companySize']],
[{ name: 'Ada Lovelace', email: 'not-an-email' }, ['email', 'companySize']],
[{ name: 'Ada', email: '[email protected]', companySize: '1-9' as const }, []],
])('returns expected error keys for %j', (input, expectedErrorKeys) => {
const r = validateLead(input)
if (expectedErrorKeys.length === 0) {
expect(r.ok).toBe(true)
} else {
expect(r.ok).toBe(false)
if (!r.ok) expect(Object.keys(r.errors).sort()).toEqual(expectedErrorKeys.sort())
}
})
})
describe('<LeadForm />', () => {
it('blocks submit when fields are empty and shows three errors', async () => {
const onSubmit = vi.fn()
render(<LeadForm onSubmit={onSubmit} />)
await userEvent.click(screen.getByRole('button', { name: /get the demo/i }))
expect(await screen.findAllByRole('alert')).toHaveLength(3)
expect(onSubmit).not.toHaveBeenCalled()
})
it('submits a clean lead and disables the button while sending', async () => {
const onSubmit = vi.fn().mockImplementation(() => new Promise((r) => setTimeout(r, 50)))
render(<LeadForm onSubmit={onSubmit} />)
await userEvent.type(screen.getByLabelText(/name/i), 'Ada Lovelace')
await userEvent.type(screen.getByLabelText(/email/i), '[email protected]')
await userEvent.selectOptions(screen.getByLabelText(/company size/i), '10-49')
await userEvent.click(screen.getByRole('button', { name: /get the demo/i }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Ada Lovelace', email: '[email protected]', companySize: '10-49' })
expect(screen.getByRole('button')).toBeDisabled()
})
})Notice it.each for the validator. One table, four cases, zero copy-paste. This is the pattern SMB teams underuse most.
Test 4: Auth flow (the one test that prevents the security incident)
For SMBs the most damaging single test gap is auth. Test the gate, not the framework.
// src/auth/require-role.ts
export type Session = { userId: string; role: 'admin' | 'staff' | 'viewer' } | null
export class ForbiddenError extends Error { constructor() { super('forbidden') } }
export class UnauthorisedError extends Error { constructor() { super('unauthorised') } }
const HIERARCHY: Record<NonNullable<Session>['role'], number> = { viewer: 1, staff: 2, admin: 3 }
export function requireRole(session: Session, minRole: NonNullable<Session>['role']) {
if (!session) throw new UnauthorisedError()
if (HIERARCHY[session.role] < HIERARCHY[minRole]) throw new ForbiddenError()
return session
}// src/auth/require-role.test.ts
import { describe, it, expect } from 'vitest'
import { requireRole, ForbiddenError, UnauthorisedError } from './require-role'
describe('requireRole', () => {
it('throws UnauthorisedError when there is no session', () => {
expect(() => requireRole(null, 'viewer')).toThrow(UnauthorisedError)
})
it.each([
[{ userId: 'u', role: 'viewer' as const }, 'staff', ForbiddenError],
[{ userId: 'u', role: 'staff' as const }, 'admin', ForbiddenError],
])('rejects %j when min role is %s', (session, min, err) => {
expect(() => requireRole(session, min as any)).toThrow(err)
})
it.each([
[{ userId: 'u', role: 'admin' as const }, 'admin'],
[{ userId: 'u', role: 'admin' as const }, 'viewer'],
[{ userId: 'u', role: 'staff' as const }, 'viewer'],
])('allows %j when min role is %s', (session, min) => {
expect(requireRole(session, min as any)).toEqual(session)
})
})Four tests, four files, four meaningful failure modes covered. Your team is now ahead of 90% of SMB codebases. Next: get this running on CI.
Minimum-viable CI: a GitHub Actions workflow tuned for the free tier
The GitHub Actions free tier on private repos is 2,000 minutes/month per organisation. A 3-dev SMB pushing 18 PRs/week, each running tests on push + on PR, is about 1,440 runs/year if every push fires a workflow. The math only works if each run is short and pnpm + Vitest caches actually hit.
Here is the workflow. Drop it at .github/workflows/test.yml:
name: test
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: test-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
vitest:
runs-on: ubuntu-latest
timeout-minutes: 8
permissions:
contents: read
pull-requests: writesteps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install
run: pnpm install --frozen-lockfile
- name: Cache Vitest
uses: actions/cache@v4
with:
path: node_modules/.vitest
key: vitest-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('vitest.config.ts') }}
restore-keys: |
vitest-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
vitest-${{ runner.os }}-
- name: Run tests (with coverage)
run: pnpm test:ci
env:
CI: true
- name: Upload coverage summary
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-${{ github.run_id }}
path: coverage/
retention-days: 7
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
uses: davelosert/vitest-coverage-report-action@v2
with:
json-summary-path: ./coverage/coverage-summary.json
Six properties that matter at SMB scale:
1. **`concurrency.cancel-in-progress: true`** kills the previous run on the same branch when a new push lands. Your "I noticed a typo and pushed three more commits" stops billing four runs.
2. **`timeout-minutes: 8`** is a hard cap. If something goes wrong (a hung browser, an infinite loop) you do not burn an hour.
3. **pnpm cache via `setup-node` + Vitest cache via `actions/cache`** drop install time from ~80s to ~12s and test time from ~70s to ~35s on a steady-state codebase.
4. **`--reporter=github-actions`** in `test:ci` (from the package.json above) puts failures as inline annotations on the PR file diff. No clicking through logs.
5. **`coverage-summary.json` posted as a PR comment** gives the team a delta on coverage per PR. This is the visible feedback loop that makes coverage thresholds actually move over time.
6. **`retention-days: 7`** on the artifact keeps storage usage low. The free tier on storage is small; default 90 days will blow it up.
### Free-tier math, worked
- 3 devs × 6 PRs/week × 2 runs/PR (push + open) = 36 runs/week
- × ~2 min/run after caches warm = 72 min/week
- × 4.3 weeks/month = ~310 min/month
- Leaves ~1,690 min of the 2,000-min free tier for deploys, scheduled jobs, and the occasional cache-miss run
If you crack 1,000 min/month, look at: a) running tests only on touched packages via `pnpm vitest --changed`, b) splitting browser-mode into a manually-triggered workflow, c) skipping CI on `docs/**` paths only via `paths-ignore`.
---
### Test ROI math: when 3 devs × 6 weeks pays for itself
Most SMB tech leads do not have a model for whether testing pays. Here is one. Plug in your numbers; the structure is what matters.
### Inputs
- **Devs:** 3
- **Avg fully-loaded hourly cost per dev:** $90
- **Hours per dev per week to write/maintain tests (steady state):** 2
- **Bugs caught per week by tests (steady state, weeks 6+):** 1.5
- **Avg hours per incident (detect + diagnose + fix + customer comms):** 5
- **Stripe / auth incidents avoided per quarter:** 1
- **Avg revenue impact per Stripe/auth incident:** $4,000
### Costs (weekly, weeks 1-5 ramp; weeks 6+ steady state)
- Weeks 1-5: 3 devs × 5 hours/week × $90 = **$1,350/week** (writing the initial 40-test corpus)
- Weeks 6+: 3 devs × 2 hours/week × $90 = **$540/week** (maintenance)
### Benefits (weekly, weeks 6+)
- Bugs caught: 1.5 × 5 hours × $90 = **$675/week** in saved engineering hours
- Stripe/auth incident avoided: 1 per quarter × $4,000 / 13 = **$308/week** in revenue protection
### Break-even
- 5-week ramp cost: 5 × $1,350 = **$6,750**
- Steady-state net benefit: $675 + $308 - $540 = **$443/week**
- Payback: $6,750 / $443 = **15.2 weeks from start of writing tests** (~10 weeks from steady state).
Three-month payback in real money, ignoring the much larger long-tail benefit (faster onboarding, lower bus factor, fewer Sunday-night rollbacks, the ability to ship on a Friday without fear). The first 40 tests are the only ones that matter for the math; you do not need 90% coverage to break even.
### Where the math goes wrong
- You write tests that test the framework, not your business logic. Symptom: a refactor breaks every test. Fix: test behaviour, not implementation. Re-read the four scenarios above.
- You skip CI because "the team will just run tests locally." Two months in, three engineers are running three different test commands and main is broken. Fix: enforce `test:ci` in a required status check on the protected branch.
- You set the coverage threshold so low that the file you care about is allowed to drop. Fix: per-path thresholds (above) make `src/payments` and `src/auth` non-negotiable.
---
## Handoff-friendly test patterns (and the README template)
The single biggest hidden cost in SMB engineering is what happens when your most senior person leaves. You inherit a codebase you do not understand. Tests are the cheapest form of institutional memory you can buy, if you write them right.
### Rule 1: Every `it()` reads as a sentence
Bad:
```ts
it('test 1', () => { ... })
it('works', () => { ... })Good:
it('records a paid checkout once', () => { ... })
it('is idempotent on Stripe retry (same event id)', () => { ... })The next engineer can grep "is idempotent" and find the test that documents the policy. The test name is the spec.
Rule 2: describe blocks group by domain noun, not by file
Bad: describe('stripe-webhook.ts', ...). The next engineer reads the file name themselves.
Good: describe('handleStripeWebhook (revenue path)', ...). Now vitest --testNamePattern="revenue path" runs every revenue-critical test in the repo, across every file.
Rule 3: Co-locate tests next to source
src/payments/stripe-webhook.ts and src/payments/stripe-webhook.test.ts together. Not a parallel tests/ tree. When the next engineer opens the payments folder they see the source plus the spec plus the failure history (via git blame on the test file). Co-location is the most underrated handoff pattern in JS testing.
Rule 4: Keep one TESTING.md per repo
Drop this file at the repo root:
# Testing playbook
## What's tested
- **Revenue paths (`src/payments`, `src/auth`):** 85-90% coverage, behaviour tests.
- **Internal CRUD (`src/internal`):** 60% coverage, happy-path + soft-delete + restore.
- **Marketing forms (`src/marketing/lead-form`):** 100% on the validator, ~70% on the component.
## What's intentionally NOT tested
- **Marketing CMS rendering** (`src/marketing/blog`): content-driven, low blast radius.
- **Storybook stories**: design artefacts, not behaviour.
- **Third-party SDK internals**: we mock Stripe/Postmark at the SDK boundary.
## What to test next (priority order)
1. Refund handler (`src/payments/refunds.ts`): currently untested, high blast radius.
2. Magic-link expiry edge case (`src/auth/magic-link.ts`): untested, security-adjacent.
3. CSV import error reporting (`src/internal/import/csv.ts`): flaky in production.
## How to run
- Local watch: `pnpm test`
- Local UI: `pnpm test:ui`
- Coverage: `pnpm test:cov`
- CI mode (what Actions runs): `pnpm test:ci`
## How to add a test
1. Co-locate next to the file under test.
2. `describe` by domain noun.
3. `it` reads as a sentence.
4. Mock at the SDK boundary, not the helper boundary.
5. Use `it.each` for parameterised cases.
## Who to ask
- Payments tests: @lead-eng
- Auth tests: @lead-eng
- Marketing/forms: @frontend-engThat README is the single document the next engineer reads on day one. Update it every quarter. It is worth more than all your Confluence pages combined.
Rule 5: Capture intent in the test, not in the PR description
A PR description disappears. A comment in stripe-webhook.test.ts saying // Stripe retries on 2xx-delay >5s; we must dedupe by event.id stays forever and surfaces every time someone touches that file.
Vitest browser-mode for client-side regressions without a full e2e suite
SMBs cannot afford a Playwright e2e harness with its own CI, fixtures, and flakiness budget. Vitest browser-mode is the cheaper substitute that covers ~70% of e2e value at ~10% of the cost.
Setup
pnpm add -D @vitest/browser playwright// vitest.browser.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
name: 'portal-browser',
include: ['src/portal/**/*.browser.test.{ts,tsx}'],
browser: {
enabled: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }],
headless: true,
},
},
})Example
// src/portal/dashboard.browser.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from 'vitest-browser-react'
import { Dashboard } from './dashboard'
describe('Dashboard (real browser)', () => {
it('renders the KPI tiles and keeps the layout above the fold at 1280×720', async () => {
const { container, getByText } = render(<Dashboard />)
await expect.element(getByText(/monthly revenue/i)).toBeVisible()
const tiles = container.querySelectorAll('[data-testid="kpi-tile"]')
expect(tiles).toHaveLength(4)
const rect = container.getBoundingClientRect()
expect(rect.height).toBeLessThanOrEqual(720)
})
it('opens the date-range popover and closes on outside click', async () => {
const { getByRole, queryByRole } = render(<Dashboard />)
await getByRole('button', { name: /date range/i }).click()
await expect.element(getByRole('dialog')).toBeVisible()
await getByRole('main').click()
expect(queryByRole('dialog')).toBeNull()
})
})CI strategy for browser-mode
Browser-mode is slower (~2-4x a jsdom run) so do not run it on every push. Two patterns work:
# .github/workflows/browser.yml
on:
pull_request:
paths:
- 'src/portal/**'
- 'vitest.browser.config.ts'
- 'package.json'
workflow_dispatch:
jobs:
browser:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm vitest run --config vitest.browser.config.tsPath filter means the browser job only fires when the portal code (or its config) changes. Most PRs skip it entirely; the ones that matter run it. Free-tier minute usage stays sane.
Migrating from Jest, from no tests, or from a partially-tested codebase
Three migration paths, three honest playbooks. Pick the one that matches your starting state.
Path A: Migrating from Jest
Vitest is intentionally Jest-API-compatible at the surface. Most teams complete the move in a weekend. Steps:
pnpm add -D vitest @vitest/coverage-v8and removejest,babel-jest,ts-jest,@types/jest.- Rename
jest.config.*→vitest.config.ts. ConverttestEnvironment→test.environment,setupFilesAfterEach→setupFiles,transform→ delete (Vite handles it). - Find/replace at the import level:
from '@jest/globals'→from 'vitest'. jest.fn→vi.fn,jest.mock→vi.mock,jest.useFakeTimers→vi.useFakeTimers. The codemodnpx @vitest/migratorautomates most of this.- Drop Jest globals from the tsconfig
typesarray:["@types/jest"]→["vitest/globals"](or setglobals: truein the Vitest config). - Run
vitest run --coverageand fix the long tail. Most teams report ~95% of tests passing as-is, with the remaining ~5% being timer-mock differences and a fewjest.requireActualusages that needvi.importActual.
Path B: Migrating from no tests
Do not try to retrofit 80% coverage in month one. The order that works:
- Week 1: Setup (config, setup.ts, scripts, one passing test).
- Week 2: Stripe webhook test, auth gate test. Two files, four tests, the revenue paths covered.
- Week 3: CI. The Actions workflow above. Make tests required on the protected branch.
- Week 4: One internal CRUD path and one form. Now you have the four-scenario playbook covered.
- Week 5: Coverage thresholds on the revenue paths only (
src/payments,src/auth). Do not set a global threshold yet. - Week 6+: Add one test per PR going forward. Promote the global threshold once it passes naturally.
This sequence avoids the most common SMB failure: spending three weeks getting 60% global coverage on the easy parts and never testing the parts that matter.
Path C: Migrating from a partially-tested codebase
The trap here is the existing tests are mostly junk (snapshot tests of trivial components, framework-internal tests that break on every refactor). Audit before you extend.
- Run
vitest run --coverageand look at the files with low coverage that touch revenue. That is your real backlog. - For each file in the backlog, ask: "if this file regresses, what does it cost?" Order by cost.
- Add tests in cost order. Stop when you hit a file where the answer is "nothing meaningful."
- Delete tests that have never failed but break on every refactor. Snapshot tests of styled-components are usually the worst offender.
- Set per-path thresholds for the files in your backlog. Leave the rest unconstrained.
A 6-month-old codebase with 20% bad coverage gets to "the right 40% of coverage" in two months by deleting and rewriting rather than padding.
FAQs
How is Vitest different from Jest for a small business?
Vitest uses your existing Vite config so you do not maintain a parallel build pipeline; it runs faster in watch mode, which compounds at 3+ developers; it ships project-mode for monorepos without third-party plugins; and its browser-mode replaces most of what you would otherwise pay Playwright for. Jest is still fine if you are on a non-Vite stack. On Vite, Next, Astro, Nuxt, or Remix, Vitest is the right default in 2026.
Is Vitest free for commercial use?
Yes. Vitest is MIT-licensed and free for any use including production at any company size. There is no enterprise tier or commercial license. The only paid services in the ecosystem are optional CI/coverage SaaS (Codecov, etc.) and you do not need them.
How many tests should a 3-dev small business have?
For the first six months: 40 to 80 tests covering the four scenario types in this page (payments, auth, internal CRUD, public forms). Coverage will land between 35% and 55% globally with 85%+ on the revenue paths. That is the right shape. Chasing 80% global coverage on month one is the most common SMB testing mistake.
Will Vitest in CI blow up my GitHub Actions free-tier budget?
Not if you follow the workflow in this page. A 3-dev team running tests on every push and PR with cache hits uses ~310 minutes/month out of the 2,000-minute free tier. The killer optimisations are concurrency.cancel-in-progress, the pnpm cache, the Vitest cache, and keeping browser-mode on a path-filtered workflow.
Can Vitest replace Playwright for end-to-end tests?
For ~70% of what most SMBs use e2e for, yes. Vitest browser-mode runs components in a real browser via Playwright as the driver and covers regressions in rendering, interactions, and viewport layout. What it does not do is multi-page user-flow tests (login on page A, navigate to checkout on page B, complete payment on page C). For those you still want Playwright Test as a separate harness, but most SMBs do not need that until they are 20+ engineers.
How do I write Vitest tests for a Node API with no frontend?
Set environment: 'node' in the config (or use project-mode with a node project for the API). Mock at the SDK boundary (Stripe, Postmark, your database client). The four-scenario playbook still applies: webhook handling, auth gates, CRUD with soft-delete semantics, and input validation. The only change is you do not need jsdom, ResizeObserver polyfills, or @testing-library/react.
What should I tell a new hire on day one about our tests?
Point them at TESTING.md in the repo root (the template above). Have them read it, then run pnpm test:ui and open three test files: one revenue-path test, one CRUD test, one form test. Day-one outcome: they know what is tested, what is intentionally not tested, what to test next, and how to add a new test. That conversation takes 45 minutes and replaces the first three weeks of "how do you test things here" Slack threads.
Closing notes and the sibling page
If you have read this far, you have the full SMB playbook: a production vitest.config.ts, four real test scenarios, a GitHub Actions workflow tuned for the free tier, an ROI model, a handoff README, and a browser-mode strategy. Ship the four tests this week. Wire the CI next week. Add per-path thresholds in week three. Six weeks in you will be at break-even and the next engineer who joins your team will onboard in one afternoon instead of two weeks.
If you are a solo founder reading this, the sibling page Vitest for Entrepreneurs is shorter, has no CI section, and assumes you are shipping alone. If you have 2-10 engineers, this page is the right one and you should also read GitHub Actions for Small Business for the deeper CI playbook and Stripe Webhook Testing for the full payments coverage strategy.
Authoritative references: official Vitest project-mode documentation and the GitHub Actions billing & usage limits page for the free-tier minute math.
Back to the Launch School use-case index.