# 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.

Backend with Node.js roadmap · Production · 18 min read · https://levelcode.org/roadmaps/node-backend/testing-node

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

```bash
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`:

```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)
}
```

```ts
// 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)
  })
})
```

```bash
npm test
```

```text
 ✓ 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`:

```ts
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:

```ts
// 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:

```ts
// 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()
  })
}
```

```ts
// 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: 'ana@example.com', items: [{ sku: 'PEN-01', quantity: 3 }] })
    const order = await findOrder(id)
    expect(order).toMatchObject({ customerEmail: 'ana@example.com', 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: 'ben@example.com', 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:

```ts
// 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:

```ts
// 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('ana@example.com', 'Your order is confirmed')
    return c.body(null, 204)
  })
  return app
}
```

```ts
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(['ana@example.com: Your order is confirmed'])
})
```

Vitest also has **mock functions**, which record their calls:

```ts
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('ana@example.com', '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**:

```ts
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()
```

> [!TIP]
> Aim for a pyramid: many fast unit tests for pure logic, a solid layer of route and repository tests against a real database, and a handful of end-to-end checks of the deployed system. Most bugs in a typical API live where your code meets the database and HTTP, so don't skimp on the middle layer.

## Coverage and CI

```bash
npm install --save-dev @vitest/coverage-v8
npx vitest run --coverage
```

The 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:

```yaml
# .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 test
```

The 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:

1. Unit tests for `orderTotal`, including a table of discount cases and the error case.
2. Route tests for the bookmarks API: create, validation failure with details, 404 for an unknown ID, 409 for a duplicate URL.
3. Repository integration tests against a `*_test` database: an order is stored with its items, and a failing item insert leaves no order behind.
4. 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).

**Solution**

The pricing, route and repository tests are the ones shown in the lesson. Two additions complete the list:

```ts
// 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')
})
```

```ts
// 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)
})
```

```text
 ✓ 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`/`expect` API; use watch mode while coding.
- Use `it.each` tables for many cases, and route tests with `app.request` to 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.fn` to 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.

## Quiz

1. Why test repository code against a real PostgreSQL instead of mocking pool.query?

   A. Mocks are slower
   B. Vitest can't mock pg
   C. Constraints, transactions and type conversions only behave correctly in the real database; mocks would just repeat your assumptions
   D. Real databases need no setup

   **Answer: C.** A mocked query returns whatever you told it to, so it can't catch a wrong column name, a missing constraint or a broken transaction. The real database can.

2. Why does the test database helper refuse to run unless DATABASE_URL contains "test"?

   A. Tests truncate tables, so accidentally pointing them at a real database would destroy data
   B. PostgreSQL requires it
   C. It makes tests faster
   D. Vitest only loads test databases

   **Answer: A.** A misconfigured environment variable shouldn't be able to wipe production or development data. A cheap guard prevents an expensive mistake.

3. Your database tests pass alone but fail randomly when run together. What's a likely cause?

   A. Vitest doesn't support async tests
   B. TypeScript type errors
   C. The tests are too slow
   D. Test files run in parallel against the same database and truncate each other's data

   **Answer: D.** Files run in parallel by default. Tests sharing one database need fileParallelism: false, or a separate database or schema per file.

4. How do you test that a session expires after 30 days without waiting 30 days?

   A. Set a 1-second TTL in production
   B. Use vi.useFakeTimers() and vi.setSystemTime() to move the clock
   C. Skip the test
   D. Edit the database row by hand in the test

   **Answer: B.** Fake timers replace Date and the timer functions, so a test can jump to any moment instantly and deterministically.
