Writing Tests in TypeScript
The TypeScript testing toolchain — Jest or Vitest, typed doubles, async idioms, and the type-level tricks that make tests both safer and more readable.
1 min read · updated 19 September 2026
TypeScript's type system is the most useful and most obstructive thing about testing in it. Useful, because a double that does not match its interface fails to compile. Obstructive, because satisfying a large interface for a test that uses one method of it is tedious.
#The toolchain
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"typecheck": "tsc --noEmit",
"e2e": "playwright test"
}
}// jest.config.js — transform, do not type-check, in the hot path
module.exports = {
transform: { '^.+\\.(t|j)sx?$': '@swc/jest' },
moduleNameMapper: { '^@/(.*)$': '<rootDir>/$1' },
testEnvironment: 'node',
clearMocks: true,
restoreMocks: true
};Splitting transform from type-checking is the most valuable configuration
decision here. ts-jest type-checks every file on every run, which is a
large recurring cost for a check that tsc --noEmit performs once, faster,
in parallel with everything else in CI.
Vitest is the better choice if the project already uses Vite; the test bodies are effectively identical.
#Typed doubles
// A hand-rolled fake. The compiler enforces the contract — when
// PaymentGateway gains a method, this fails to compile, which is exactly
// the notification you want.
class FakeGateway implements PaymentGateway {
readonly charges: ChargeRequest[] = [];
private nextFailure: Error | null = null;
failNext(error: Error) { this.nextFailure = error; }
async charge(request: ChargeRequest): Promise<ChargeResult> {
if (this.nextFailure) {
const failure = this.nextFailure;
this.nextFailure = null;
throw failure;
}
this.charges.push(request);
return { ok: true, id: `pi_${this.charges.length}` };
}
}// Typed module mocks
import { fetchGbpRate } from './rates-client';
jest.mock('./rates-client');
const mockRate = fetchGbpRate as jest.MockedFunction<typeof fetchGbpRate>;
mockRate.mockResolvedValue(0.79); // type error if 0.79 is not the return type// A partial double, when the interface is large and the test uses two methods
const repository = {
find: jest.fn().mockResolvedValue(order),
save: jest.fn()
} as unknown as OrderRepository;That last cast is a deliberate escape hatch, and it is where type safety
leaks: nothing then checks that find still exists on OrderRepository.
Prefer the fake class when the double is used in more than a couple of
tests.
#Test data builders
Partial overrides with full type safety — the pattern from test data management, typed:
export const anOrder = (overrides: Partial<Order> = {}): Order => ({
id: crypto.randomUUID(),
customer: aCustomer(),
lines: [aLine()],
status: 'reserved',
placedAt: new Date('2026-01-01T12:00:00Z'),
...overrides
});
// Deep partial, when nested objects need overriding too
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };#Async
// Resolution and rejection
await expect(client.gbpRate()).resolves.toBe(0.79);
await expect(client.gbpRate()).rejects.toThrow(RateLimitError);
await expect(client.gbpRate()).rejects.toMatchObject({ retryAfterSeconds: 60 });
// Concurrency — the test worth writing and rarely written
it('processes the same message twice without double-charging', async () => {
const [first, second] = await Promise.all([
handler.handle(message),
handler.handle(message) // same idempotency key
]);
expect(gateway.charges).toHaveLength(1);
expect(first.orderId).toBe(second.orderId);
});A missing await on an expect(...).rejects produces a test that passes
regardless. eslint-plugin-jest's require-await and
no-floating-promises in @typescript-eslint both catch it; both are worth
enabling.
#Discriminated unions
TypeScript's union narrowing makes a particular kind of test very clean:
type Result =
| { ok: true; order: Order }
| { ok: false; reason: 'declined' | 'insufficient-stock' };
it('reports a declined card without creating an order', async () => {
gateway.failNext(new CardDeclined());
const result = await checkout.pay(basket);
expect(result.ok).toBe(false);
if (result.ok) throw new Error('unreachable'); // narrows for the compiler
expect(result.reason).toBe('declined'); // result.reason is typed
expect(await orders.count()).toBe(0);
});Modelling outcomes as a union rather than throwing makes both the production code and the tests more direct. It is a small design decision with an outsized effect on how readable the error-path tests are.
#Testing the types themselves
For a published library, the types are part of the contract:
import { expectTypeOf } from 'expect-type'; // or vitest's built-in
it('infers the element type from the schema', () => {
const parsed = parse(orderSchema, raw);
expectTypeOf(parsed).toEqualTypeOf<Order>();
expectTypeOf(parse).parameter(0).toMatchTypeOf<Schema<unknown>>();
});// @ts-expect-error is a test: it fails if the line stops being an error.
// @ts-expect-error quantity must be a number
anOrder({ lines: [{ sku: 'a', quantity: 'two' }] });#End to end
Playwright is TypeScript-first and shares the project's
tsconfig, so page objects, API helpers and fixtures are all typed against
the same domain model the application uses:
export const test = base.extend<{ customer: Customer }>({
customer: async ({ request }, use, testInfo) => {
const customer = await createCustomer(request, {
email: `buyer-${testInfo.workerIndex}@example.test`
});
await use(customer);
}
});That shared model is the quiet advantage of writing the suite in the same
language as the application: a change to Customer breaks the tests at
compile time rather than at 2am.
Common questions
- Should I type-check my tests?
- Yes, with tsc --noEmit as a separate CI step rather than inside the test runner. Type checking during the transform step slows every run; as a separate job it is fast, parallel and catches the same problems.
- How do I type a mock in TypeScript?
- jest.Mocked<T> or vi.Mocked<T> for a whole module, jest.MockedFunction<typeof fn> for a single function. For hand-rolled doubles, implementing the interface directly gives the best errors — the compiler tells you when the real interface changes.
- Do I need ts-jest?
- Only if you want type errors to fail the test run. @swc/jest or babel-jest strip types far faster and leave checking to tsc, which is the better split for anything but a small project.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/languages/typescript
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- JestThe default JavaScript test runner — configuration, projects, snapshot testing, fake timers, and the choices that keep a large Jest suite fast.
- VitestA Vite-native test runner with a Jest-compatible API — faster startup, native ESM and TypeScript, and the cases where it is the better default.
- PlaywrightThe default browser automation tool in 2026 — auto-waiting locators, tracing, sharding and fixtures — with the configuration that matters and the mistakes that still cause flakiness.
- Jest MockingModule mocks, spies, manual mocks and timer control — how Jest's mocking works, and the patterns that keep it from taking over a suite.
- Testing ReactComponents, hooks, context, server components and async state — what to test in a React application, what to leave alone, and how to avoid act warnings.
- Writing Tests in C#The .NET testing stack — xUnit, Moq or NSubstitute, WebApplicationFactory, Testcontainers — and the dependency injection story that makes it the most testable of the four.
- Writing Tests in Pythonpytest fixtures, the patching rules that catch everyone, async testing, and the toolchain for a Python project that has to hold up in CI.