DEV Community

Tarun Moorjani
Tarun Moorjani

Posted on

Type-Safe Mocking: Testing Without Lying to TypeScript

I used to write tests like this:

const mockUser: any = { name: 'Test User' };
const mockFetch = jest.fn() as any;
const mockRouter = { push: jest.fn() } as any;
Enter fullscreen mode Exit fullscreen mode

Every as any was a small betrayal of why I chose TypeScript in the first place. My tests would pass, then production would break because my mocks didn't match reality.

The breaking point came when I refactored a User interface, and every test still passed. TypeScript couldn't help me because I'd told it to look away with any. I spent hours finding runtime failures that should have been compile-time errors.

That's when I learned: tests without type safety are just expensive lies.

Let me show you how to write tests where TypeScript actually helps instead of getting in the way.

The Problem: Type Safety Stops at Test Boundaries

Consider this production code:

// api.ts
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

And this test:

// api.test.ts
test('fetchUser returns user', async () => {
  // ❌ Lying to TypeScript
  const mockUser: any = {
    id: '1',
    name: 'Test User',
    // Missing email - test passes but production breaks!
  };

  global.fetch = jest.fn().mockResolvedValue({
    json: () => Promise.resolve(mockUser)
  }) as any;

  const user = await fetchUser('1');
  expect(user.name).toBe('Test User');
});
Enter fullscreen mode Exit fullscreen mode

The bug: email is missing, but TypeScript doesn't catch it because of as any. Change the User interface? Tests still pass. Ship to production? Users see errors.

This is the test equivalent of technical debt.

Pattern 1: Type-Safe Mock Factories

Build mocks that TypeScript can verify.

Basic Mock Factory

// testUtils/factories.ts
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

// ✅ Type-safe factory with defaults
export function createMockUser(overrides?: Partial<User>): User {
  return {
    id: '1',
    name: 'Test User',
    email: 'test@example.com',
    role: 'user',
    createdAt: new Date('2024-01-01'),
    ...overrides,
  };
}

// Usage in tests
test('displays user name', () => {
  const user = createMockUser({ name: 'Alice' });
  // TypeScript knows user is User
  // All required fields are present
  expect(user.name).toBe('Alice');
});

test('handles admin users', () => {
  const admin = createMockUser({ role: 'admin' });
  // ✅ TypeScript validates 'admin' is valid role
  expect(isAdmin(admin)).toBe(true);
});

// ❌ TypeScript prevents invalid data
const badUser = createMockUser({ role: 'superuser' }); // Type error!
Enter fullscreen mode Exit fullscreen mode

Generic Factory Builder

// testUtils/factories.ts
type FactoryFunction<T> = (overrides?: Partial<T>) => T;

function createFactory<T>(defaults: T): FactoryFunction<T> {
  return (overrides = {}) => ({
    ...defaults,
    ...overrides,
  });
}

// Define factories
export const createMockUser = createFactory<User>({
  id: '1',
  name: 'Test User',
  email: 'test@example.com',
  role: 'user',
  createdAt: new Date('2024-01-01'),
});

export const createMockPost = createFactory<Post>({
  id: '1',
  title: 'Test Post',
  content: 'Test content',
  authorId: '1',
  published: false,
  createdAt: new Date('2024-01-01'),
});

export const createMockComment = createFactory<Comment>({
  id: '1',
  postId: '1',
  authorId: '1',
  text: 'Test comment',
  createdAt: new Date('2024-01-01'),
});
Enter fullscreen mode Exit fullscreen mode

Advanced: Builder Pattern for Complex Objects

// testUtils/builders.ts
class UserBuilder {
  private user: User;

  constructor() {
    this.user = {
      id: '1',
      name: 'Test User',
      email: 'test@example.com',
      role: 'user',
      createdAt: new Date('2024-01-01'),
    };
  }

  withId(id: string): this {
    this.user.id = id;
    return this;
  }

  withName(name: string): this {
    this.user.name = name;
    return this;
  }

  withEmail(email: string): this {
    this.user.email = email;
    return this;
  }

  asAdmin(): this {
    this.user.role = 'admin';
    return this;
  }

  createdOn(date: Date): this {
    this.user.createdAt = date;
    return this;
  }

  build(): User {
    return { ...this.user };
  }
}

// Usage - fluent API
test('admin users can delete posts', () => {
  const admin = new UserBuilder()
    .withName('Admin User')
    .asAdmin()
    .build();

  expect(canDelete(admin, post)).toBe(true);
});

// Chain multiple properties
test('specific user scenario', () => {
  const user = new UserBuilder()
    .withId('user-123')
    .withName('Alice')
    .withEmail('alice@example.com')
    .createdOn(new Date('2023-01-01'))
    .build();

  // All fields are properly typed
  expect(processUser(user)).toBeDefined();
});
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Type-Safe API Mocking with MSW

Mock Service Worker (MSW) intercepts requests at the network level with full type safety.

Setting Up MSW with TypeScript

npm install msw --save-dev
Enter fullscreen mode Exit fullscreen mode
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

interface CreateUserRequest {
  name: string;
  email: string;
  role?: 'admin' | 'user';
}

// ✅ Fully typed handlers
export const handlers = [
  // GET user
  http.get<{ id: string }, never, User>(
    '/api/users/:id',
    ({ params }) => {
      const { id } = params;

      return HttpResponse.json({
        id,
        name: 'Test User',
        email: 'test@example.com',
        role: 'user',
      });
    }
  ),

  // POST create user
  http.post<never, CreateUserRequest, User>(
    '/api/users',
    async ({ request }) => {
      const body = await request.json();

      // TypeScript validates request body structure
      return HttpResponse.json({
        id: '123',
        name: body.name,
        email: body.email,
        role: body.role || 'user',
      });
    }
  ),

  // DELETE user
  http.delete<{ id: string }>(
    '/api/users/:id',
    ({ params }) => {
      return HttpResponse.json({ success: true });
    }
  ),
];
Enter fullscreen mode Exit fullscreen mode

MSW Setup for Tests

// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// setupTests.ts (for Jest)
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Enter fullscreen mode Exit fullscreen mode

Using MSW in Tests

// api.test.ts
import { server } from './mocks/server';
import { http, HttpResponse } from 'msw';

test('fetches user successfully', async () => {
  const user = await fetchUser('1');

  // Mock returns typed data
  expect(user).toEqual({
    id: '1',
    name: 'Test User',
    email: 'test@example.com',
    role: 'user',
  });
});

test('handles user not found', async () => {
  // Override handler for this test
  server.use(
    http.get<{ id: string }, never, { error: string }>(
      '/api/users/:id',
      () => {
        return HttpResponse.json(
          { error: 'User not found' },
          { status: 404 }
        );
      }
    )
  );

  await expect(fetchUser('999')).rejects.toThrow('User not found');
});

test('creates user with valid data', async () => {
  const newUser = await createUser({
    name: 'Alice',
    email: 'alice@example.com',
  });

  expect(newUser.name).toBe('Alice');
});
Enter fullscreen mode Exit fullscreen mode

Type-Safe Mock Data with Factories

// mocks/handlers.ts
import { createMockUser } from '../testUtils/factories';

export const handlers = [
  http.get<{ id: string }, never, User>(
    '/api/users/:id',
    ({ params }) => {
      // ✅ Factory ensures type safety
      const user = createMockUser({ id: params.id });
      return HttpResponse.json(user);
    }
  ),

  http.get<never, never, User[]>(
    '/api/users',
    () => {
      // ✅ Generate multiple users
      const users = [
        createMockUser({ id: '1', name: 'Alice' }),
        createMockUser({ id: '2', name: 'Bob', role: 'admin' }),
        createMockUser({ id: '3', name: 'Charlie' }),
      ];

      return HttpResponse.json(users);
    }
  ),
];
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Mocking Modules with Type Safety

Mocking External Libraries

// __mocks__/axios.ts
import { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios';

type AxiosMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';

interface MockAxios extends AxiosInstance {
  mockResponse: <T = any>(data: T, status?: number) => void;
  mockError: (error: Error | string, status?: number) => void;
  reset: () => void;
}

const createMockAxios = (): MockAxios => {
  let mockData: any = null;
  let mockError: any = null;
  let mockStatus = 200;

  const createResponse = <T>(data: T, status = 200): AxiosResponse<T> => ({
    data,
    status,
    statusText: 'OK',
    headers: {},
    config: {} as AxiosRequestConfig,
  });

  const axiosMock = jest.fn() as any;

  const methods: Record<AxiosMethod, jest.Mock> = {
    get: jest.fn(),
    post: jest.fn(),
    put: jest.fn(),
    delete: jest.fn(),
    patch: jest.fn(),
  };

  // Implement each method
  Object.entries(methods).forEach(([method, fn]) => {
    fn.mockImplementation(() => {
      if (mockError) {
        return Promise.reject(mockError);
      }
      return Promise.resolve(createResponse(mockData, mockStatus));
    });
    axiosMock[method] = fn;
  });

  // Helper methods
  axiosMock.mockResponse = <T>(data: T, status = 200) => {
    mockData = data;
    mockStatus = status;
    mockError = null;
  };

  axiosMock.mockError = (error: Error | string, status = 500) => {
    mockError = typeof error === 'string' ? new Error(error) : error;
    mockStatus = status;
    mockData = null;
  };

  axiosMock.reset = () => {
    mockData = null;
    mockError = null;
    mockStatus = 200;
    Object.values(methods).forEach(fn => fn.mockClear());
  };

  return axiosMock as MockAxios;
};

export default createMockAxios();
Enter fullscreen mode Exit fullscreen mode

Using Type-Safe Axios Mock

// api.test.ts
import axios from 'axios';

// TypeScript knows about mockResponse and mockError
const mockAxios = axios as jest.Mocked<typeof axios> & {
  mockResponse: <T>(data: T, status?: number) => void;
  mockError: (error: Error | string, status?: number) => void;
  reset: () => void;
};

beforeEach(() => {
  mockAxios.reset();
});

test('fetches user data', async () => {
  const user = createMockUser({ name: 'Alice' });
  mockAxios.mockResponse(user);

  const result = await fetchUser('1');

  expect(result).toEqual(user);
  expect(mockAxios.get).toHaveBeenCalledWith('/api/users/1');
});

test('handles API errors', async () => {
  mockAxios.mockError('Network error', 500);

  await expect(fetchUser('1')).rejects.toThrow('Network error');
});
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Mocking React Hooks

Type-Safe Hook Mocking

// hooks/useAuth.ts
interface User {
  id: string;
  name: string;
  email: string;
}

interface UseAuthReturn {
  user: User | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

export function useAuth(): UseAuthReturn {
  // Real implementation
  const [user, setUser] = useState<User | null>(null);
  // ... rest of hook
  return { user, isLoading, login, logout };
}
Enter fullscreen mode Exit fullscreen mode
// __mocks__/hooks/useAuth.ts
import { UseAuthReturn } from '../../hooks/useAuth';

let mockAuthState: UseAuthReturn = {
  user: null,
  isLoading: false,
  login: jest.fn(),
  logout: jest.fn(),
};

export const useAuth = jest.fn((): UseAuthReturn => mockAuthState);

// ✅ Type-safe mock helpers
export const mockUseAuth = {
  setAuthState: (state: Partial<UseAuthReturn>) => {
    mockAuthState = { ...mockAuthState, ...state };
  },

  setUser: (user: User | null) => {
    mockAuthState = { ...mockAuthState, user, isLoading: false };
  },

  setLoading: (isLoading: boolean) => {
    mockAuthState = { ...mockAuthState, isLoading };
  },

  reset: () => {
    mockAuthState = {
      user: null,
      isLoading: false,
      login: jest.fn(),
      logout: jest.fn(),
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

Using Mocked Hooks in Tests

// components/UserProfile.test.tsx
import { useAuth, mockUseAuth } from '../hooks/useAuth';

jest.mock('../hooks/useAuth');

beforeEach(() => {
  mockUseAuth.reset();
});

test('shows user name when logged in', () => {
  const user = createMockUser({ name: 'Alice' });
  mockUseAuth.setUser(user);

  render(<UserProfile />);

  expect(screen.getByText('Alice')).toBeInTheDocument();
});

test('shows login button when not authenticated', () => {
  mockUseAuth.setUser(null);

  render(<UserProfile />);

  expect(screen.getByText('Login')).toBeInTheDocument();
});

test('shows loading state', () => {
  mockUseAuth.setLoading(true);

  render(<UserProfile />);

  expect(screen.getByText('Loading...')).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Type-Safe Testing Library Utilities

Custom Render with Providers

// testUtils/render.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  queryClient?: QueryClient;
  initialRoute?: string;
}

export function renderWithProviders(
  ui: React.ReactElement,
  {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
        mutations: { retry: false },
      },
    }),
    initialRoute = '/',
    ...renderOptions
  }: CustomRenderOptions = {}
) {
  window.history.pushState({}, 'Test page', initialRoute);

  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <BrowserRouter>
          {children}
        </BrowserRouter>
      </QueryClientProvider>
    );
  }

  return {
    ...render(ui, { wrapper: Wrapper, ...renderOptions }),
    queryClient,
  };
}

// Usage
test('renders user profile with data', async () => {
  const { queryClient } = renderWithProviders(<UserProfile userId="1" />);

  // Can interact with queryClient if needed
  await waitFor(() => {
    expect(queryClient.getQueryData(['user', '1'])).toBeDefined();
  });
});
Enter fullscreen mode Exit fullscreen mode

Type-Safe Custom Queries

// testUtils/queries.ts
import { screen, within } from '@testing-library/react';

export const customQueries = {
  getByDataTestId: (container: HTMLElement, id: string) => {
    const element = container.querySelector(`[data-testid="${id}"]`);
    if (!element) throw new Error(`Element with data-testid="${id}" not found`);
    return element as HTMLElement;
  },

  getButtonByText: (text: string) => {
    const button = screen.getByRole('button', { name: new RegExp(text, 'i') });
    return button as HTMLButtonElement;
  },

  getInputByLabel: (label: string) => {
    const input = screen.getByLabelText(new RegExp(label, 'i'));
    return input as HTMLInputElement;
  },
};

// Usage with types
test('form interaction', () => {
  render(<LoginForm />);

  const emailInput = customQueries.getInputByLabel('Email');
  const submitButton = customQueries.getButtonByText('Submit');

  // TypeScript knows these are the correct element types
  fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
  fireEvent.click(submitButton);
});
Enter fullscreen mode Exit fullscreen mode

Pattern 6: Type-Safe Test Fixtures

Fixture Files with Type Safety

// fixtures/users.ts
import { User } from '../types';

export const userFixtures = {
  admin: {
    id: 'admin-1',
    name: 'Admin User',
    email: 'admin@example.com',
    role: 'admin' as const,
    createdAt: new Date('2023-01-01'),
  } satisfies User,

  regularUser: {
    id: 'user-1',
    name: 'Regular User',
    email: 'user@example.com',
    role: 'user' as const,
    createdAt: new Date('2023-06-01'),
  } satisfies User,

  newUser: {
    id: 'user-2',
    name: 'New User',
    email: 'new@example.com',
    role: 'user' as const,
    createdAt: new Date('2024-01-01'),
  } satisfies User,
} as const;

// ✅ TypeScript validates each fixture matches User interface
// ✅ Using 'satisfies' keeps literal types while enforcing structure
Enter fullscreen mode Exit fullscreen mode

Fixture Collections

// fixtures/index.ts
interface Post {
  id: string;
  title: string;
  content: string;
  authorId: string;
  published: boolean;
}

export const fixtures = {
  users: userFixtures,

  posts: {
    published: {
      id: 'post-1',
      title: 'Published Post',
      content: 'This is published',
      authorId: userFixtures.admin.id,
      published: true,
    } satisfies Post,

    draft: {
      id: 'post-2',
      title: 'Draft Post',
      content: 'This is a draft',
      authorId: userFixtures.regularUser.id,
      published: false,
    } satisfies Post,
  },
} as const;

// Usage in tests
test('admin can see drafts', () => {
  const user = fixtures.users.admin;
  const draft = fixtures.posts.draft;

  expect(canViewPost(user, draft)).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

Pattern 7: Mocking Date and Time

Type-Safe Date Mocking

// testUtils/time.ts
export class MockDate {
  private originalDate: DateConstructor;
  private mockTime: number;

  constructor(dateString: string) {
    this.originalDate = global.Date;
    this.mockTime = new Date(dateString).getTime();
  }

  install() {
    const mockTime = this.mockTime;

    global.Date = class extends Date {
      constructor(...args: any[]) {
        if (args.length === 0) {
          super(mockTime);
        } else {
          super(...args);
        }
      }

      static now() {
        return mockTime;
      }
    } as DateConstructor;
  }

  restore() {
    global.Date = this.originalDate;
  }
}

// Usage
test('shows correct date', () => {
  const mockDate = new MockDate('2024-01-15T12:00:00Z');
  mockDate.install();

  const component = render(<DateDisplay />);
  expect(screen.getByText('January 15, 2024')).toBeInTheDocument();

  mockDate.restore();
});
Enter fullscreen mode Exit fullscreen mode

Pattern 8: Type-Safe Mock Responses

Creating Response Builders

// testUtils/responses.ts
interface ApiResponse<T> {
  data: T;
  status: number;
  message?: string;
}

interface ApiError {
  error: string;
  code: string;
  status: number;
}

export class ResponseBuilder {
  static success<T>(data: T, message?: string): ApiResponse<T> {
    return {
      data,
      status: 200,
      message,
    };
  }

  static created<T>(data: T): ApiResponse<T> {
    return {
      data,
      status: 201,
    };
  }

  static error(message: string, code: string, status = 400): ApiError {
    return {
      error: message,
      code,
      status,
    };
  }

  static notFound(resource: string): ApiError {
    return {
      error: `${resource} not found`,
      code: 'NOT_FOUND',
      status: 404,
    };
  }

  static unauthorized(): ApiError {
    return {
      error: 'Unauthorized',
      code: 'UNAUTHORIZED',
      status: 401,
    };
  }
}

// Usage with MSW
http.get('/api/users/:id', ({ params }) => {
  const user = createMockUser({ id: params.id });
  return HttpResponse.json(ResponseBuilder.success(user));
});

http.get('/api/users/:id/not-found', () => {
  return HttpResponse.json(
    ResponseBuilder.notFound('User'),
    { status: 404 }
  );
});
Enter fullscreen mode Exit fullscreen mode

Pattern 9: Testing React Query with Type Safety

Mocking Query Client

// testUtils/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        gcTime: 0,
      },
      mutations: {
        retry: false,
      },
    },
  });
}

// Pre-populate cache with typed data
export function createQueryClientWithData<T>(
  queryKey: unknown[],
  data: T
): QueryClient {
  const queryClient = createTestQueryClient();
  queryClient.setQueryData(queryKey, data);
  return queryClient;
}
Enter fullscreen mode Exit fullscreen mode

Testing Components with Queries

// UserProfile.test.tsx
import { createQueryClientWithData } from './testUtils/queryClient';

test('displays cached user data', () => {
  const user = createMockUser({ name: 'Alice' });
  const queryClient = createQueryClientWithData(['user', '1'], user);

  renderWithProviders(<UserProfile userId="1" />, { queryClient });

  expect(screen.getByText('Alice')).toBeInTheDocument();
});

test('shows loading state', () => {
  const queryClient = createTestQueryClient();

  renderWithProviders(<UserProfile userId="1" />, { queryClient });

  expect(screen.getByText('Loading...')).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Pattern 10: Keeping Test Types in Sync

Shared Type Definitions

// types/api.ts - Source of truth for API types
export interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

export interface CreateUserRequest {
  name: string;
  email: string;
  role?: 'admin' | 'user';
}

export interface UpdateUserRequest {
  name?: string;
  email?: string;
  role?: 'admin' | 'user';
}
Enter fullscreen mode Exit fullscreen mode
// mocks/handlers.ts - Use the same types
import { User, CreateUserRequest } from '../types/api';
import { createMockUser } from '../testUtils/factories';

export const handlers = [
  http.post<never, CreateUserRequest, User>(
    '/api/users',
    async ({ request }) => {
      const body = await request.json();

      // TypeScript ensures body matches CreateUserRequest
      const user = createMockUser({
        name: body.name,
        email: body.email,
        role: body.role,
      });

      return HttpResponse.json(user);
    }
  ),
];
Enter fullscreen mode Exit fullscreen mode

Type Assertions for Runtime Validation

// testUtils/assertions.ts
import { User } from '../types/api';

export function assertIsUser(value: unknown): asserts value is User {
  if (typeof value !== 'object' || value === null) {
    throw new Error('Value is not an object');
  }

  const obj = value as Record<string, unknown>;

  if (typeof obj.id !== 'string') {
    throw new Error('User.id must be a string');
  }

  if (typeof obj.name !== 'string') {
    throw new Error('User.name must be a string');
  }

  if (typeof obj.email !== 'string') {
    throw new Error('User.email must be a string');
  }

  if (obj.role !== 'admin' && obj.role !== 'user') {
    throw new Error('User.role must be admin or user');
  }
}

// Usage in tests
test('API returns valid user', async () => {
  const response = await fetch('/api/users/1');
  const data = await response.json();

  // Runtime validation that also satisfies TypeScript
  assertIsUser(data);

  // Now TypeScript knows data is User
  expect(data.name).toBe('Test User');
});
Enter fullscreen mode Exit fullscreen mode

Pattern 11: Type-Safe Spy Functions

Creating Typed Spies

// testUtils/spies.ts
type SpyFunction<T extends (...args: any[]) => any> = jest.Mock<
  ReturnType<T>,
  Parameters<T>
>;

export function createSpy<T extends (...args: any[]) => any>(
  implementation?: T
): SpyFunction<T> {
  return jest.fn(implementation);
}

// Usage
interface Logger {
  log: (message: string, level: 'info' | 'error') => void;
  error: (error: Error) => void;
}

const mockLogger: Logger = {
  log: createSpy<Logger['log']>(),
  error: createSpy<Logger['error']>(),
};

test('logs errors correctly', () => {
  const error = new Error('Test error');
  mockLogger.error(error);

  expect(mockLogger.error).toHaveBeenCalledWith(error);

  // TypeScript knows the parameter types
  const calls = mockLogger.error.mock.calls;
  expect(calls[0][0]).toBeInstanceOf(Error);
});
Enter fullscreen mode Exit fullscreen mode

Pattern 12: Integration Test Utilities

Type-Safe Test Database Seeding

// testUtils/database.ts
interface SeedData<T> {
  table: string;
  data: T[];
}

export class TestDatabase {
  async seed<T>(seedData: SeedData<T>): Promise<void> {
    // Implementation depends on your database
    // TypeScript ensures data matches expected shape
  }

  async clear(table: string): Promise<void> {
    // Clear table
  }

  async findOne<T>(table: string, id: string): Promise<T | null> {
    // Find record
    return null;
  }
}

// Usage
test('creates user in database', async () => {
  const db = new TestDatabase();

  await db.seed<User>({
    table: 'users',
    data: [createMockUser({ id: '1' })],
  });

  const user = await db.findOne<User>('users', '1');
  expect(user?.name).toBe('Test User');
});
Enter fullscreen mode Exit fullscreen mode

Anti-Patterns to Avoid

❌ Don't: Cast Everything to Any

// ❌ Bad - defeats TypeScript
const mockUser = { name: 'Test' } as any;
const mockFetch = jest.fn() as any;
Enter fullscreen mode Exit fullscreen mode

✅ Do: Use Proper Types

// ✅ Good - TypeScript can help
const mockUser = createMockUser({ name: 'Test' });
const mockFetch = jest.fn<Promise<User>, [string]>();
Enter fullscreen mode Exit fullscreen mode

❌ Don't: Duplicate Type Definitions

// ❌ Bad - types can drift
interface User {
  id: string;
  name: string;
}

// In test file
interface TestUser {
  id: string;
  name: string;
}
Enter fullscreen mode Exit fullscreen mode

✅ Do: Import Shared Types

// ✅ Good - single source of truth
import { User } from '../types';

const mockUser = createMockUser();
Enter fullscreen mode Exit fullscreen mode

❌ Don't: Ignore Type Errors in Tests

// ❌ Bad - @ts-ignore hides problems
// @ts-ignore
expect(user.nonExistentProperty).toBe(true);
Enter fullscreen mode Exit fullscreen mode

✅ Do: Fix the Root Cause

// ✅ Good - update types or test
const user = createMockUser();
expect(user.name).toBe('Test User');
Enter fullscreen mode Exit fullscreen mode

Complete Example: Type-Safe Test Suite

// UserService.test.ts
import { renderWithProviders } from './testUtils/render';
import { createMockUser } from './testUtils/factories';
import { server } from './mocks/server';
import { http, HttpResponse } from 'msw';

describe('UserService', () => {
  beforeEach(() => {
    server.resetHandlers();
  });

  describe('fetchUser', () => {
    test('returns user data', async () => {
      const mockUser = createMockUser({ name: 'Alice' });

      server.use(
        http.get('/api/users/:id', () => {
          return HttpResponse.json(mockUser);
        })
      );

      const user = await fetchUser('1');
      expect(user).toEqual(mockUser);
    });

    test('handles errors', async () => {
      server.use(
        http.get('/api/users/:id', () => {
          return HttpResponse.json(
            { error: 'Not found' },
            { status: 404 }
          );
        })
      );

      await expect(fetchUser('1')).rejects.toThrow('Not found');
    });
  });

  describe('UserProfile component', () => {
    test('displays user information', async () => {
      const user = createMockUser({ name: 'Alice', email: 'alice@example.com' });

      server.use(
        http.get('/api/users/:id', () => {
          return HttpResponse.json(user);
        })
      );

      renderWithProviders(<UserProfile userId="1" />);

      expect(await screen.findByText('Alice')).toBeInTheDocument();
      expect(screen.getByText('alice@example.com')).toBeInTheDocument();
    });

    test('shows loading state', () => {
      renderWithProviders(<UserProfile userId="1" />);
      expect(screen.getByText('Loading...')).toBeInTheDocument();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

The Benefits

Before type-safe testing:

  • Tests with any everywhere
  • Production breaks after passing tests
  • Refactoring breaks tests silently
  • No confidence in test coverage

After type-safe testing:

  • TypeScript validates mock data
  • Tests break when types change (good!)
  • Refactoring is safe and guided
  • High confidence tests catch real bugs

Quick Reference

// ✅ Mock factories
const user = createMockUser({ name: 'Alice' });

// ✅ MSW with types
http.get<PathParams, RequestBody, ResponseBody>('/api/users/:id', handler);

// ✅ Typed fixtures
export const fixtures = { ... } satisfies Record<string, User>;

// ✅ Custom render
renderWithProviders(<Component />, { queryClient });

// ✅ Typed spies
const spy = createSpy<(arg: string) => void>();

// ❌ Don't use any
const mock = { ... } as any;
Enter fullscreen mode Exit fullscreen mode

Conclusion

Type-safe testing isn't about making tests harder to write. It's about making them impossible to write incorrectly.

When you refactor production code, your tests should break at compile-time, not runtime. When types change, TypeScript should guide you through updating every affected test.

Stop lying to TypeScript with any. Start building tests that actually protect you from bugs.

Your future self—debugging a production issue at 2am—will thank you.


What testing challenges are you facing with TypeScript? Share in the comments!

Top comments (0)