How I Test My Node.js Apps: A Practical Guide (2026)
Testing doesn't have to be complicated. Here's the minimal setup that catches real bugs.
The Truth About Testing
Most developers know they should test.
Few actually do it consistently.
Why?
→ "I don't have time"
→ "My code is too simple to test"
→ "Setting up tests is annoying"
→ "Tests break when I refactor"
Here's my counter-argument:
→ Tests catch bugs before users do
→ Tests document how your code works
→ Tests give you confidence to refactor
→ The "minimal" setup takes 15 minutes
My Testing Stack
| Layer | Tool | Why |
|---|---|---|
| Unit tests | Node.js built-in test runner | Zero dependencies |
| HTTP APIs |
node:test + undici dispatch |
Built into Node.js 18+ |
| Integration | Custom scripts with supertest
|
Simple, predictable |
| E2E | Playwright | Reliable, fast, free |
Total cost: $0. Everything here is free and open source.
Step 1: Unit Testing with Node.js Built-in Runner
No need for Jest or Mocha anymore. Node.js has its own test runner since v18:
# No installation needed — it's built in
node --test
Basic Example
// math.js
export function add(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
// math.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { add, divide } from './math.js';
describe('Math utilities', () => {
it('should add two numbers', () => {
assert.equal(add(2, 3), 5);
});
it('should handle negative numbers', () => {
assert.equal(add(-1, 1), 0);
});
it('should throw on division by zero', () => {
assert.throws(() => divide(10, 0), {
message: 'Division by zero'
});
});
});
$ node --test
✓ Math utilities (4.8ms)
✓ should add two numbers
✓ should handle negative numbers
✓ should throw on division by zero
ℹ tests 3
ℹ pass 3
ℹ fail 0
ℹ duration 15ms
Async Code Testing
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
describe('API calls', () => {
it('should fetch data successfully', async () => {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
assert.ok(data.items);
assert(Array.isArray(data.items));
});
it('should timeout after 5 seconds', async () => {
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
await assert.rejects(
async () => fetch('https://slow-api.example.com', {
signal: controller.signal
}),
{ name: 'AbortError' }
);
});
});
Step 2: Testing Express.js APIs
This is where most side projects need testing:
// app.js
import express from 'express';
const app = express();
app.use(express.json());
let items = [];
app.get('/api/items', (req, res) => {
res.json({ items });
});
app.post('/api/items', (req, res) => {
const { name } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
const item = { id: Date.now(), name };
items.push(item);
res.status(201).json(item);
});
export default app;
// app.test.js
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import createApp from './app.js';
describe('Items API', () => {
let app;
beforeEach(() => {
app = createApp();
});
it('GET /api/items returns empty array initially', async () => {
// We'll use undici's MockAgent for mocking HTTP
// Or use supertest-style approach
});
it('POST /api/items creates an item', async () => {
// Test implementation below
});
});
Using Supertest for API Testing
npm install --save-dev supertest
// app.test.js (with supertest)
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import createApp from './app.js';
describe('Items API', () => {
let app;
beforeEach(() => {
app = createApp();
});
it('GET /api/items returns empty array initially', async () => {
const res = await request(app).get('/api/items');
assert.equal(res.status, 200);
assert.deepEqual(res.body, { items: [] });
});
it('POST /api/item creates item with valid data', async () => {
const res = await request(app)
.post('/api/items')
.send({ name: 'Test Item' })
.expect('Content-Type', /json/);
assert.equal(res.status, 201);
assert.equal(res.body.name, 'Test Item');
assert.ok(res.body.id);
});
it('POST /api/items rejects missing name', async () => {
const res = await request(app)
.post('/api/items')
.send({})
.expect(400);
assert.equal(res.body.error, 'Name required');
});
});
Step 3: Testing Database Operations
For SQLite (what I use in production):
// db.test.js
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
const TEST_DB_PATH = path.join('/tmp', `test-${Date.now()}.db`);
describe('Database operations', () => {
let db;
beforeEach(() => {
db = new Database(TEST_DB_PATH);
db.exec(`CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)`);
});
afterEach(() => {
db.close();
try { fs.unlinkSync(TEST_DB_PATH); } catch {}
});
it('inserts and retrieves a user', () => {
const stmt = db.prepare('INSERT INTO users (email) VALUES (?)');
const result = stmt.run('test@example.com');
assert.ok(result.lastInsertRowid);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
assert.equal(user.email, 'test@example.com');
});
it('rejects duplicate emails', () => {
const stmt = db.prepare('INSERT INTO users (email) VALUES (?)');
stmt.run('unique@example.com');
assert.throws(() => {
stmt.run('unique@example.com');
}, /UNIQUE constraint failed/);
});
});
Key pattern: Use a fresh database file for each test run (Date.now() ensures uniqueness).
Step 4: Running Tests Automatically
package.json Scripts
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch",
"test:coverage": "node --experimental-test-coverage --test"
}
}
With GitHub Actions (Free CI)
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm test
This runs your tests on 3 Node.js versions every push. Free for public repos.
What I Actually Test (And What I Skip)
✅ TEST thoroughly:
→ Business logic (calculations, validations, transformations)
→ API endpoints (request/response format, error handling)
→ Database queries (CRUD operations, constraints)
→ Authentication flows (login, token validation)
⚠️ TEST lightly:
→ Utility functions (simple pure functions)
→ Configuration loading
→ Basic CRUD scaffolding
❌ SKIP (usually):
→ UI/CSS (use visual testing tools instead)
→ Third-party library behavior (they have their own tests)
→ Static content pages
The 80/20 Rule of Testing
20% of your code handles 80% of the complexity → TEST THIS PART
Focus on:
1. Conditional logic (if/else, switch/case)
2. Error handling paths
3. Data transformations
4. External API integrations
5. Authentication & authorization
Don't waste time testing getters/setters or trivial components.
Common Mistakes I Made (So You Don't Have To)
Mistake #1: Testing Implementation Details
// ❌ Bad — tests internal structure
it('calls save() once', () => {
assert.equal(mockSave.callCount, 1);
});
// ✅ Good — tests observable behavior
it('persists data correctly', () => {
const saved = db.query('SELECT * FROM users');
assert.equal(saved.length, 1);
});
Mistake #2: Over-mocking
// ❌ Bad — mocking everything makes tests fragile
const mockDb = { query: mockFn, exec: mockFn, prepare: mockFn };
// ✅ Good — use real database (SQLite is fast enough)
const db = new Database(':memory:');
Mistake #3: Ignoring Error Paths
// Most developers only test happy path
it('returns user data', () => { ... });
// Also test what happens when things go WRONG
it('handles database connection failure', () => { ... });
it('returns 404 for non-existent user', () => { ... });
it('validates malformed input', () => { ... });
Quick Reference: Assertion Cheatsheet
import assert from 'node:assert/strict';
// Equality
assert.equal(actual, expected); // ==
assert.notEqual(actual, expected); // !=
assert.deepEqual(actual, expected); // deep object compare
// Types & existence
assert.ok(value); // truthy
assert.strictEqual(a, b); // ===
assert.typeOf(value, 'string'); // type check
// Exceptions
assert.throws(fn, { message: /.../ }); // must throw matching error
assert.doesNotThrow(fn); // must not throw
// Promises
await assert.rejects(asyncFn); // promise must reject
await assert.doesNotReject(asyncFn); // promise must resolve
What's your testing setup? Are you using the built-in test runner yet?
Follow @armorbreak for more practical Node.js guides.
Resources:
- Node.js Test Runner Docs — Official documentation
- Playwright — E2E testing framework (free)
- Supertest — HTTP assertion library
- better-sqlite3 — Fastest SQLite client for Node.js
- GitHub Actions — Free CI/CD for public repos
Top comments (0)