2026-04-05

In this post, we'll set up a fast, clear unit testing workflow for a TypeScript project using Jest, and then walk through patterns you'll use every day: mocking dependencies, testing async code and timers, taming randomness, tracking coverage, and running everything in CI.

Why this matters

  • Catches regressions before they reach users
  • Encourages small, composable functions
  • Documents behavior via examples
  • Enables fearless refactoring

Prerequisites

  • Node.js 18+ (for modern JS features)
  • npm (or pnpm/yarn; commands below use npm)

1) 5‑minute setup

Initialize a project and install dev dependencies:

mkdir ts-jest-demo && cd ts-jest-demo
npm init -y
npm i -D typescript @types/node jest ts-jest @types/jest
npx tsc --init

tsconfig.json (keep it simple and CommonJS to avoid ESM friction):

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "outDir": "dist",
    "types": ["node", "jest"]
  },
  "include": ["src"]
}

jest.config.js:

/** @type {import('jest').Config} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  clearMocks: true,
  collectCoverage: true,
  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/__tests__/**'],
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html'],
  coverageThreshold: {
    global: { lines: 90, statements: 90, branches: 80, functions: 90 }
  },
  moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
  testMatch: ['**/?(*.)+(spec|test).ts']
};

package.json scripts:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage"
  }
}

First testable code: src/math/sum.ts

export const sum = (a: number, b: number) => a + b;

Test: src/math/sum.test.ts

import { sum } from './sum';

test('adds two numbers', () => {
  expect(sum(1, 2)).toBe(3);
});

Run tests:

npm test

2) Mock dependencies to isolate behavior

Create a tiny service that depends on a repository. We’ll test the service in isolation by mocking the repo.

src/userRepo.ts

export type User = { id: string; name: string };

// Imagine this hits a DB. The real impl doesn't matter for the service test.
export const getUserById = async (id: string): Promise<User | null> => {
  // Placeholder to suggest I/O
  return id === '1' ? { id: '1', name: 'Ana' } : null;
};

src/userService.ts

import { getUserById } from './userRepo';

export const getDisplayName = async (id: string): Promise<string> => {
  const user = await getUserById(id);
  if (!user) throw new Error('NOT_FOUND');
  return user.name.toUpperCase();
};

src/userService.test.ts

// Auto-mock only what we import from './userRepo'
jest.mock('./userRepo', () => ({ getUserById: jest.fn() }));

import { getUserById } from './userRepo';
import { getDisplayName } from './userService';

const mockGetUserById = getUserById as jest.MockedFunction<typeof getUserById>;

describe('getDisplayName', () => {
  it('returns uppercase name for existing user', async () => {
    mockGetUserById.mockResolvedValueOnce({ id: '1', name: 'Ana' });
    await expect(getDisplayName('1')).resolves.toBe('ANA');
  });

  it('throws on missing user', async () => {
    mockGetUserById.mockResolvedValueOnce(null);
    await expect(getDisplayName('42')).rejects.toThrow('NOT_FOUND');
  });
});

Tips:

  • Mock at the module boundary your unit depends on; leave the unit under test unmocked.
  • Prefer “arrange-act-assert” structure for test clarity.

3) Test async code and timers

Sometimes your code waits. Use fake timers to skip real time.

src/timers.ts

export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
export const doubleAfter = async (x: number, ms: number) => {
  await delay(ms);
  return x * 2;
};

src/timers.test.ts

import { doubleAfter } from './timers';

// Modern Jest provides async helper APIs for fake timers
jest.useFakeTimers();

test('doubleAfter waits then doubles', async () => {
  const promise = doubleAfter(2, 1000);
  await jest.advanceTimersByTimeAsync(1000);
  await expect(promise).resolves.toBe(4);
});

Notes:

  • Use jest.useFakeTimers() at file or describe level.
  • Prefer advanceTimersByTimeAsync/runAllTimersAsync with async code.

4) Tame randomness with dependency injection

If a function uses Math.random directly, results are flaky. Inject an RNG instead.

src/id.ts

export type RNG = () => number;

export const generateId = (rng: RNG = Math.random): string => {
  // 1,000,000 possibilities, base36 for compactness
  return Math.floor(rng() * 1_000_000).toString(36);
};

src/id.test.ts

import { generateId } from './id';

const fixed = () => 0.5; // Always returns the same number

test('generateId is deterministic with injected RNG', () => {
  expect(generateId(fixed)).toBe(Math.floor(0.5 * 1_000_000).toString(36));
});

Pattern:

  • Accept collaborators (RNG, clock, IO) as parameters with sensible defaults.
  • In tests, pass fakes for determinism.

5) Data builders keep tests readable

Use factories to make data only as specific as the test needs.

src/test/builders.ts

import type { User } from '../userRepo';

let counter = 0;
export const aUser = (overrides: Partial<User> = {}): User => {
  counter += 1;
  return {
    id: `u_${counter}`,
    name: 'Test User',
    ...overrides
  };
};

Usage in a test:

import { aUser } from './test/builders';

test('builder example', () => {
  const u = aUser({ name: 'Chris' });
  expect(u.name).toBe('Chris');
});

6) Coverage you can trust

  • Already enabled in jest.config.js via collectCoverage and thresholds.
  • Run coverage locally:
npm run test:cov
  • Aim for meaningful coverage: cover important branches and invariants, not just lines.

7) Wire up CI (GitHub Actions)

.github/workflows/test.yml

name: CI
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test -- --ci
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-lcov
          path: coverage/lcov.info

8) Make tests fast and reliable

  • Keep units pure; isolate I/O behind small modules you can mock.
  • Prefer many small, focused tests over large integration-style tests in Jest; use a few higher-level integration/e2e tests elsewhere.
  • Avoid real network and real time in unit tests.
  • Use watch mode during development: npm run test:watch.
  • Name tests after behavior (“returns uppercase name”) not implementation (“calls toUpperCase”).

9) Optional: property-based testing

For certain invariants, property testing can reveal edge cases.

npm i -D fast-check

src/math/sum.prop.test.ts

import fc from 'fast-check';
import { sum } from './sum';

test('sum is commutative', () => {
  fc.assert(
    fc.property(fc.integer(), fc.integer(), (a, b) => {
      return sum(a, b) === sum(b, a);
    })
  );
});

Summary checklist

  • Jest + TypeScript configured with coverage thresholds
  • Unit boundaries enforced via module mocks
  • Async and timers tested with fake timers
  • Randomness/time injected for determinism
  • Data builders for readable tests
  • CI workflow running tests on each PR

This is a test you can trust: fast, deterministic, and focused on behavior. Happy testing!