🚀AdvancedExpert Patterns

TypeScript Testing Patterns

Write type-safe tests with typed mocks, test doubles, utility types for testing, type assertion tests, and patterns for testing TypeScript-specific features.

Overview

Testing TypeScript code has two dimensions: testing runtime behavior (as in any JavaScript project) and testing the types themselves. Both matter. **Runtime testing with TypeScript:** - Typed mocks: avoid using `any` for mock objects - use `Partial<T>` and type-safe factory functions - Type-safe spy/stub patterns that preserve the original function's type signature - Testing generic functions: instantiate with concrete types and assert the results **Type testing (testing types, not values):** - Libraries like `expect-type` and `tsd` let you assert that an expression has a specific type at compile time - `// @ts-expect-error` asserts that the next line IS a type error (tests that invalid usage is rejected) - Conditional type test helpers: `type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false` Well-tested types prevent regressions as your utility types and APIs evolve.

Key Points

  • •Typed mocks: `Partial<Service>` for test doubles; `as jest.Mock` only for jest-specific assertions
  • •`// @ts-expect-error` asserts the next line must be a type error - fails if the line compiles
  • •`tsd` and `expect-type` provide `expectType<T>(value)` compile-time type assertions
  • •Test factory functions: `makeUser(overrides?: Partial<User>): User` for creating test data
  • •Generic test helpers: test a generic function by instantiating it with a specific type
  • •Snapshot types: `type Assert<T extends true> = T` with `type TestA = Assert<Equal<A, B>>`
  • •Test the type of JSX components by checking `React.ComponentProps<typeof MyComp>`

Examples

Type-safe mocks and test data factories

// Instead of 'any', use Partial<T> to build test doubles
interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
  count(): Promise<number>;
}

interface User {
  id: string;
  name: string;
  email: string;
  admin: boolean;
  createdAt: Date;
}

// Type-safe factory for test data - all required fields have defaults
function makeUser(overrides: Partial<User> = {}): User {
  return {
    id:        overrides.id        ?? 'test-user-id',
    name:      overrides.name      ?? 'Test User',
    email:     overrides.email     ?? 'test@example.com',
    admin:     overrides.admin     ?? false,
    createdAt: overrides.createdAt ?? new Date('2024-01-01'),
    ...overrides,
  };
}

// Type-safe mock repository - only implement what the test needs
function makeUserRepo(overrides: Partial<UserRepository> = {}): UserRepository {
  const defaultUser = makeUser();
  return {
    findById:    async (id) => id === defaultUser.id ? defaultUser : null,
    findByEmail: async ()   => defaultUser,
    save:        async ()   => {},
    delete:      async ()   => {},
    count:       async ()   => 1,
    ...overrides,
  };
}

// Usage in tests (framework-agnostic)
async function testRegisterUser() {
  const savedUsers: User[] = [];
  const repo = makeUserRepo({
    save: async (user) => { savedUsers.push(user); },
  });

  const svc = new UserService(repo, new NoOpEmailService(), new ConsoleLogger());
  const newUser = await svc.registerUser('Alice', 'alice@example.com');

  console.assert(newUser.name === 'Alice', 'Name should be Alice');
  console.assert(savedUsers.length === 1, 'Should have saved one user');
  console.assert(savedUsers[0].email === 'alice@example.com', 'Email should match');
}

Testing types with @ts-expect-error and type assertions

// Test that invalid usage is rejected by the type checker

// @ts-expect-error - asserts the NEXT LINE must be a type error
// If the line is valid TS, the @ts-expect-error itself becomes an error
function acceptString(s: string): void {}

// @ts-expect-error
acceptString(42);       // Error: number is not assignable to string
// @ts-expect-error
acceptString(null);     // Error: null is not assignable to string

acceptString('hello');  // No @ts-expect-error needed - this is valid

// Type equality testing with a helper
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends
                   (<T>() => T extends B ? 1 : 2) ? true : false;

type Assert<T extends true> = T;

// These compile successfully - the types ARE equal
type _1 = Assert<Equal<string, string>>;
type _2 = Assert<Equal<{ a: number }, { a: number }>>;

// These fail to compile - the types are NOT equal (catches regressions)
// type _3 = Assert<Equal<string, number>>;        // Error
// type _4 = Assert<Equal<string[], number[]>>;    // Error

// Test that a utility type produces the correct output
type MyPartial<T> = { [K in keyof T]?: T[K] };

type UserPartial = MyPartial<User>;

// Verify specific properties are optional in the result
type _5 = Assert<Equal<UserPartial['name'], string | undefined>>;
type _6 = Assert<Equal<UserPartial['admin'], boolean | undefined>>;

// Verify the keys are the same
type _7 = Assert<Equal<keyof UserPartial, keyof User>>;

Testing generic functions and complex type utilities

// Testing a generic function by instantiating with concrete types
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

// Type tests - these fail to compile if the types are wrong
const strResult  = first(['a', 'b', 'c']);
type _strResult  = Assert<Equal<typeof strResult, string | undefined>>;

const numResult  = first([1, 2, 3]);
type _numResult  = Assert<Equal<typeof numResult, number | undefined>>;

const emptyResult = first([]);
type _emptyResult = Assert<Equal<typeof emptyResult, never | undefined>>;

// Test conditional types produce the correct results
type Flatten<T> = T extends (infer U)[] ? U : T;

type _f1 = Assert<Equal<Flatten<string[]>, string>>;
type _f2 = Assert<Equal<Flatten<number[][]>, number[]>>;
type _f3 = Assert<Equal<Flatten<string>, string>>;    // Non-array passes through

// Test discriminated union narrowing behavior
type Shape =
  | { kind: 'circle';   radius: number }
  | { kind: 'square';   side: number }
  | { kind: 'triangle'; base: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle':   return Math.PI * s.radius ** 2;
    case 'square':   return s.side ** 2;
    case 'triangle': return 0.5 * s.base * s.height;
  }
}

// Test exhaustiveness: if a new Shape is added without updating area(),
// the switch is no longer exhaustive and TypeScript errors on the function

// Runtime test to complement the type test
console.assert(area({ kind: 'square', side: 4 }) === 16, 'Square area');
console.assert(
  Math.abs(area({ kind: 'circle', radius: 1 }) - Math.PI) < 0.001,
  'Circle area'
);

Common Mistakes

  • Using `as any` for test doubles: this defeats type checking in tests. When you change the production interface, the mock silently becomes out of date. Use `Partial<T>` and proper factories instead
  • Leaving `// @ts-expect-error` comments without removing them when the type error is fixed: TypeScript errors on unused `@ts-expect-error` (there is no error to suppress). This is a feature - it catches when your invalid-usage tests start passing by accident
  • Testing implementation details instead of behavior: test what the function does (given these inputs, the output is X), not how it does it (it calls method Y with argument Z). Type-level tests should check output types, not internal intermediate types
  • Not running `tsc --noEmit` in CI: type tests are only checked during type checking. If your CI only runs tests with `tsx` or `ts-jest`, type errors in test files might be silently ignored

Note

The `expect-type` library (from Simone Busoli) provides the most ergonomic type assertion API: `expectTypeOf(value).toEqualTypeOf<ExpectedType>()`. It gives good error messages when types do not match. For small projects, `// @ts-expect-error` and the `Equal<A, B>` helper are enough. For packages you publish, add `tsd` to your CI to catch type regressions across versions.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 2 exercises

Knowledge Check

Question 1 of 4

What does `// @ts-expect-error` do when the following line does NOT have a type error?