Testing Introduction
Why testing matters, types of tests, and choosing the right testing framework for your project.
Why Test?#
"It works on my machine" is not a deployment strategy.
Without tests:
- You break things without knowing
- Refactoring is terrifying
- New team members are afraid to touch code
- Bugs reach production
With tests:
- Catch bugs before users do
- Refactor with confidence
- Documentation that runs
- Sleep better at night
Types of Tests#
Unit Tests#
Test individual functions in isolation.
// Testing a function
function add(a, b) {
return a + b;
}
test('add returns sum of two numbers', () => {
expect(add(2, 3)).toBe(5);
});
Fast, isolated, many of these.
Integration Tests#
Test how pieces work together.
// Testing API endpoint with database
test('POST /users creates a user', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'John', email: 'john@test.com' });
expect(response.status).toBe(201);
expect(response.body.data.name).toBe('John');
// Verify it's actually in the database
const user = await User.findOne({ email: 'john@test.com' });
expect(user).not.toBeNull();
});
Slower, tests real interactions, fewer of these.
End-to-End (E2E) Tests#
Test the entire system like a user would.
// Full flow: register → login → create resource
test('user registration flow', async () => {
await request(app)
.post('/api/auth/register')
.send({ email: 'new@test.com', password: 'password123' });
const login = await request(app)
.post('/api/auth/login')
.send({ email: 'new@test.com', password: 'password123' });
const token = login.body.data.tokens.accessToken;
const profile = await request(app)
.get('/api/users/me')
.set('Authorization', `Bearer ${token}`);
expect(profile.body.data.email).toBe('new@test.com');
});
Slowest, most realistic, few of these.
The Testing Pyramid#
/\
/ \ E2E Tests (few)
/----\
/ \ Integration Tests (some)
/--------\
/ \ Unit Tests (many)
/------------\
- Many unit tests (fast, cheap)
- Some integration tests (medium)
- Few E2E tests (slow, expensive)
Choosing a Testing Framework#
Option 1: Node.js Built-in Test Runner (v20+)#
import { test, describe } from 'node:test';
import assert from 'node:assert';
describe('math', () => {
test('adds numbers', () => {
assert.strictEqual(1 + 1, 2);
});
});
Pros: No dependencies, built into Node Cons: Fewer features, newer ecosystem Best for: Simple projects, minimal dependencies
Option 2: Vitest#
import { describe, it, expect } from 'vitest';
describe('math', () => {
it('adds numbers', () => {
expect(1 + 1).toBe(2);
});
});
Pros: Fast, modern, Jest-compatible API, great DX Cons: Newer, smaller ecosystem than Jest Best for: Modern projects, Vite users, speed priority
Option 3: Jest#
describe('math', () => {
it('adds numbers', () => {
expect(1 + 1).toBe(2);
});
});
Pros: Mature, huge ecosystem, lots of resources Cons: Slower, heavier, ESM support can be tricky Best for: Large teams, existing Jest experience
Comparison Table#
| Feature | Node Test | Vitest | Jest |
|---|---|---|---|
| Speed | Fast | Very Fast | Slower |
| Dependencies | None | Light | Heavy |
| ESM Support | Native | Native | Tricky |
| Watch Mode | Basic | Excellent | Good |
| Mocking | Manual | Built-in | Built-in |
| Snapshot | No | Yes | Yes |
| Coverage | Via c8 | Built-in | Built-in |
| Community | Growing | Growing | Huge |
Recommendation
New projects: Vitest - fast, modern, great DX Existing Jest projects: Stay with Jest Minimal dependencies: Node.js built-in
Project Setup#
Vitest Setup#
npm install -D vitest
// package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
});
Jest Setup#
npm install -D jest
// package.json
{
"scripts": {
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:watch": "npm test -- --watch",
"test:coverage": "npm test -- --coverage"
}
}
// jest.config.js
export default {
testEnvironment: 'node',
transform: {},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
};
Node.js Test Runner#
// package.json
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch",
"test:coverage": "node --test --experimental-test-coverage"
}
}
File Organization#
src/
├── services/
│ ├── users.js
│ └── users.test.js # Co-located tests
├── utils/
│ ├── crypto.js
│ └── crypto.test.js
# Or separate test directory
tests/
├── unit/
│ ├── services/
│ │ └── users.test.js
│ └── utils/
│ └── crypto.test.js
├── integration/
│ └── api/
│ └── users.test.js
└── setup.js # Global test setup
Key Takeaways#
- Test at multiple levels - Unit, integration, E2E
- More unit tests, fewer E2E - Follow the pyramid
- Choose based on your needs - Vitest for speed, Jest for ecosystem
- Tests are documentation - They show how code should work
- Start testing early - Retrofitting tests is painful
Getting Started
Pick a framework, write one test, run it. Don't overthink it. A few tests are infinitely better than no tests.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.