ttps://slow-api.example.com', {
signal: controller.signal
}),
{ name: 'AbortError' }
);
});
});
Enter fullscreen mode Exit fullscreen mode
## [](#step-2-testing-expressjs-apis)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;
Enter fullscreen mode Exit fullscreen mode
// 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
});
});
Enter fullscreen mode Exit fullscreen mode
### [](#using-supertest-for-api-testing)Using Supertest for API Testing
npm install --save-dev supertest
Enter fullscreen mode Exit fullscreen mode
// 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');
});
});
Enter fullscreen mode Exit fullscreen mode
## [](#step-3-testing-database-operations)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/);
});
});
Enter fullscreen mode Exit fullscreen mode
**Key pattern**: Use a fresh database file for each test run (`Date.now()` ensures uniqueness).
## [](#step-4-running-tests-automatically)Step 4: Running Tests Automatically
### [](#packagejson-scripts)package.json Scripts
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch",
"test:coverage": "node --experimental-test-coverage --test"
}
}
Enter fullscreen mode Exit fullscreen mode
### [](#with-github-actions-free-ci)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
Enter fullscreen mode Exit fullscreen mode
This runs your tests on **3 Node.js versions** every push. Free for public repos.
## [](#what-i-actually-test-and-what-i-skip)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
Enter fullscreen mode Exit fullscreen mode
## [](#the-8020-rule-of-testing)The 80/20 Rule of Testing
20% of your code handles 80% of the complexity β TEST THIS PART
Focus on:
- Conditional logic (if/else, switch/case)
- Error handling paths
- Data transformations
- External API integrations
- Authentication & authorization
Don't waste time testing getters/setters or trivial components.
Enter fullscreen mode Exit fullscreen mode
## [](#common-mistakes-i-made-so-you-dont-have-to)Common Mistakes I Made (So You Don't Have To)
### [](#mistake-1-testing-implementation-details)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);
});
Enter fullscreen mode Exit fullscreen mode
### [](#mistake-2-overmocking)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:');
Enter fullscreen mode Exit fullscreen mode
### [](#mistake-3-ignoring-error-paths)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', () => { ... });
Enter fullscreen mode Exit fullscreen mode
## [](#quick-reference-assertion-cheatsheet)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
Enter fullscreen mode Exit fullscreen mode
* * *
_What's your testing setup? Are you using the built-in test runner yet?_
_Follow [@armorbreak](https://dev.to/armorbreak) for more practical Node.js guides._
* * *
**Resources:**
- [Node.js Test Runner Docs](https://nodejs.org/api/test.html) β Official documentation
- [Playwright](https://playwright.dev/) β E2E testing framework (free)
- [Supertest](https://github.com/visionmedia/supertest) β HTTP assertion library
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) β Fastest SQLite client for Node.js
- [GitHub Actions](https://github.com/features/actions) β Free CI/CD for public repos