Course:Node.js & Express/
Lesson

If you have ever shipped a change that broke something you thought was fine, you already understand why testing exists. Tests are the safety net that lets you refactor fearlessly and deploy with confidence. They are not bureaucratic overhead, they are the thing that lets you move fast without breaking things.

Why tests matter

Think of tests like the structural inspections a building goes through before anyone moves in. You could skip them, and the building might stand just fine, or it might collapse the moment someone opens a window. Tests give you proof that your code handles the cases you care about, and they give future-you a warning when a new change breaks old behaviour.

There are three broad categories of test you will write for a backend:

TypeWhat it testsSpeedCost
UnitA single function in isolationVery fastLow
IntegrationMultiple parts working togetherMediumMedium
End-to-endThe full system from HTTP request to databaseSlowHigh

The goal is not 100% coverage of every type, it is the right mix. Unit tests are cheap to write and fast to run, so you write a lot of them. Integration tests catch wiring problems that unit tests miss. End-to-end testsWhat is e2e test?An automated check that drives the full application the way a user would, clicking buttons and filling forms in a real browser. catch the things only a real request can reveal.

02

Setting up Vitest

Vitest is the test runner you will use throughout this moduleWhat is module?A self-contained file of code with its own scope that explicitly exports values for other files to import, preventing name collisions.. It is fast, has excellent TypeScript support, and its APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. is nearly identical to Jest, so most documentation you find online applies to both.

Install it first:

npm install --save-dev vitest @vitest/coverage-v8

Then create a config file at the root of your project:

// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    setupFiles: ['./tests/setup.js'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'tests/']
    }
  }
});

Add a script to your package.json:

json
{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}
Setting `globals
true makes describe, it, expect, and beforeEach` available without importing them in every file. It is a convenience worth enabling early.
03

Writing your first test

Every test file has the same skeleton. You group related tests with describe, define individual cases with it (or test, they are identical), and make assertions with expect.

// tests/math.test.js
import { add, divide } from '../src/math.js';

describe('add', () => {
  it('adds two positive numbers', () => {
    expect(add(2, 3)).toBe(5);
  });

  it('handles negative numbers', () => {
    expect(add(-1, 1)).toBe(0);
  });
});

describe('divide', () => {
  it('divides correctly', () => {
    expect(divide(10, 2)).toBe(5);
  });

  it('throws on division by zero', () => {
    expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
  });
});

The AAA pattern

Every well-written test follows three steps: Arrange, Act, Assert.

it('calculates order total with discount', () => {
  // Arrange - set up the data you need
  const items = [{ price: 100 }, { price: 50 }];
  const discountRate = 0.1;

  // Act - call the code under test
  const total = calculateTotal(items, discountRate);

  // Assert - verify the result
  expect(total).toBe(135);
});

Keeping these steps visually separated (even with blank lines or comments) makes tests easier to read and debug. When a test fails, you want to understand exactly what was set up, what was called, and what went wrong.

Hooks for shared setup

If multiple tests need the same starting state, use hooks instead of repeating setup code:

describe('UserService', () => {
  let userService;

  beforeEach(() => {
    // Runs before each test
    userService = new UserService({ db: mockDb });
  });

  afterEach(() => {
    // Runs after each test - clean up side effects
    vi.clearAllMocks();
  });

  it('creates a user', async () => {
    const user = await userService.create({ name: 'Alice' });
    expect(user.id).toBeDefined();
  });

  it('finds a user by id', async () => {
    const user = await userService.findById(1);
    expect(user.name).toBe('Alice');
  });
});
Use beforeEach rather than beforeAll for setup that mutates state. beforeAll runs once before all tests in a suite, if one test changes the shared state, it can corrupt every test that runs after it.
04

Common matchers

Vitest comes with a rich set of matchers. These are the ones you will use most:

MatcherUse case
toBe(value)Strict equality (same reference for objects)
toEqual(value)Deep equality (compares object contents)
toBeTruthy() / toBeFalsy()Loosely truthy or falsy
toBeNull()Exactly null
toBeDefined()Not undefined
toContain(item)Array contains item, or string contains substring
toHaveLength(n)Array or string has length n
toThrow(message)Function throws an error
resolves.toBe(value)Promise resolves to value
rejects.toThrow()Promise rejects

For async code, always use await with resolves and rejects:

it('fetches user from database', async () => {
  await expect(db.findUser(1)).resolves.toEqual({ id: 1, name: 'Alice' });
});

it('rejects with not found error', async () => {
  await expect(db.findUser(999)).rejects.toThrow('User not found');
});
05

Quick reference

CommandWhat it does
npx vitestRun tests in watch mode
npx vitest runRun tests once (CI mode)
npx vitest run --coverageRun tests + generate coverage report
npx vitest --reporter=verboseShow each test name in output
it.only(...)Run only this test (debug mode)
it.skip(...)Skip this test
javascript
// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    setupFiles: ['./tests/setup.js'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'tests/']
    }
  }
});