Step 10 of 12
Testing with Vitest
Test a Node API with Vitest: unit tests, table-driven cases, route tests with app.request, integration tests against a real PostgreSQL, fakes, mocks, fake timers and CI.
A backend without tests is one where every change is a gamble and every deploy needs someone clicking through the app. With a good suite, you refactor freely and ship several times a day. This lesson tests a Hono and PostgreSQL API with Vitest: fast unit tests for pure logic, route tests that send requests to the app in-process, integration tests against a real database, and the tools for replacing what you can't control, such as the clock and external services. It ends with running it all in CI.
Setting up Vitest
npm install --save-dev vitest
npm pkg set scripts.test="vitest run" scripts.test:watch="vitest"Vitest runs TypeScript directly, uses the same configuration conventions as Vite, and has a Jest-compatible API. Test files are named *.test.ts:
// src/orders/pricing.ts
export function orderTotal(items: { unitCents: number; quantity: number }[], discountPercent = 0): number {
if (discountPercent < 0 || discountPercent > 100) throw new RangeError('discount must be between 0 and 100')
const subtotal = items.reduce((sum, i) => sum + i.unitCents * i.quantity, 0)
return Math.round((subtotal * (100 - discountPercent)) / 100)
}// src/orders/pricing.test.ts
import { describe, expect, it } from 'vitest'
import { orderTotal } from './pricing.ts'
describe('orderTotal', () => {
it('sums line items', () => {
expect(orderTotal([{ unitCents: 120, quantity: 3 }, { unitCents: 3500, quantity: 1 }])).toBe(3860)
})
it('is zero for an empty order', () => {
expect(orderTotal([])).toBe(0)
})
it('rejects discounts outside 0-100', () => {
expect(() => orderTotal([], 150)).toThrow(RangeError)
})
})npm test ✓ src/orders/pricing.test.ts (3 tests) 2ms
Test Files 1 passed (1)
Tests 3 passed (3)npm run test:watch reruns affected tests on every save, which makes tests part of how you write code rather than a chore afterwards.
The matchers you'll use most: toBe (same value or same object), toEqual (deep equality), toMatchObject (contains these fields, ignores others), toThrow, toHaveLength, toContain, and resolves/rejects for promises (await expect(promise).rejects.toThrow('not found')).
Table-driven tests
When a function has many interesting inputs, list them as a table with it.each:
it.each([
{ items: [{ unitCents: 1000, quantity: 1 }], discount: 0, expected: 1000 },
{ items: [{ unitCents: 1000, quantity: 1 }], discount: 15, expected: 850 },
{ items: [{ unitCents: 999, quantity: 1 }], discount: 50, expected: 500 }, // 499.5 rounds up
{ items: [{ unitCents: 1000, quantity: 3 }], discount: 100, expected: 0 },
])('total $expected for discount $discount%', ({ items, discount, expected }) => {
expect(orderTotal(items, discount)).toBe(expected)
})Each row is reported as its own test, named from the template, so a failure tells you exactly which case broke.
Route tests with app.request
Because a Hono app is a function from Request to Response, route tests need no server and no port. They exercise routing, middleware, validation, error handling and serialisation together:
// src/bookmarks/routes.test.ts
import { describe, expect, it } from 'vitest'
import { app } from '../app.ts'
const post = (path: string, body: unknown) =>
app.request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
describe('POST /bookmarks', () => {
it('creates a bookmark', async () => {
const res = await post('/bookmarks', { url: 'https://vitest.dev', title: 'Vitest' })
expect(res.status).toBe(201)
expect(await res.json()).toMatchObject({ url: 'https://vitest.dev', title: 'Vitest', tags: [] })
})
it('rejects invalid input with field details', async () => {
const res = await post('/bookmarks', { url: 'not a url', title: '' })
expect(res.status).toBe(422)
const body = await res.json()
expect(body.error.code).toBe('validation_failed')
expect(body.error.details.map((d: { path: string }) => d.path)).toEqual(['url', 'title'])
})
})These are the tests that give you the most confidence per line: they check the API as clients see it, and they survive internal refactoring because they only depend on HTTP in and HTTP out.
Integration tests against a real database
Mocking the database tests your mocks, not your SQL. Constraints, transactions, ON CONFLICT and type conversions only behave correctly against real PostgreSQL. Use a dedicated test database, migrated before the tests run and cleaned between tests:
// test/db.ts
import { afterAll, beforeAll, beforeEach } from 'vitest'
import { pool } from '../src/db.ts'
import { migrate } from '../src/migrate.ts'
if (!process.env.DATABASE_URL?.includes('test')) {
throw new Error('Refusing to run tests against a database whose name does not contain "test"')
}
export function useTestDatabase() {
beforeAll(async () => {
await migrate(pool)
})
beforeEach(async () => {
await pool.query('TRUNCATE orders, order_items RESTART IDENTITY CASCADE')
})
afterAll(async () => {
await pool.end()
})
}// src/orders/repository.test.ts
import { describe, expect, it } from 'vitest'
import { useTestDatabase } from '../../test/db.ts'
import { pool } from '../db.ts'
import { createOrder, findOrder } from './repository.ts'
useTestDatabase()
describe('createOrder', () => {
it('stores the order and its items', async () => {
const id = await createOrder({ customerEmail: '[email protected]', items: [{ sku: 'PEN-01', quantity: 3 }] })
const order = await findOrder(id)
expect(order).toMatchObject({ customerEmail: '[email protected]', totalCents: 360, status: 'pending' })
expect(order?.items).toEqual([{ sku: 'PEN-01', quantity: 3, unitCents: 120 }])
})
it('writes nothing when an item insert fails', async () => {
await expect(
createOrder({ customerEmail: '[email protected]', items: [{ sku: 'PEN-01', quantity: 1 }, { sku: 'PEN-01', quantity: 2 }] }),
).rejects.toThrow(/duplicate key/)
const { rows } = await pool.query('SELECT count(*)::int AS n FROM orders')
expect(rows[0].n).toBe(0)
})
})The guard in test/db.ts matters: a test suite that truncates tables must never be pointed at a real database by an unlucky environment variable.
Test files run in parallel by default. Tests that share one database and truncate tables between tests need to run one file at a time; set that in the config:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
fileParallelism: false, // database tests share one schema
env: { DATABASE_URL: 'postgres://orders:orders@localhost:5432/orders_test' },
},
})Larger suites give each test file its own database or schema so they can run in parallel again, but start simple.
Fakes, mocks and the clock
Code that sends email, charges cards or calls a slow API shouldn't do so in tests. The cleanest approach is to make such collaborators parameters: build the app from its dependencies, and pass fakes in tests:
// src/app.ts
export type Deps = { mailer: { send(to: string, subject: string): Promise<void> } }
export function createApp(deps: Deps) {
const app = new Hono()
app.post('/orders/:id/confirm', async (c) => {
// ...mark the order confirmed...
await deps.mailer.send('[email protected]', 'Your order is confirmed')
return c.body(null, 204)
})
return app
}it('sends a confirmation email', async () => {
const sent: string[] = []
const app = createApp({ mailer: { send: async (to, subject) => void sent.push(`${to}: ${subject}`) } })
await app.request('/orders/1/confirm', { method: 'POST' })
expect(sent).toEqual(['[email protected]: Your order is confirmed'])
})Vitest also has mock functions, which record their calls:
import { vi } from 'vitest'
const send = vi.fn(async () => {})
const app = createApp({ mailer: { send } })
await app.request('/orders/1/confirm', { method: 'POST' })
expect(send).toHaveBeenCalledWith('[email protected]', 'Your order is confirmed')vi.mock('../mailer.ts') can replace a whole module that wasn't designed for injection. It works, but couples tests to file paths and import structure, so prefer passing dependencies in.
For time-dependent code (session expiry, retries with backoff, "orders older than 24 hours"), control the clock with fake timers:
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-11-01T10:00:00Z'))
// ...create a session that expires in 30 days...
vi.advanceTimersByTime(31 * 24 * 60 * 60 * 1000) // 31 days pass instantly; timers fire
// ...assert the session is expired...
vi.useRealTimers()Coverage and CI
npm install --save-dev @vitest/coverage-v8
npx vitest run --coverageThe report lists untested lines per file; look there for error paths you never exercised. As with any coverage tool, use it to find gaps, not as a target.
In CI, start PostgreSQL as a service container and run migrations through the tests themselves:
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: orders
POSTGRES_PASSWORD: orders
POSTGRES_DB: orders_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U orders" --health-interval 5s --health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm testThe health check makes the job wait until PostgreSQL accepts connections before tests start.
Practice
Write a test suite for the API you've built in this roadmap:
- Unit tests for
orderTotal, including a table of discount cases and the error case. - Route tests for the bookmarks API: create, validation failure with details, 404 for an unknown ID, 409 for a duplicate URL.
- Repository integration tests against a
*_testdatabase: an order is stored with its items, and a failing item insert leaves no order behind. - A test that uses fake timers to show a session created now is valid in 29 days and invalid in 31 (write a small pure function
isExpired(expiresAt, now = new Date())if your session code doesn't expose one).
Show solution
The pricing, route and repository tests are the ones shown in the lesson. Two additions complete the list:
// src/bookmarks/errors.test.ts
import { expect, it } from 'vitest'
import { app } from '../app.ts'
const post = (body: unknown) =>
app.request('/bookmarks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
it('returns 404 for an unknown bookmark', async () => {
const res = await app.request('/bookmarks/999999')
expect(res.status).toBe(404)
expect((await res.json()).error.code).toBe('not_found')
})
it('returns 409 for a duplicate URL', async () => {
expect((await post({ url: 'https://dup.example', title: 'One' })).status).toBe(201)
const res = await post({ url: 'https://dup.example', title: 'Two' })
expect(res.status).toBe(409)
expect((await res.json()).error.code).toBe('duplicate_url')
})// src/auth/expiry.test.ts
import { afterEach, expect, it, vi } from 'vitest'
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
const newExpiry = () => new Date(Date.now() + SESSION_TTL_MS)
const isExpired = (expiresAt: Date, now = new Date()) => now >= expiresAt
afterEach(() => {
vi.useRealTimers()
})
it('keeps a session valid for 30 days', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-11-01T10:00:00Z'))
const expiresAt = newExpiry()
vi.setSystemTime(new Date('2026-11-30T10:00:00Z')) // 29 days later
expect(isExpired(expiresAt)).toBe(false)
vi.setSystemTime(new Date('2026-12-02T10:00:00Z')) // 31 days later
expect(isExpired(expiresAt)).toBe(true)
}) ✓ src/orders/pricing.test.ts (7 tests) 3ms
✓ src/bookmarks/routes.test.ts (2 tests) 12ms
✓ src/bookmarks/errors.test.ts (2 tests) 6ms
✓ src/auth/expiry.test.ts (1 test) 2ms
✓ src/orders/repository.test.ts (2 tests) 61ms
Test Files 5 passed (5)
Tests 14 passed (14)The bookmarks store is in memory and shared by every test in the process, which is why the duplicate test uses its own URL: tests must not depend on each other's data. With a database-backed store, the beforeEach truncate in useTestDatabase gives each test a clean slate instead. afterEach(vi.useRealTimers) restores the clock even if an assertion fails, so one test's fake time can't leak into the next.
Key takeaways
- Vitest runs TypeScript tests directly with a familiar
describe/it/expectAPI; use watch mode while coding. - Use
it.eachtables for many cases, and route tests withapp.requestto check the API as clients see it. - Test SQL against a real, dedicated test database: migrate once, truncate between tests, guard against pointing at the wrong database.
- Pass collaborators in so tests can use fakes; use
vi.fnto record calls and fake timers to control time. - Run type checks and tests in CI with PostgreSQL as a service container, and use coverage to find untested paths.
Check yourself
Quiz
0/4 correct · 4 left
1.Why test repository code against a real PostgreSQL instead of mocking pool.query?
2.Why does the test database helper refuse to run unless DATABASE_URL contains "test"?
3.Your database tests pass alone but fail randomly when run together. What's a likely cause?
4.How do you test that a session expires after 30 days without waiting 30 days?
Finished reading?
Mark it complete to fill in your roadmap.