Skip to content
End To End Tester
Languagespractical

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

json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "typecheck": "tsc --noEmit",
    "e2e": "playwright test"
  }
}
javascript
// 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

typescript
// 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}` };
  }
}
typescript
// 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
typescript
// 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:

typescript
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

typescript
// 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:

typescript
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:

typescript
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>>();
});
typescript
// @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:

typescript
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 ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?