Testing

1. Introduction โœ…

Automated tests give you confidence that your Node.js application behaves correctly โ€” both today and after every future change. This tutorial covers testing philosophy, popular frameworks, and practical techniques like mocking, coverage, and CI integration.

2. Why Test Node.js Applications? ๐ŸŽฏ

  • Catches regressions before they reach production.
  • Documents expected behavior through executable examples.
  • Enables confident refactoring โ€” tests fail loudly if behavior changes unexpectedly.
  • Speeds up debugging by isolating exactly which unit broke.

3. Types of Testing ๐Ÿงช

3.1 Unit Testing

Unit tests verify a single function or module in isolation, typically with dependencies mocked out.

3.2 Integration Testing

Integration tests verify that multiple units โ€” a route handler, a database, a service โ€” work correctly together.

3.3 End-to-End Testing

End-to-end (E2E) tests exercise the entire system as a real user would, often through a browser or full API calls against a running instance.

TypeScopeSpeed
UnitSingle function/moduleVery fast
IntegrationMultiple units togetherModerate
End-to-EndWhole systemSlow

Best Practice

A healthy test suite follows the testing pyramid: many unit tests, fewer integration tests, and a handful of E2E tests.

4. Node.js Test Runner ๐Ÿƒ

Since Node 18+, a built-in test runner (node:test) is available with no external dependencies required.

node-test-runner.test.js

const test = require('node:test');
const assert = require('node:assert');

test('adds two numbers', () => {
  assert.strictEqual(1 + 2, 3);
});

test('async example', async () => {
  const result = await Promise.resolve(42);
  assert.strictEqual(result, 42);
});

run-tests.sh

node --test

5. Popular Testing Frameworks ๐Ÿงฐ

Jest is a full-featured, batteries-included framework: assertions, mocking, and coverage all built in.

jest-example.test.js

test('adds 1 + 2 to equal 3', () => {
  expect(1 + 2).toBe(3);
});

Vitest is a modern, fast alternative with a Jest-compatible API, built for ESM and Vite-based projects.

vitest-example.test.js

import { expect, test } from 'vitest';

test('adds 1 + 2 to equal 3', () => {
  expect(1 + 2).toBe(3);
});

Mocha provides the test runner and structure, while Chai supplies expressive assertions โ€” a flexible, unopinionated pairing.

mocha-chai-example.test.js

const { expect } = require('chai');

describe('Addition', () => {
  it('adds 1 + 2 to equal 3', () => {
    expect(1 + 2).to.equal(3);
  });
});

6. Supertest ๐Ÿ“ฎ

Supertest makes HTTP assertions easy by wrapping an http.Server instance (like an Express app) and letting you chain request builders directly.

supertest-example.test.js

const request = require('supertest');
const app = require('../app');

test('GET /users returns 200', async () => {
  const response = await request(app).get('/users');
  expect(response.status).toBe(200);
  expect(response.body).toHaveLength(3);
});

7. Test Doubles: Mocking, Stubbing, Spying ๐ŸŽญ

7.1 Mocking

A mock replaces a real dependency with a fake implementation whose behavior and calls you can inspect.

mocking-example.test.js

jest.mock('../emailService');
const emailService = require('../emailService');

emailService.send.mockResolvedValue({ success: true });

7.2 Stubbing

A stub replaces a function with one that returns a predetermined value, without necessarily tracking how it was called.

stubbing-example.test.js

const sinon = require('sinon');

const stub = sinon.stub(database, 'getUser').returns({ id: 1, name: 'Alice' });

7.3 Spying

A spy wraps a real function, letting it still execute while recording how many times it was called and with what arguments.

spying-example.test.js

const spy = jest.spyOn(console, 'log');

logGreeting('Alice');

expect(spy).toHaveBeenCalledWith('Hello, Alice!');

8. Testing Functions ๐Ÿ”ข

testing-functions.test.js

function add(a, b) {
  return a + b;
}

test('add() returns the sum of two numbers', () => {
  expect(add(2, 3)).toBe(5);
  expect(add(-1, 1)).toBe(0);
});

9. Testing Modules ๐Ÿ“ฆ

testing-modules.test.js

const { greet } = require('../greet');

describe('greet module', () => {
  test('greets by name', () => {
    expect(greet('Node')).toBe('Hello, Node!');
  });
});

10. Testing APIs ๐ŸŒ

testing-apis.test.js

const request = require('supertest');
const app = require('../app');

describe('POST /users', () => {
  test('creates a new user', async () => {
    const res = await request(app)
      .post('/users')
      .send({ name: 'Bob' });

    expect(res.status).toBe(201);
    expect(res.body.name).toBe('Bob');
  });
});

11. Testing Databases ๐Ÿ—„๏ธ

Database tests typically use a dedicated test database or an in-memory alternative, resetting state before or after each test to keep them independent.

testing-databases.test.js

beforeEach(async () => {
  await db.migrate.latest();
  await db.seed.run();
});

afterEach(async () => {
  await db('users').del();
});

test('creates a user record', async () => {
  const user = await createUser({ name: 'Alice' });
  expect(user.id).toBeDefined();
});

12. Testing File Systems ๐Ÿ“

File system tests should avoid touching real project files โ€” use a temporary directory, or mock fs entirely to keep tests fast and side-effect free.

testing-file-systems.test.js

const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs/promises');

test('writes and reads a file', async () => {
  const tmpFile = path.join(os.tmpdir(), 'test-file.txt');
  await fs.writeFile(tmpFile, 'hello');
  const content = await fs.readFile(tmpFile, 'utf-8');
  expect(content).toBe('hello');
  await fs.unlink(tmpFile);
});

13. Snapshot Testing ๐Ÿ“ธ

Snapshot tests capture the output of a function or component and compare future runs against that saved snapshot, flagging any unexpected change.

snapshot-testing.test.js

test('renders user profile correctly', () => {
  const profile = renderUserProfile({ name: 'Alice', age: 30 });
  expect(profile).toMatchSnapshot();
});

Caution

Blindly running --updateSnapshot whenever a snapshot fails defeats the purpose โ€” always review the diff first to confirm the change is intentional.

14. Code Coverage ๐Ÿ“Š

Coverage reports show which lines, branches, and functions were actually exercised by your test suite โ€” useful for spotting untested code, though 100% coverage doesn't guarantee correctness.

coverage-command.sh

node --test --experimental-test-coverage
# or with Jest:
jest --coverage

15. Continuous Integration ๐Ÿ”

CI pipelines automatically run your test suite on every push or pull request, catching regressions before they merge.

.github/workflows/test.yml

name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test

16. Debugging Tests ๐Ÿ”

debug-tests.sh

node --inspect-brk node_modules/.bin/jest --runInBand

Tip

Running Jest with --runInBand disables parallel workers, making it much easier to attach a debugger to a single test process.

17. Test Organization ๐Ÿ—‚๏ธ

my-app
src
user.js
order.js
tests
user.test.js
order.test.js
  • Mirror your src structure inside tests for easy navigation.
  • Group related tests with describe() blocks by feature or module.
  • Keep integration and unit tests in clearly separated directories.

18. Best Practices โœ…

  • Write tests that are independent โ€” no test should rely on another running first.
  • Prefer testing behavior (inputs and outputs) over internal implementation details.
  • Keep test names descriptive: it('returns 404 when user does not exist') beats it('works').
  • Reset mocks and test data between tests to avoid state leakage.
  • Run tests automatically in CI on every pull request.

19. Common Mistakes โš ๏ธ

  • Writing tests that depend on execution order or shared mutable state.
  • Over-mocking to the point that tests no longer verify real behavior.
  • Chasing 100% coverage as a goal in itself, rather than testing meaningful behavior.
  • Forgetting to clean up test data (database rows, temp files) after each test.
  • Committing outdated snapshots without reviewing what actually changed.

20. Frequently Asked Questions โ“

Question

Do I need Jest, or is the built-in node:test runner enough?

Answer

node:test is solid for many projects and has zero dependencies. Jest or Vitest add richer mocking utilities, snapshot testing, and ecosystem tooling that larger projects often benefit from.

Question

What's the difference between a mock and a stub?

Answer

A stub just returns canned data; a mock additionally lets you assert on how it was called โ€” arguments, call count, and order.

Question

How much test coverage is "enough"?

Answer

There's no universal number โ€” focus on covering critical business logic and edge cases thoroughly rather than chasing a specific percentage.

21. Summary ๐Ÿ“

Summary

A healthy Node.js test suite blends unit, integration, and end-to-end tests, using tools like the built-in node:test runner, Jest, or Vitest, alongside Supertest for API testing. Judicious use of mocks, stubs, and spies keeps tests fast and isolated, while coverage reports and CI pipelines help catch regressions before they reach production.