Chapter 9: Testing and Types
Types reduce whole classes of bugs; tests prevent regressions and document intent. Together they create a fast, confident workflow where small edits stay safe, and large refactors remain predictable. This chapter shows how to choose a runner, wire configuration for TypeScript, write type aware tests and safe test doubles, use types as executable specifications, and measure coverage so quality does not drift.
Choosing a test runner and configuration
Your test runner should feel invisible during day to day work. The three most common choices in the TypeScript and Node ecosystem are jest, vitest, and the built in node:test module. Each can run TypeScript through a transformer or a just in time compiler; each can also run the JavaScript output produced by tsc. The best choice depends on the mix of speed, features, and ecosystem plugins you need.
Feature comparison
This quick table highlights common selection criteria. Treat it as a starting point rather than a rule.
| Runner | Philosophy | TS strategy | Mocking | Coverage | Watch mode |
vitest |
Fast unit tests in Node or browser like environments | Transform with esbuild or tsconfig.paths; also runs JS built by tsc |
Built in vi.fn, spies, timers |
V8 coverage via --coverage |
Yes; smart reruns |
jest |
Batteries included test platform | Transform via ts-jest or babel-jest; also runs JS built by tsc |
Built in jest.fn, module mocking |
Istanbul with configuration | Yes; filters and patterns |
node:test |
Minimal core module for simple tests | Run JS output from tsc; pair with tsx or ts-node if needed |
Manual or library based | Via c8 or Node flags |
Yes; simple re runs with external tools |
Whichever runner you choose, keep TypeScript compilation simple. Either compile once with tsc -p tsconfig.json then run tests against dist, or rely on a fast transformer and keep tsconfig.json aligned with your test environment.
Project layout and tsconfig.json
Place tests beside source files or under a tests directory; both patterns work as long as file globs are clear. Add a small test specific tsconfig that extends the main one so editor tooling knows about globals and paths.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node", "vitest/globals"],
"noEmit": true
},
"include": ["src", "tests"]
}
For jest change the types entry to include jest. For node:test keep only node and ensure the test runner loads files from your built output directory when you compile first.
Minimal configurations
These examples assume ESM with path mapping. Adjust the parts inside braces as needed; unknown sections are shown as ….
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['src/**/*.test.ts'],
environment: 'node'
},
resolve: { alias: { '@': new URL('./src', import.meta.url).pathname } }
});
// jest.config.ts
import type { Config } from '@jest/types';
const config: Config.InitialOptions = {
testMatch: ['<rootDir>/src/**/*.test.ts'],
transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }] },
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' }
};
export default config;
// package.json
{
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest",
"test:watch": "vitest --watch",
"coverage": "vitest --coverage"
}
}
target and module in tsconfig.json compatible with your runtime. If Node executes native ESM then set "module": "esnext" and point the runner at ESM output or a transformer that understands ESM.
Type aware tests
Type aware tests fail fast when public shapes drift. You can assert value behavior with the runner, and assert type intent with compile time checks that run alongside tests. Use helpers that encode contracts as types and keep doubles aligned with the interfaces they pretend to implement.
Asserting types during tests
Two practical patterns exist. First, compile test files so any misuse of APIs fails the build. Second, add explicit type assertions inside tests using utilities such as expectTypeOf in vitest or small custom helpers that force compile time evaluation.
// sample.spec.ts
import { expect, describe, it, expectTypeOf } from 'vitest';
import { makeUser } from '@/user';
describe('makeUser', () => {
it('returns an immutable user', () => {
const u = makeUser({ id: 'u1', name: 'Ada' } as const);
expect(u.id).toBe('u1');
expectTypeOf(u).toMatchTypeOf<{ id: string; name: string }>();
// @ts-expect-error: name is readonly
// u.name = 'Grace';
});
});
If you prefer framework neutral checks, use @ts-expect-error and satisfies so the compiler enforces intent without extra libraries.
// types-only checks compile or fail
type User = { id: string; name: string };
const good = { id: '1', name: 'Ada' } satisfies User;
// @ts-expect-error: missing name
const bad: User = { id: '2' } as any;
@ts-expect-error instead of @ts-ignore. It tells the compiler an error is expected and fails if the error disappears, which protects intent during refactors.
Designing safe test doubles
Spies and fakes should match the real interface so production code and test code evolve together. Represent the dependency as a TypeScript interface, then implement a minimal fake in tests that satisfies the interface. This avoids casting and keeps editor completion accurate.
// service.ts
export interface Mailer {
send(to: string, subject: string, body: string): Promise<void>;
}
export class Notifier {
constructor(private mailer: Mailer) {}
async welcome(email: string) {
await this.mailer.send(email, 'Welcome', 'Thanks for joining');
}
}
// service.spec.ts (vitest)
import { describe, it, expect, vi } from 'vitest';
import { Notifier, type Mailer } from './service';
class FakeMailer implements Mailer {
sent: Array<[string, string, string]> = [];
async send(to: string, subject: string, body: string) {
this.sent.push([to, subject, body]);
}
}
describe('Notifier', () => {
it('sends a welcome email', async () => {
const mailer = new FakeMailer();
const n = new Notifier(mailer);
await n.welcome('a@example.com');
expect(mailer.sent[0]?.[1]).toBe('Welcome');
});
it('can spy on a single call', async () => {
const mailer: Mailer = { send: vi.fn().mockResolvedValue() };
const n = new Notifier(mailer);
await n.welcome('a@example.com');
expect(mailer.send).toHaveBeenCalledOnce();
});
});
satisfies or explicit types so the fake breaks when the real contract changes.
Property based checks
For pure functions, property based strategies generate many inputs and assert invariants. Libraries can feed random data into your functions so a small test describes a large input space. When pairing this with TypeScript, define the generator’s output type to match the function’s input type so the compiler assists discovery.
// example with a hypothetical fc library
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
const reverse = (s: string) => [...s].reverse().join('');
describe('reverse', () => {
it('is its own inverse', () => {
fc.assert(fc.property(fc.string(), s => {
expect(reverse(reverse(s))).toBe(s);
}));
});
});
Contract tests with types as specifications
Public types are living documentation. You can turn them into contract tests by checking that implementations conform at compile time, and by validating real data at runtime using the same source of truth. The key is to avoid duplication so type level promises and runtime checks stay aligned.
Compile time conformance
Expose an interface or type in your package and require implementations to satisfy it during the build. A small test-contract file can import an implementation and fail compilation when the shape drifts.
// contract.ts in a shared library
export interface PaymentProvider {
charge(amountCents: number, token: string): Promise<{ id: string }>;
}
// contract.spec.types.ts used only for type checking
import type { PaymentProvider } from '@acme/contracts';
import { StripeProvider } from '@/stripe';
const provider: PaymentProvider = new StripeProvider(); // fails if methods or types differ
void provider;
Run tsc --noEmit against the contract tests in CI. No runtime is required, yet breaking changes surface immediately.
Runtime validation from a single source
Types alone cannot reject malformed input at the boundary. Generate validators from type like declarations so inbound data is checked at runtime while editors still see rich types. Schema libraries provide this bridge.
// schema.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email()
});
export type User = z.infer<typeof UserSchema>;
// handler.ts
import type { User } from './schema';
import { UserSchema } from './schema';
export function handle(input: unknown): User {
const user = UserSchema.parse(input);
return user;
}
src/contracts and export both the schema and its inferred type. Application code imports the type for compile time safety and uses the schema at the boundary for runtime checks.
Consumer driven contracts
When multiple services interact, write minimal examples that represent how consumers call providers. Store these examples as JSON and validate them against shared schemas during CI so providers cannot ship a change that violates a consumer’s expectations.
// example-consumer.json
{ "id": "u_123", "name": "Ada", "email": "ada@example.com" }
// pact-like check …
import { UserSchema } from '@/contracts';
import example from './example-consumer.json' assert { type: 'json' };
UserSchema.parse(example);
Measuring coverage and maintaining confidence
Coverage shows which code executes during tests; confidence comes from what those tests assert. Use coverage to locate blind spots, not as a score to game. Track lines, branches, and functions, then set pragmatic thresholds so the team keeps intent high without chasing perfection.
Source maps and accurate coverage
Enable high quality source maps so coverage points to TypeScript lines rather than transpiled output. Include maps in dev builds, and keep them external in production builds if needed for size.
// tsconfig.json
{
"compilerOptions": {
"sourceMap": true,
"inlineSources": true,
"declarationMap": false
}
}
With vitest pass --coverage to record V8 coverage. With jest enable coverage in configuration. For node:test pair tests with c8 so V8’s native coverage is reported clearly.
// package.json …
{
"scripts": {
"coverage": "vitest --coverage",
"coverage:html": "vitest --coverage --reporter=html"
}
}
any casts or unchecked as can make coverage look healthy while meaningful assertions are missing. Prefer precise types and runtime checks at boundaries so coverage reflects real behavior.
Thresholds and what to measure
Set thresholds that encourage intent. Lines and statements prevent dead regions. Branch coverage encourages tests for success and failure paths. Function coverage highlights untested utilities. Keep thresholds slightly below current numbers so improvements land incrementally.
// vitest.config.ts …
export default defineConfig({
test: {
coverage: {
statements: 90,
branches: 85,
functions: 90,
lines: 90,
reporter: ['text', 'html']
}
}
});
Mutation testing for deeper signals
Mutation testing flips operators and changes literals to see whether tests notice. A high mutation score is a strong signal that assertions are specific. Use it sparingly on core modules where confidence matters most; it is slower than regular unit tests.
Continuous integration and fast feedback
Run type checks, unit tests, and coverage in parallel during pull requests. Cache node_modules and the TypeScript build info so rechecks are quick. Keep a short path for local development with watch modes and a longer path in CI that includes contract checks and coverage thresholds.