DEV Community

Alex Chen
Alex Chen

Posted on

How I Test My Node.js Apps: A Practical Guide (2026)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
// 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'
    });
  });
});
Enter fullscreen mode Exit fullscreen mode
$ 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
Enter fullscreen mode Exit fullscreen mode

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' }
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

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

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

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

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)

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

✅ 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 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.
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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

// 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

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 for more practical Node.js guides.


Resources:

Top comments (0)