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!