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:
| Type | What it tests | Speed | Cost |
|---|---|---|---|
| Unit | A single function in isolation | Very fast | Low |
| Integration | Multiple parts working together | Medium | Medium |
| End-to-end | The full system from HTTP request to database | Slow | High |
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.
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-v8Then 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:
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage"
}
} makes describe, it, expect, and beforeEach` available without importing them in every file. It is a convenience worth enabling early.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');
});
});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.Common matchers
Vitest comes with a rich set of matchers. These are the ones you will use most:
| Matcher | Use 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');
});Quick reference
| Command | What it does |
|---|---|
npx vitest | Run tests in watch mode |
npx vitest run | Run tests once (CI mode) |
npx vitest run --coverage | Run tests + generate coverage report |
npx vitest --reporter=verbose | Show each test name in output |
it.only(...) | Run only this test (debug mode) |
it.skip(...) | Skip this test |
// 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/']
}
}
});