Skip to content

Testing in Node - Why You Need It Not Just Want It

Table of Contents


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