Back to Blog

TypeScript Testing Patterns in 2026: Test Doubles, Factory Functions, Fixtures, and MSW

Write maintainable TypeScript tests: test double patterns (stub, mock, spy, fake), factory functions for test data, fixture management, MSW for API mocking, and avoiding common testing anti-patterns.

Viprasol Tech Team
September 2, 2026
13 min read

TypeScript Testing Patterns in 2026: Test Doubles, Factory Functions, Fixtures, and MSW

Test code is production code. It has the same maintenance burden, the same quality requirements, and the same impact on team velocity โ€” except it's twice as likely to be neglected. Brittle tests that break on every refactor, test doubles scattered inconsistently, and fixture data copy-pasted across 40 test files are the symptoms of tests written without patterns.

This post covers the patterns that make test suites maintainable at scale: the four types of test doubles and when to use each, factory functions that eliminate fixture duplication, and MSW for realistic API mocking without coupling to implementation.


Test Double Types

Most engineers use the words "mock" and "stub" interchangeably. They're different:

// Test double taxonomy:

// 1. STUB: Returns a pre-programmed response. No assertions.
const stubUserRepo = {
  findById: jest.fn().mockResolvedValue({ id: '1', email: 'test@example.com' }),
};
// Use when: you need the code under test to receive a value; don't care how many times called

// 2. MOCK: Stub + verification (asserts it was called correctly)
const mockEmailService = {
  sendWelcome: jest.fn(),
};
// ... after test:
expect(mockEmailService.sendWelcome).toHaveBeenCalledWith({ to: 'test@example.com' });
// Use when: you want to verify a side effect happened

// 3. SPY: Wraps real implementation; records calls
const spyConsole = jest.spyOn(console, 'error').mockImplementation(() => {});
// ... after test:
expect(spyConsole).toHaveBeenCalledWith(expect.stringContaining('error'));
spyConsole.mockRestore();
// Use when: you want to observe without replacing behavior

// 4. FAKE: Lightweight working implementation (not a mock)
class FakeEmailService implements EmailService {
  sent: Array<{ to: string; template: string }> = [];

  async sendWelcome(to: string): Promise<void> {
    this.sent.push({ to, template: 'welcome' });
  }

  async sendPasswordReset(to: string): Promise<void> {
    this.sent.push({ to, template: 'password-reset' });
  }
}
// Use when: the dependency is used heavily across many tests;
// a shared fake is easier to maintain than individual mocks

Factory Functions for Test Data

// src/__tests__/factories/user.factory.ts
// Define once, use everywhere โ€” no more copy-pasted test data

import { faker } from '@faker-js/faker';

// Type mirrors your domain model exactly
interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'member' | 'viewer';
  plan: 'free' | 'pro' | 'enterprise';
  createdAt: Date;
  lastLoginAt: Date | null;
  mfaEnabled: boolean;
}

// Factory: creates realistic test data with sensible defaults
// Override any field by passing partial
export function createUser(overrides: Partial<User> = {}): User {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email().toLowerCase(),
    name: faker.person.fullName(),
    role: 'member',
    plan: 'free',
    createdAt: faker.date.past({ years: 1 }),
    lastLoginAt: faker.date.recent({ days: 30 }),
    mfaEnabled: false,
    ...overrides,  // Override any field
  };
}

// Trait functions for common variants
export const createAdminUser = (overrides: Partial<User> = {}): User =>
  createUser({ role: 'admin', plan: 'enterprise', mfaEnabled: true, ...overrides });

export const createNewUser = (overrides: Partial<User> = {}): User =>
  createUser({ createdAt: new Date(), lastLoginAt: null, ...overrides });

export const createEnterpriseUser = (overrides: Partial<User> = {}): User =>
  createUser({ plan: 'enterprise', ...overrides });

// Usage in tests:
// const user = createUser();                           โ†’ random member
// const admin = createUser({ role: 'admin' });         โ†’ admin with defaults
// const admin = createAdminUser();                     โ†’ shorthand
// const user = createUser({ email: 'specific@test.com' }); โ†’ controlled email
// src/__tests__/factories/order.factory.ts
import { faker } from '@faker-js/faker';

interface OrderItem {
  productId: string;
  quantity: number;
  unitPrice: number;
}

interface Order {
  id: string;
  customerId: string;
  items: OrderItem[];
  status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
  totalCents: number;
  createdAt: Date;
}

function createOrderItem(overrides: Partial<OrderItem> = {}): OrderItem {
  const quantity = faker.number.int({ min: 1, max: 5 });
  const unitPrice = faker.number.int({ min: 100, max: 10000 });
  return {
    productId: faker.string.uuid(),
    quantity,
    unitPrice,
    ...overrides,
  };
}

export function createOrder(overrides: Partial<Order> = {}): Order {
  const items = overrides.items ?? [createOrderItem(), createOrderItem()];
  const total = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);

  return {
    id: faker.string.uuid(),
    customerId: faker.string.uuid(),
    items,
    status: 'pending',
    totalCents: total,
    createdAt: faker.date.recent({ days: 7 }),
    ...overrides,
    // Recalculate total if items overridden
    ...(overrides.items && !overrides.totalCents
      ? { totalCents: overrides.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0) }
      : {}),
  };
}

export const createShippedOrder = (overrides: Partial<Order> = {}) =>
  createOrder({ status: 'shipped', ...overrides });

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Fake Repository Pattern

// src/__tests__/fakes/fake-user-repository.ts
// Full in-memory implementation of the repository interface

import type { UserRepository } from '@/repositories/user.repository';
import type { User } from '@/domain/user';
import { createUser } from '../factories/user.factory';

export class FakeUserRepository implements UserRepository {
  private users: Map<string, User> = new Map();

  // Test helpers (not on the interface โ€” for test setup/assertions)
  seed(users: User[]): void {
    users.forEach((u) => this.users.set(u.id, u));
  }

  all(): User[] {
    return Array.from(this.users.values());
  }

  // Interface implementation
  async findById(id: string): Promise<User | null> {
    return this.users.get(id) ?? null;
  }

  async findByEmail(email: string): Promise<User | null> {
    return Array.from(this.users.values()).find((u) => u.email === email) ?? null;
  }

  async save(user: User): Promise<void> {
    this.users.set(user.id, { ...user });
  }

  async delete(id: string): Promise<void> {
    this.users.delete(id);
  }

  async findAll(filter: Partial<Pick<User, 'role' | 'plan'>>): Promise<User[]> {
    return Array.from(this.users.values()).filter((u) =>
      (!filter.role || u.role === filter.role) &&
      (!filter.plan || u.plan === filter.plan),
    );
  }
}

// Usage in tests:
// const repo = new FakeUserRepository();
// repo.seed([createUser({ email: 'existing@test.com' })]);
// const service = new UserService(repo);
// const result = await service.findByEmail('existing@test.com');
// expect(result).toMatchObject({ email: 'existing@test.com' });

MSW: API Mocking Without Coupling

Mock Service Worker intercepts HTTP requests at the network level โ€” no need to mock fetch or axios:

npm install -D msw@latest
// src/__tests__/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import { createUser } from '../factories/user.factory';

export const handlers = [
  // List users
  http.get('/api/users', ({ request }) => {
    const url = new URL(request.url);
    const role = url.searchParams.get('role');

    const users = [
      createUser({ role: 'admin' }),
      createUser({ role: 'member' }),
      createUser({ role: 'member' }),
    ].filter((u) => !role || u.role === role);

    return HttpResponse.json({
      data: users,
      pagination: { total: users.length, page: 1, perPage: 20, hasNextPage: false },
    });
  }),

  // Get single user
  http.get('/api/users/:id', ({ params }) => {
    if (params.id === 'not-found') {
      return HttpResponse.json({ error: 'User not found' }, { status: 404 });
    }

    return HttpResponse.json(createUser({ id: params.id as string }));
  }),

  // Create user
  http.post('/api/users', async ({ request }) => {
    const body = await request.json() as Partial<User>;
    const user = createUser(body);
    return HttpResponse.json(user, { status: 201 });
  }),

  // Simulate server error
  http.delete('/api/users/:id', () => {
    return HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 });
  }),
];
// src/__tests__/setup/msw-setup.ts
import { setupServer } from 'msw/node';
import { handlers } from '../mocks/handlers';

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

// Jest setup file:
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());  // Reset to defaults between tests
afterAll(() => server.close());
// src/__tests__/components/UserList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { server } from '../setup/msw-setup';
import { http, HttpResponse } from 'msw';
import { UserList } from '@/components/UserList';
import { QueryProvider } from '@/providers/QueryProvider';

const wrapper = ({ children }: { children: React.ReactNode }) => (
  <QueryProvider>{children}</QueryProvider>
);

describe('UserList', () => {
  it('renders users from API', async () => {
    render(<UserList />, { wrapper });

    // Loading state
    expect(screen.getByRole('status')).toBeInTheDocument();  // Spinner

    // Wait for users to load
    await waitFor(() => {
      expect(screen.getAllByRole('row')).toHaveLength(4); // 3 users + header
    });
  });

  it('shows error state when API fails', async () => {
    // Override handler for this test only
    server.use(
      http.get('/api/users', () =>
        HttpResponse.json({ error: 'Service unavailable' }, { status: 503 }),
      ),
    );

    render(<UserList />, { wrapper });

    await waitFor(() => {
      expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
    });
  });
});

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Common Anti-Patterns and Fixes

// โŒ Anti-pattern: Testing implementation details
test('calls setLoading(true) when fetching', () => {
  // This test breaks on any refactor of loading state management
  expect(mockSetLoading).toHaveBeenCalledWith(true);
});

// โœ… Test observable behavior instead
test('shows loading spinner while fetching', async () => {
  render(<UserList />);
  expect(screen.getByRole('status')).toBeInTheDocument(); // Spinner visible
});

// โŒ Anti-pattern: Sharing mutable state between tests
let mockRepo: MockRepo;
beforeAll(() => { mockRepo = new MockRepo(); }); // Shared state = test pollution

// โœ… Fresh instance per test
beforeEach(() => { mockRepo = new MockRepo(); });

// โŒ Anti-pattern: Hardcoded test data everywhere
const user = { id: '123', email: 'test@test.com', role: 'admin', plan: 'free' };
// Missing half the User fields โ€” breaks when User type adds required fields

// โœ… Factory functions
const user = createUser({ role: 'admin' }); // Always matches the real type

Working With Viprasol

We implement testing infrastructure for TypeScript applications โ€” from factory function design through fake repositories, MSW handler libraries, and CI coverage reporting.

What we deliver:

  • Factory function library matching your domain model
  • Fake repository implementations for all dependencies
  • MSW handler setup with realistic response patterns
  • Test double strategy review (stub vs mock vs fake per use case)
  • Jest configuration with coverage thresholds and CI integration

โ†’ Discuss your testing architecture โ†’ Software development services


Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.