<< Home

Chapter 28: Testing in TypeScript for Node

Testing turns a codebase from a fragile puzzle into a predictable machine. With TypeScript you also gain a layer of static assurance that pairs well with runtime checks. This chapter focuses on practical tools and patterns for fast, reliable feedback as you build services, libraries, and command line tools.

Jest, Vitest, Node test runner

Most TypeScript projects settle on one of three runners. Jest offers a full ecosystem with mocks and global APIs. Vitest stays close to Jest but runs inside Vite, which makes it fast and lightweight. The built in Node test runner provides minimalism for projects that avoid heavy frameworks. All three can run TypeScript either through ts-node, compilation, or an integrated transformer.

Jest with TypeScript

Jest gives you an expressive test vocabulary and snapshot capabilities. Pair it with ts-jest or a transform so you can run TypeScript without building first. Keep configuration small; most setups work with a few lines.

// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node'
};

export default config;
// sum.test.ts
import { sum } from './sum';

test('adds two numbers', () => {
  expect(sum(2, 3)).toBe(5);
});
💡 If startup time matters, run Jest in watch mode so only touched files trigger new runs.

Vitest for speed

Vitest shares Jest like syntax but keeps boot time low. It integrates naturally with modern ESM projects and offers smart caching. Because it uses Vite under the hood, imports work as they do in production.

// example.test.ts
import { expect, test } from 'vitest';
import { greet } from './greet';

test('greets with a name', () => {
  expect(greet('Ada')).toBe('Hello Ada');
});

Node test runner

The Node test runner lives under node:test. It offers structure without extra layers. This suits small libraries or projects that already rely on native Node facilities. Assertions come from node:assert by default.

// math.test.ts
import test from 'node:test';
import assert from 'node:assert/strict';
import { div } from './math';

test('safe division', () => {
  assert.equal(div(6, 3), 2);
});
⚠️ The Node test runner does not include automatic mocking, so you must use hand rolled fakes or a helper library if you need controlled behaviours.

Type aware mocks and spies

Mocks give you controlled stand ins for dependencies. With TypeScript you can use interfaces to express expectations and let your mocks track calls without drifting from the real contract. A well typed mock prevents accidental mismatches and encourages small, clear interfaces.

Mocking functions safely

A simple mock records calls and returns predictable values. Capture the generic signature so your helper mirrors the real function type.

function createMockFn<T extends (...args: any[]) => any>() {
  const calls: Array<Parameters<T>> = [];
  let impl: ((...a: Parameters<T>) => ReturnType<T>) | null = null;

  function fn(...args: Parameters<T>): ReturnType<T> {
    calls.push(args);
    if (impl) return impl(...args);
    throw new Error('No implementation set');
  }

  fn.setImpl = (f: typeof impl) => (impl = f);
  fn.calls = calls;
  return fn as T & { setImpl: typeof impl; calls: Array<Parameters<T>> };
}

Spying on methods

Spies wrap existing functions rather than replacing them. This is useful when you want to observe behaviour without modifying logic. Vitest and Jest both offer spy helpers, while hand written spies work well in the Node runner.

// spy example with Vitest
import { vi, test, expect } from 'vitest';
import * as fs from 'node:fs';

test('reads a file', () => {
  const spy = vi.spyOn(fs, 'readFileSync');
  fs.readFileSync('some.txt', 'utf8');
  expect(spy).toHaveBeenCalled();
  spy.mockRestore();
});

Mocking ports and adapters

Abstract interfaces make it easy to replace infrastructure during tests. Configure your container or module to inject a fake that records calls. Typed interfaces ensure the fake respects the correct signature.

// billing.port.ts
export interface BillingPort {
  charge(cents: number, source: string): Promise<{ authId: string }>;
}

// fake billing for tests
export class FakeBilling implements BillingPort {
  public calls: Array<[number, string]> = [];
  async charge(cents: number, source: string) {
    this.calls.push([cents, source]);
    return { authId: 'fake_' + source };
  }
}

Property based and contract testing

Property based tests explore input ranges automatically. Instead of checking a single example, you describe an invariant and let the framework generate many cases. Contract tests ensure that interfaces shared between services behave as expected, independent of implementation details.

Property based testing with fast check

Fast check generates random values and shrinks failures. This reveals edge cases that example based tests might miss. Focus on algebraic laws, inverse functions, or behaviours that must hold for all valid inputs.

// reverse(reverse(s)) is s
import { test } from 'vitest';
import fc from 'fast-check';
import { reverse } from './reverse';

test('double reverse returns original', () => {
  fc.assert(fc.property(fc.string(), s => reverse(reverse(s)) === s));
});

Contract tests for API clients

Contract tests confirm that a client obeys the API specification and that the server fulfils it. Use schemas or routers as the source of truth. Test the client against mocked servers that enforce the contract.

// using a schema
import { appRouter } from '../router';
import { z } from 'zod';

const ChargeRequest = z.object({
  cents: z.number().int().positive(),
  source: z.string()
});

// thin contract test
function validateRequest(input: unknown) {
  return ChargeRequest.parse(input);
}

Event contract and versioning tests

Event contracts must stay stable over time. Write tests that ensure older versions can still be read and that new consumers tolerate extra fields. Keep fixture events in version numbered files to prevent accidental shape drift.

// upcast test
import { upcastToV2 } from './upcast';

const legacy = { orderId: '1', totalCents: 500 };
const upgraded = upcastToV2(legacy);

if (upgraded.currency !== 'USD') throw new Error('Upcast failed');
💡 Treat contracts as public APIs. Once published, changes require discipline and tests that defend against accidental breakage.

CI pipelines and fast feedback

A healthy test suite runs quickly and fails loudly. The faster you catch mistakes, the more confidently you can change code. CI pipelines should be clear, repeatable, and tuned for minimal latency.

Parallel and focused runs

CI runners often provide several cores. Configure your runner to split work across workers so long test suites finish sooner. Also use path filters to skip jobs when irrelevant files change.

// GitHub Actions sketch
name: ci
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npm test --workspaces

Caching dependencies and builds

Cache node_modules or the output of your package manager to reduce install time. Cache tsc output when using project references. The faster CI can rebuild only what changed, the more often you can push without delay.

Static checks in CI

Static checks catch issues before runtime tests run. Include tsc --noEmit and linters early in the pipeline. Failing early saves cycles and keeps logs shorter.

// package.json scripts
{
  "scripts": {
    "check": "tsc --noEmit && eslint .",
    "test": "vitest run"
  }
}

Coverage and thresholds

Coverage does not measure correctness, but it highlights blind spots. Set thresholds that reflect the criticality of each module. Track coverage trends over time so regressions do not slip past reviews.

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: { provider: 'v8', lines: 85 }
  }
});
⚠️ Slow tests accumulate like heavy luggage. Profile long running tests and remove unnecessary setup. A light suite pays dividends every day.

With a balanced mix of runners, mocks, property tests, and tight CI loops, your TypeScript and Node projects can grow safely and stay enjoyable to maintain.