Skip to content
AyoKoding

Test Driven Development

Why Test-Driven Development Matters

Test-Driven Development (TDD) is critical for TypeScript applications because it ensures type-safe code behaves correctly at runtime, prevents regression bugs, enables confident refactoring, and documents behavior through executable specifications.

Core Benefits:

  • Runtime correctness: Types check at compile-time, tests verify runtime behavior
  • Prevents regressions: Catch bugs before deployment
  • Enables refactoring: Tests provide safety net for improvements
  • Documents behavior: Tests serve as executable specifications
  • Builds confidence: High test coverage reduces deployment risk

Problem: TypeScript's type system catches type errors, but cannot verify business logic correctness.

Solution: Write tests first to catch logic bugs in seconds, never in production.

The Red-Green-Refactor Cycle

TDD follows a simple three-step cycle that drives development through tests.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
%% All colors are color-blind friendly and meet WCAG AA contrast standards
 
stateDiagram-v2
    [*] --> Red: Write test
 
    Red --> Green: Test fails
    Green --> Refactor: Test passes
    Refactor --> Red: Tests still pass,<br/>next feature
 
    note right of Red
        šŸ”“ RED
        Write failing test
        Specify desired behavior
    end note
 
    note right of Green
        🟢 GREEN
        Write minimum code
        Make test pass
    end note
 
    note right of Refactor
        ā™»ļø REFACTOR
        Improve code quality
        Keep tests passing
    end note
 
    classDef redState fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:3px
    classDef greenState fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:3px
    classDef refactorState fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:3px
 
    class Red redState
    class Green greenState
    class Refactor refactorState

Step 1: RED - Write Failing Test

Write a test that specifies desired behavior. The test fails because the feature doesn't exist yet.

Before: No test, no specification of behavior After: Clear specification of what code should do, test fails as expected

Step 2: GREEN - Make It Pass

Write minimum code to make the test pass. Don't worry about perfection yet.

Before: Test fails, feature not implemented After: Test passes, feature works (even if implementation is simple)

Step 3: REFACTOR - Improve Code

Improve code quality while keeping tests green. Extract functions, improve naming, add type safety.

Before: Code works but may be simple or duplicated After: Clean, maintainable code with tests still passing

Testing Approaches in TypeScript

TypeScript/Node.js provides multiple approaches for testing code, from built-in modules to sophisticated frameworks.

ApproachAbstractionOrganizationReportingProduction ReadyUse When
assert moduleLowManualNoneNoLearning, debugging
Manual runnersLowManualBasicNoSimple scripts
JestHighAutomaticRichYesReact apps, full-featured
VitestHighAutomaticRichYesVite projects
MochaMediumAutomaticGoodYesFlexible, composable

Recommended progression: Start with assert module to understand testing fundamentals → Learn manual test runners to see framework value → Use Jest/Vitest/Mocha for production testing.

Built-in Testing with Standard Library

TypeScript can use Node.js's built-in assert module for basic testing without external dependencies.

Assert Module Basics

Node.js provides the assert module for runtime assertions that verify program correctness.

Pattern:

import assert from "assert";
// => Import assert from Node.js standard library
// => No external dependencies required
// => Available in all Node.js versions
 
class Calculator {
  // => Simple calculator for demonstration purposes
  // => Type-safe methods with TypeScript annotations
 
  add(a: number, b: number): number {
    // => Basic addition operation
    // => Type parameters ensure number inputs
    return a + b;
    // => No validation (intentionally simple for learning)
  }
 
  divide(a: number, b: number): number {
    assert(b !== 0, "Divisor cannot be zero");
    // => assert() runtime check
    // => Throws AssertionError if condition false
    // => Second parameter is error message
    return a / b;
    // => Division proceeds if assertion passes
  }
}
 
// Manual test runner
const calc = new Calculator();
// => Create instance to test
 
// Test addition
const result = calc.add(2, 3);
// => Execute method under test
assert.strictEqual(result, 5, "Expected 2 + 3 = 5");
// => assert.strictEqual checks exact equality (===)
// => Throws AssertionError if result !== 5
// => Third parameter is error message
 
// Test division
const quotient = calc.divide(10, 2);
// => Test another operation
assert.strictEqual(quotient, 5, "Expected 10 / 2 = 5");
// => Same assertion pattern
// => Message provides diagnostic context
 
console.log("All assertions passed!");
// => Success message (only reached if all assertions pass)
// => No automatic test reporting or aggregation

Running tests:

# Run with ts-node for development
npx ts-node test.ts
 
# Or compile and run with node
tsc test.ts && node test.js

Problem: No test organization, discovery, or reporting.

Solution: Manual test execution sufficient for learning, inadequate for production.

Assert Methods

Assert module provides multiple assertion methods for different comparison needs.

Syntax:

import assert from "assert";
// => Import assert module
 
// Loose equality (==)
assert.equal(actual, expected, message);
// => Uses == comparison (type coercion allowed)
 
// Strict equality (===)
assert.strictEqual(actual, expected, message);
// => Uses === comparison (no type coercion)
// => RECOMMENDED: Use strictEqual in TypeScript
 
// Deep equality (object comparison)
assert.deepStrictEqual(actual, expected, message);
// => Recursively compares object properties
// => Uses === for primitive values
 
// Truthy/falsy checks
assert.ok(value, message);
// => Checks if value is truthy
// => Equivalent to assert(value, message)
 
// Exception checking
assert.throws(() => code(), ErrorClass, message);
// => Verifies that code() throws expected error
// => Second parameter can be error class or regex

Example:

class StringUtils {
  // => String manipulation utilities
  // => Type-safe string operations
 
  reverse(input: string): string {
    // => Reverses a string
    assert(input !== null && input !== undefined, "Input cannot be null/undefined");
    // => Pre-condition: input must exist
    // => Runtime check (TypeScript types don't prevent null at runtime)
    assert(input.length > 0, "Input cannot be empty");
    // => Pre-condition: input must have content
 
    const reversed = input.split("").reverse().join("");
    // => split('') creates character array
    // => reverse() reverses array
    // => join('') converts back to string
 
    assert(reversed.length === input.length, "Length mismatch after reversal");
    // => Post-condition: reversed string has same length
    // => Invariant check
 
    return reversed;
    // => Returns reversed string
  }
}
 
// Test suite
const utils = new StringUtils();
// => Create instance for testing
 
assert.strictEqual(utils.reverse("hello"), "olleh", "Failed to reverse 'hello'");
// => Verifies reverse operation correctness
// => strictEqual ensures type-safe comparison
 
assert.strictEqual(utils.reverse("TypeScript"), "tpircSepyT", "Failed to reverse 'TypeScript'");
// => Test with different input
// => Multiple assertions verify behavior across inputs
 
console.log("All string tests passed!");
// => Manual success reporting

Density: 13 code lines, 19 annotation lines = 1.46 density (within 1.0-2.25 target)

Limitations of Assert Module

Assert module has significant limitations for production testing.

Critical limitations:

  1. No test discovery: Must manually import and run tests
  2. Poor failure reporting: Only shows first failure, then stops execution
  3. No test organization: No way to group related tests (describe/it blocks)
  4. No setup/teardown: No lifecycle hooks for test isolation
  5. No test reporting: No summary of passed/failed tests
  6. No parallel execution: Tests run sequentially in single process
  7. No mocking: No built-in mocking or stubbing capabilities
  8. No coverage: No code coverage reporting

Example showing limitations:

import assert from "assert";
// => Demonstrates why assert module insufficient for production testing
 
// Limitation 1: First failure stops everything
assert.strictEqual(2 + 2, 4, "Math works");
// => This assertion passes (condition true)
// => Execution continues to next line
 
assert.strictEqual(2 + 2, 5, "This fails and stops execution");
// => This assertion FAILS (2 + 2 !== 5)
// => Throws AssertionError immediately
// => Halts entire program (no graceful handling)
// => Remaining assertions never executed
 
assert.strictEqual(3 + 3, 6, "Never runs");
// => This line NEVER executes
// => No reporting of how many tests passed before failure
// => No aggregated test results

Trade-offs:

  • Simplicity: Zero dependencies, minimal setup
  • No tooling: No test runner, no reporting, no coverage
  • Justification: Good for learning assertions, inadequate for production testing

When to use assert module:

  • āœ… Learning testing concepts
  • āœ… Simple scripts and utilities
  • āœ… Runtime assertions in production code (defensive programming)
  • āŒ Production test suites (use Jest/Vitest/Mocha)
  • āŒ CI/CD pipelines (need reporting and exit codes)
  • āŒ Complex applications (need mocking and fixtures)

Production Testing with Jest

Jest is a batteries-included testing framework providing test organization, mocking, coverage, and rich reporting out of the box.

Installing Jest

Install Jest with TypeScript support.

Installation:

# Install Jest and TypeScript support
npm install --save-dev jest @types/jest ts-jest
 
# Initialize Jest configuration
npx ts-jest config:init

Configuration (jest.config.js):

module.exports = {
  preset: "ts-jest",
  // => Use ts-jest preset for TypeScript support
  // => Handles .ts file compilation automatically
  testEnvironment: "node",
  // => Use Node.js environment (not browser)
  // => Alternative: 'jsdom' for browser-like environment
  collectCoverageFrom: [
    "src/**/*.ts",
    // => Collect coverage from all TypeScript files in src/
    "!src/**/*.d.ts",
    // => Exclude type declaration files
  ],
  coverageThreshold: {
    // => Enforce minimum coverage percentages
    global: {
      branches: 80,
      // => 80% branch coverage required
      functions: 80,
      // => 80% function coverage required
      lines: 80,
      // => 80% line coverage required
      statements: 80,
      // => 80% statement coverage required
    },
  },
};
// => Configuration object exported as CommonJS module
// => Jest reads this configuration automatically

Jest Test Structure

Jest provides describe and test/it blocks for organizing tests.

Pattern:

import { Calculator } from "./calculator";
// => Import code under test
// => TypeScript ensures type-safe imports
 
describe("Calculator", () => {
  // => describe() groups related tests
  // => First parameter is group name (shown in reports)
  // => Second parameter is function containing tests
 
  let calc: Calculator;
  // => Declare variable for test subject
  // => Type annotation ensures type safety
 
  beforeEach(() => {
    // => beforeEach() runs before each test
    // => Creates fresh instance for test isolation
    calc = new Calculator();
    // => Initialize calculator for each test
    // => Prevents test interdependencies
  });
 
  afterEach(() => {
    // => afterEach() runs after each test
    // => Cleanup hook for resource disposal
    // => Not needed for simple objects, but demonstrates pattern
  });
 
  test("adds two numbers correctly", () => {
    // => test() defines individual test case
    // => Alternative: it() (BDD-style alias)
    // => First parameter is test description
 
    const result = calc.add(2, 3);
    // => Arrange: prepare test data
    // => Act: execute method under test
 
    expect(result).toBe(5);
    // => Assert: verify expected outcome
    // => expect() creates assertion
    // => toBe() matcher for strict equality (===)
  });
 
  test("divides two numbers correctly", () => {
    // => Second test in same describe block
    // => Gets fresh calc instance from beforeEach
 
    const result = calc.divide(10, 2);
    // => Arrange and act in one line
 
    expect(result).toBe(5);
    // => Assert expected result
  });
 
  test("throws error when dividing by zero", () => {
    // => Test error handling behavior
    // => Error cases are important to test
 
    expect(() => calc.divide(10, 0)).toThrow("Divisor cannot be zero");
    // => expect() with arrow function for error testing
    // => toThrow() matcher verifies exception thrown
    // => Can match error message or error class
  });
});
// => End of test suite
// => Jest automatically discovers and runs this file

Density: 22 code lines, 32 annotation lines = 1.45 density (within 1.0-2.25 target)

Running tests:

# Run all tests
npm test
 
# Run with coverage
npm test -- --coverage
 
# Run in watch mode (re-run on file changes)
npm test -- --watch
 
# Run specific test file
npm test calculator.test.ts

Test output:

PASS  src/calculator.test.ts
  Calculator
    āœ“ adds two numbers correctly (2 ms)
    āœ“ divides two numbers correctly (1 ms)
    āœ“ throws error when dividing by zero (3 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.234 s

Jest Matchers

Jest provides rich matchers for various assertion types.

Common matchers:

// Equality matchers
expect(value).toBe(expected);
// => Strict equality (===)
// => Use for primitives (number, string, boolean)
 
expect(value).toEqual(expected);
// => Deep equality for objects/arrays
// => Recursively compares properties
 
expect(value).not.toBe(unexpected);
// => Negation: value should NOT equal unexpected
// => .not works with any matcher
 
// Truthiness matchers
expect(value).toBeTruthy();
// => Value is truthy (not false, 0, '', null, undefined, NaN)
 
expect(value).toBeFalsy();
// => Value is falsy
 
expect(value).toBeNull();
// => Value is exactly null
 
expect(value).toBeUndefined();
// => Value is exactly undefined
 
expect(value).toBeDefined();
// => Value is not undefined
 
// Number matchers
expect(value).toBeGreaterThan(3);
// => Numeric comparison (value > 3)
 
expect(value).toBeGreaterThanOrEqual(3);
// => value >= 3
 
expect(value).toBeLessThan(5);
// => value < 5
 
expect(value).toBeCloseTo(0.3, 5);
// => Floating point comparison (within 5 decimal places)
// => Handles floating point precision issues
 
// String matchers
expect(string).toMatch(/pattern/);
// => String matches regex pattern
 
expect(string).toContain("substring");
// => String contains substring
 
// Array/Set matchers
expect(array).toContain(item);
// => Array includes item
 
expect(array).toHaveLength(3);
// => Array has exactly 3 elements
 
// Object matchers
expect(obj).toHaveProperty("key", value);
// => Object has property with specific value
 
expect(obj).toMatchObject({ key: value });
// => Object contains at least these properties
// => Partial matching (additional properties ignored)
 
// Exception matchers
expect(() => code()).toThrow();
// => Code throws any error
 
expect(() => code()).toThrow(ErrorClass);
// => Code throws specific error class
 
expect(() => code()).toThrow("error message");
// => Code throws error with specific message

Example:

describe("String operations", () => {
  test("reverse preserves length", () => {
    const input = "hello";
    // => Test input string
 
    const result = reverse(input);
    // => Execute function under test
 
    expect(result).toHaveLength(input.length);
    // => Verify length preserved
    // => toHaveLength() matcher for array/string length
 
    expect(result).toBe("olleh");
    // => Verify correct reversal
    // => toBe() for strict string equality
  });
 
  test("palindrome detection", () => {
    expect(isPalindrome("racecar")).toBe(true);
    // => Truthy assertion
    expect(isPalindrome("hello")).toBe(false);
    // => Falsy assertion
    expect(isPalindrome("A man a plan a canal Panama")).toBe(true);
    // => Case-insensitive palindrome
  });
});

Jest Mocking

Jest provides powerful mocking capabilities for isolating code under test.

Mock functions:

import { sendEmail } from "./email-service";
// => Import real email service
 
jest.mock("./email-service");
// => jest.mock() replaces module with mocks
// => All exports become mock functions
// => No real email sent during tests
 
const mockSendEmail = sendEmail as jest.MockedFunction<typeof sendEmail>;
// => Type-safe mock assertion
// => Cast to jest.MockedFunction for type safety
 
describe("User registration", () => {
  beforeEach(() => {
    mockSendEmail.mockClear();
    // => Clear mock call history before each test
    // => Prevents test interdependencies
  });
 
  test("sends welcome email on registration", async () => {
    mockSendEmail.mockResolvedValue({ success: true });
    // => Mock successful email sending
    // => mockResolvedValue() for async functions returning Promise
 
    const user = await registerUser("john@example.com");
    // => Execute registration logic
    // => Email service called internally
 
    expect(mockSendEmail).toHaveBeenCalledWith("john@example.com", "Welcome!");
    // => Verify email service called with correct arguments
    // => toHaveBeenCalledWith() checks function call parameters
 
    expect(mockSendEmail).toHaveBeenCalledTimes(1);
    // => Verify email sent exactly once
    // => Prevents duplicate emails
  });
 
  test("handles email failure gracefully", async () => {
    mockSendEmail.mockRejectedValue(new Error("SMTP connection failed"));
    // => Mock email sending failure
    // => mockRejectedValue() for async functions throwing errors
 
    await expect(registerUser("john@example.com")).rejects.toThrow("Email failed");
    // => Verify registration handles email errors
    // => rejects.toThrow() for async error testing
  });
});

Mock implementations:

const mockCalculate = jest.fn((a: number, b: number) => a + b);
// => jest.fn() creates mock function
// => Can provide implementation as parameter
// => Tracks calls, arguments, return values
 
mockCalculate(2, 3);
// => Call mock function
mockCalculate(5, 7);
// => Call again with different arguments
 
expect(mockCalculate).toHaveBeenCalledTimes(2);
// => Verify called twice
expect(mockCalculate.mock.calls[0]).toEqual([2, 3]);
// => Verify first call arguments
// => mock.calls array stores all call arguments
expect(mockCalculate.mock.results[0].value).toBe(5);
// => Verify first call return value
// => mock.results array stores all return values

Jest Coverage Reporting

Jest generates comprehensive code coverage reports.

Running with coverage:

# Run tests with coverage
npm test -- --coverage

Coverage output:

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |   95.12 |    90.48 |     100 |   95.12 |
calculator|   95.12 |    90.48 |     100 |   95.12 |
 index.ts |   95.12 |    90.48 |     100 |   95.12 | 23,45
----------|---------|----------|---------|---------|-------------------

Coverage types:

  • Statement coverage: Percentage of statements executed
  • Branch coverage: Percentage of if/else branches executed
  • Function coverage: Percentage of functions called
  • Line coverage: Percentage of lines executed

Coverage enforcement: Jest fails build if coverage below threshold (configured in jest.config.js).

Production Testing with Vitest

Vitest is a blazing fast testing framework designed for Vite projects with Jest-compatible API.

Installing Vitest

Install Vitest with TypeScript support.

Installation:

# Install Vitest
npm install --save-dev vitest

Configuration (vite.config.ts):

import { defineConfig } from "vitest/config";
// => Import Vitest configuration helper
// => Type-safe configuration object
 
export default defineConfig({
  test: {
    // => Test configuration block
    globals: true,
    // => Enable global test APIs (describe, test, expect)
    // => No need to import from vitest
    environment: "node",
    // => Use Node.js environment
    // => Alternative: 'jsdom' for browser-like environment
    coverage: {
      provider: "v8",
      // => Use V8 coverage provider (faster)
      // => Alternative: 'istanbul' for more detailed reports
      reporter: ["text", "json", "html"],
      // => Coverage report formats
    },
  },
});
// => Export Vite configuration with test settings

Vitest API (Jest-compatible)

Vitest provides Jest-compatible API with some enhancements.

Pattern:

import { describe, test, expect, beforeEach } from "vitest";
// => Import test utilities from vitest
// => Same API as Jest for easy migration
import { Calculator } from "./calculator";
 
describe("Calculator", () => {
  let calc: Calculator;
 
  beforeEach(() => {
    calc = new Calculator();
  });
 
  test("adds numbers", () => {
    expect(calc.add(2, 3)).toBe(5);
    // => Same expect API as Jest
  });
 
  test("concurrent test execution", { concurrent: true }, () => {
    // => Vitest supports concurrent test execution
    // => { concurrent: true } runs tests in parallel
    expect(calc.add(1, 1)).toBe(2);
  });
});

Running tests:

# Run tests
npm test
 
# Run in watch mode (default in Vitest)
npm test
 
# Run with coverage
npm test -- --coverage

Vitest Advantages

Vitest provides performance and developer experience improvements over Jest.

Key advantages:

  • Fast: Native ESM support, HMR-like test re-runs
  • Vite integration: Shares Vite config, plugins, and transformations
  • Jest-compatible: Easy migration from Jest
  • Watch mode: Intelligent test re-runs based on file changes
  • Component testing: Built-in support for UI component testing

Performance comparison:

Jest:   ~2-3s test startup time
Vitest: ~200ms test startup time (10-15x faster)

Choosing a Testing Framework

Different frameworks suit different project needs.

Framework Comparison

Featureassert moduleJestVitestMocha
InstallationBuilt-innpm installnpm installnpm install
Config complexityNoneMediumLowLow
Test runnerManualāœ… Automaticāœ… Automaticāœ… Automatic
AssertionsBasicāœ… Richāœ… RichSeparate lib
MockingNoneāœ… Built-ināœ… Built-inSeparate lib
CoverageNoneāœ… Built-ināœ… Built-inSeparate lib
TypeScriptāœ… Nativets-jestāœ… Nativets-node
SpeedFastMediumāœ… Very fastFast
Watch modeNoneāœ… Goodāœ… Excellentāœ… Good
Parallel testsNoāœ… Yesāœ… Yesāœ… Yes
Snapshot testingNoāœ… Yesāœ… YesNo
Learning curveLowMediumLowLow
EcosystemNoneāœ… HugeGrowingāœ… Mature

When to Use Each

assert module:

  • āœ… Learning testing fundamentals
  • āœ… Simple scripts and utilities
  • āœ… Runtime assertions in production code
  • āŒ Production test suites

Jest:

  • āœ… React applications (React Testing Library integration)
  • āœ… Full-featured testing (mocking, coverage, snapshots)
  • āœ… Established ecosystem with many resources
  • āœ… Zero-config setup
  • āš ļø Slower test startup compared to Vitest

Vitest:

  • āœ… Vite-based projects
  • āœ… Maximum speed and developer experience
  • āœ… Jest migration (compatible API)
  • āœ… Modern ESM-first approach
  • āš ļø Newer ecosystem (fewer resources)

Mocha:

  • āœ… Flexible, composable testing
  • āœ… Choose your own assertion/mocking libraries
  • āœ… Lightweight and minimal
  • āš ļø More configuration needed
  • āš ļø No built-in assertions or mocking

TDD Best Practices

Effective TDD requires discipline and good practices.

Write Tests First

Always write tests before implementation code.

Anti-pattern (āŒ):

// Write implementation first
function calculateDiscount(price: number, percent: number): number {
  return price * (1 - percent / 100);
}
 
// Then write tests
test("calculates discount", () => {
  expect(calculateDiscount(100, 10)).toBe(90);
});

Best practice (āœ…):

// Write test first (RED)
test("calculates 10% discount correctly", () => {
  expect(calculateDiscount(100, 10)).toBe(90);
  // => Test fails - function doesn't exist yet
});
 
// Write minimal implementation (GREEN)
function calculateDiscount(price: number, percent: number): number {
  return price * (1 - percent / 100);
  // => Simplest code to make test pass
}
 
// Refactor if needed
function calculateDiscount(price: number, percent: number): number {
  if (price < 0 || percent < 0 || percent > 100) {
    throw new Error("Invalid input");
    // => Add validation after basic functionality works
  }
  return price * (1 - percent / 100);
}

Test One Thing Per Test

Each test should verify a single behavior.

Anti-pattern (āŒ):

test("calculator operations", () => {
  expect(calc.add(2, 3)).toBe(5);
  expect(calc.subtract(5, 2)).toBe(3);
  expect(calc.multiply(2, 3)).toBe(6);
  expect(calc.divide(6, 2)).toBe(3);
  // => Multiple behaviors in one test
  // => Hard to identify which operation fails
});

Best practice (āœ…):

test("adds two numbers", () => {
  expect(calc.add(2, 3)).toBe(5);
  // => Single behavior tested
});
 
test("subtracts two numbers", () => {
  expect(calc.subtract(5, 2)).toBe(3);
  // => Separate test for separate behavior
});
 
test("multiplies two numbers", () => {
  expect(calc.multiply(2, 3)).toBe(6);
});
 
test("divides two numbers", () => {
  expect(calc.divide(6, 2)).toBe(3);
});

Use Descriptive Test Names

Test names should clearly describe what they verify.

Anti-pattern (āŒ):

test('test1', () => { ... });
test('works', () => { ... });
test('calculator', () => { ... });
// => Uninformative names
// => Hard to understand test purpose

Best practice (āœ…):

test('adds two positive numbers correctly', () => { ... });
test('throws error when dividing by zero', () => { ... });
test('returns empty array for invalid input', () => { ... });
// => Descriptive names explain expected behavior
// => Test reports become documentation

Arrange-Act-Assert Pattern

Structure tests with clear sections.

Pattern:

test("user registration creates new user", async () => {
  // ARRANGE - Set up test data and dependencies
  const userData = {
    email: "john@example.com",
    password: "secure123",
  };
  const mockDatabase = createMockDatabase();
  // => Prepare test fixtures and mocks
 
  // ACT - Execute the code under test
  const result = await registerUser(userData, mockDatabase);
  // => Single action being tested
 
  // ASSERT - Verify expected outcomes
  expect(result.id).toBeDefined();
  expect(result.email).toBe("john@example.com");
  expect(mockDatabase.insert).toHaveBeenCalledTimes(1);
  // => Multiple assertions about single action
});

Keep Tests Independent

Tests should not depend on each other or shared state.

Anti-pattern (āŒ):

let userId: string;
// => Shared state between tests
 
test("creates user", async () => {
  const user = await createUser("john@example.com");
  userId = user.id;
  // => Stores state for next test
});
 
test("updates user", async () => {
  await updateUser(userId, { name: "John" });
  // => Depends on previous test's state
  // => Fails if tests run in different order
});

Best practice (āœ…):

describe("User operations", () => {
  let testUserId: string;
 
  beforeEach(async () => {
    const user = await createUser("john@example.com");
    testUserId = user.id;
    // => Each test gets fresh user
    // => Tests can run in any order
  });
 
  afterEach(async () => {
    await deleteUser(testUserId);
    // => Clean up after each test
  });
 
  test("updates user name", async () => {
    await updateUser(testUserId, { name: "John" });
    const updated = await getUser(testUserId);
    expect(updated.name).toBe("John");
    // => Self-contained test
  });
});

Trade-offs and When to Use

Understanding when to adopt production testing frameworks.

Standard Library (assert module)

Use when:

  • āœ… Learning testing concepts
  • āœ… Simple scripts (one-off utilities)
  • āœ… Runtime assertions in production code

Avoid when:

  • āŒ Building production applications
  • āŒ CI/CD pipelines (need reporting)
  • āŒ Team projects (need consistency)

Trade-offs:

  • Pros: Zero dependencies, simple, built-in
  • Cons: No tooling, no organization, no reporting

Production Frameworks (Jest/Vitest)

Use when:

  • āœ… Production applications
  • āœ… CI/CD pipelines
  • āœ… Team projects
  • āœ… Need mocking and coverage

Trade-offs:

  • Pros: Full-featured, great DX, ecosystem
  • Cons: External dependency, configuration needed

Decision matrix:

Project TypeRecommended FrameworkRationale
Learningassert moduleUnderstand fundamentals first
Vite projectVitestSpeed, Vite integration
React appJestEcosystem, React Testing Library
Node.js APIJest or VitestBoth excellent
LibraryVitestFast, modern
Legacy projectJestMature, proven

Last updated February 6, 2026

Command Palette

Search for a command to run...