Testing in Node - Why You Need It Not Just Want It¶
Table of Contents¶
- Why Test : Catching Regressions , Confidence , Docs
- Test Types : Unit , Integration , E2E , Snapshot
- Test Runners : Jest , Mocha , Vitest , node:test
- Assertion Styles : assert , expect , should
- Arrange-Act-Assert Pattern
Why Test : Catching Regressions , Confidence , Docs¶
Every time you ship untested code you're gambling. Maybe that refactor didn't break anything. Maybe that new feature doesn't silently corrupt user data. Maybe your auth middleware still actually checks tokens. But without tests you're just guessing based on vibes
Tests do three things nothing else does:
- Catch regressions before they hit production - that "tiny change" that breaks something three modules away
- Document behavior explicitly - tests are executable specs that can't go stale like READMEs
- Give you refactoring confidence - change 10 files and know instantly if you broke something
The rule is simple: if you can't run a test suite and see green before deploying , you're pushing with your eyes closed. Stop doing that
Test Types : Unit , Integration , E2E , Snapshot¶
Unit tests test one thing in isolation - a function , a class method , a utility. No database , no network , no filesystem. Pure logic verification. Fast as hell , run them constantly
// pure unit test - no deps to mock
function calculateDiscount(price, coupon) {
if (!coupon || !coupon.valid) return price
return price - price * (coupon.percentOff / 100)
}
// test
expect(calculateDiscount(100, { valid: true, percentOff: 20 })).toBe(80)
Integration tests test how pieces work together - a route handler calling a database , a service talking to an external API. Slower but catch the bugs unit tests miss (wrong types between layers , missing DB fields)
End-to-end (E2E) tests spin up the whole app and simulate real user behavior. Login , click buttons , fill forms , verify results. Slowest but highest confidence. You can't ship broken auth if your E2E test logs in every time
Snapshot tests capture the output of a component or function and alert you when it changes. Useful for UI components but abused by lazy devs who snap entire API responses
Test Runners : Jest , Mocha , Vitest , node:test¶
Jest - the 800-pound gorilla. Built-in assertions , mocking , coverage , watch mode. Zero config for most projects. Delightful DX with the interactive watch UI. The default choice unless you have a reason not to
Mocha - old reliable. Minimal , flexible , pairs with Chai (assertions) and Sinon (mocks). More explicit wiring required. You control every piece , which means more boilerplate but also more control
Vitest - the new hotness. Drop-in Jest-compatible API but built on Vite. Insane speed for complex projects. ESM-native. If you're already using Vite for your frontend , Vitest shares the config and transform pipeline
node:test - built into Node 20+ . No install required. Minimal API but growing. Good for quick scripts and small projects where you don't want another dependency
// node:test example (Node 20+)
const { test, describe, it } = require('node:test')
const assert = require('node:assert/strict')
describe('math utilities', () => {
it('adds numbers correctly', () => {
assert.strictEqual(1 + 1, 2)
})
})
Assertion Styles : assert , expect , should¶
assert - Node built-in , no fancy DSL. assert.strictEqual(a, b) , assert.throws(fn). Verbose but zero dependencies. Good for minimal setups
expect - Jest/Vitest style. Readable chainable matchers: expect(thing).toBe(value), expect(fn).toThrow(). The most popular style for good reason - it reads like English
should - Chai style that extends Object.prototype. Controversial because it modifies global prototypes but some people swear by it. value.should.equal(42)
Stick with expect for new projects. It's the convention , your team knows it , and every test runner supports the same API
Arrange-Act-Assert Pattern¶
Every test follows the same three-phase structure:
- Arrange - set up the world (create objects , mock dependencies , seed data)
- Act - execute the thing you're testing (call the function , hit the endpoint)
- Assert - verify the result matches expectations
const { test, describe } = require('node:test')
const assert = require('node:assert/strict')
function authenticate(user, password) {
if (!user) throw new Error('User not found')
if (user.password !== password) return false
return { id: user.id, name: user.name }
}
describe('authenticate', () => {
it('returns user object for valid credentials', () => {
// Arrange
const user = { id: 1, name: 'ali', password: 'hunter2' }
// Act
const result = authenticate(user, 'hunter2')
// Assert
assert.ok(result)
assert.strictEqual(result.id, 1)
assert.strictEqual(result.name, 'ali')
})
it('returns false for wrong password', () => {
const user = { id: 1, name: 'ali', password: 'hunter2' }
const result = authenticate(user, 'wrongpass')
assert.strictEqual(result, false)
})
it('throws when user is not found', () => {
assert.throws(() => authenticate(null, 'test'), { message: 'User not found' })
})
})
Each test is self-contained. No shared state between tests. If tests depend on each other , your test suite is already broken and you just don't know it yet
One assertion per test is a myth - assert everything relevant about the result. But don't test 15 unrelated things in one test. Each test verifies one behavior
prerequisites¶
web_09_websocket.md - WebSocket patterns , request/response handling , HTTP fundamentals
next -> test_02_jest.md