Prove your code works and keep it working. Write tests with the built-in node:test runner and assert module, then with Vitest, and learn where Jest, Cypress, and Playwright fit.
Why: modern Node includes a test runner (node:test) and an assertion library (node:assert) — no install needed. Group checks with test(), and verify values with assert.
// math.test.js
import { test } from 'node:test'
import assert from 'node:assert'
import { add } from './math.js'
test('add sums two numbers', () => {
assert.strictEqual(add(2, 3), 5)
})Why: Vitest is a fast, modern test framework with a friendly API (describe/it/expect), watch mode, and great error messages. It is a common choice for new projects. After installing, add scripts to package.json: "test": "vitest run" runs the suite once and exits (great for CI), while "test:watch": "vitest" re-runs tests as you edit.
$ pnpm add -D vitest// package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}// math.test.js
import { describe, it, expect } from 'vitest'
import { add } from './math.js'
describe('add', () => {
it('sums two numbers', () => {
expect(add(2, 3)).toBe(5)
})
})Why: with the scripts in place, run the whole suite once (the first command — it exits when done, great for CI), or keep it in watch mode (the second command — re-runs on save) while you code.
$ pnpm test$ pnpm test:watchWhy: a unit test checks one function in isolation; an integration test checks that the pieces work together — most often your code against a real database. The trick is a real but disposable database: here an in-memory SQLite (better-sqlite3) created fresh before each test, so tests never bleed into each other. No mocks — you run the actual query and assert on what really landed in the table.
$ pnpm add -D better-sqlite3// users.js — the code under test talks to a real DB
export function createUser(db, name) {
const info = db.prepare('INSERT INTO users (name) VALUES (?)').run(name)
return { id: info.lastInsertRowid, name }
}
export function listUsers(db) {
return db.prepare('SELECT * FROM users ORDER BY id').all()
}// users.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import Database from 'better-sqlite3'
import { createUser, listUsers } from './users.js'
let db
beforeEach(() => {
db = new Database(':memory:') // a fresh, real DB for every test
db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)')
})
describe('users store', () => {
it('persists a user and reads it back', () => {
createUser(db, 'Ada')
expect(listUsers(db)).toEqual([{ id: 1, name: 'Ada' }]) // real query
})
})Why: a functional (end-to-end) test exercises your app the way a client does — a real HTTP request in, the real response out, through routing, validation, and handlers. Supertest sends requests straight to your app object, so you do not start a server or pick a port. The key is to export your app without calling listen() — Supertest drives it directly.
$ pnpm add -D supertest// app.js — an Express app, exported but NOT listening
import express from 'express'
export const app = express()
app.use(express.json())
app.get('/users', (req, res) => res.json({ data: [] }))
app.post('/users', (req, res) => {
if (!req.body.name) return res.status(400).json({ error: 'name required' })
res.status(201).json({ data: { id: 1, name: req.body.name } })
})// app.test.js
import { describe, it, expect } from 'vitest'
import request from 'supertest'
import { app } from './app.js'
describe('users API', () => {
it('GET /users returns 200 and a list', async () => {
const res = await request(app).get('/users')
expect(res.status).toBe(200)
expect(res.body).toEqual({ data: [] })
})
it('POST /users rejects a missing name with 400', async () => {
const res = await request(app).post('/users').send({})
expect(res.status).toBe(400)
})
})