Skip to content

Join the Seedly owners community →

Testing & Debugging

Testing Patterns

Master common testing patterns including mocks, spies, stubs, and edge case strategies

Written by 14 min read1 activity
Sprout, your presenter

Sprout presents

Mocks, spies and stubs. Three kinds of stand-ins for real code, and once you can tell them apart, tests become much easier to write.Mocks, spies and stubs. Three kinds of stand-ins for real code, and once you can tell them apart, tests become much easier to write.

Sprout works a lookalike puppet that bows on a small wooden stage
A mock is a stand in whose every move you control

Out in the real world, functions almost never work alone. They call APIs, read from databases, send emails, and care about what time it is right now. So how the heck do you test code that leans on stuff you can't control?

Testing patterns. They're tried-and-true tricks that let you test pretty much any code, no matter how tangled up its dependencies are.

The Problem with Dependencies#

Take a look at this function.

async function getWeather(city: string): Promise<string> {
  const response = await fetch(`https://api.weather.com/current?city=${city}`);
  const data = await response.json();
  return `${data.temperature}°F in ${city}`;
}

You can't reliably test this thing as it stands, and here's why.

  • It makes a real network request (slow and flaky)
  • The weather changes ALL the time (so the results are unpredictable)
  • The API might be down (so your tests fail for reasons that have nothing to do with your code)

Enter mocking.

Mocking#

A mock swaps a real dependency for a fake version you control. It's a stunt double in a movie. Looks the same from the outside, and you call EVERY move it makes.

import { describe, it, expect, vi } from 'vitest';
 
describe('getWeather', () => {
  it('should format the temperature correctly', async () => {
    // Mock the fetch function
    vi.spyOn(globalThis, 'fetch').mockResolvedValue({
      json: () => Promise.resolve({ temperature: 72 })
    } as Response);
 
    const result = await getWeather('Seattle');
 
    expect(result).toBe('72°F in Seattle');
    expect(fetch).toHaveBeenCalledWith(
      'https://api.weather.com/current?city=Seattle'
    );
  });
});

Spies#

A spy watches a function and writes down how it got called, without changing what it does. Think security camera... it sees everything and touches nothing.

describe('NotificationService', () => {
  it('should send email when user signs up', () => {
    const sendEmail = vi.fn(); // Create a spy function
    const service = new NotificationService(sendEmail);
 
    service.onUserSignup('[email protected]');
 
    // Check the spy's recordings
    expect(sendEmail).toHaveBeenCalledTimes(1);
    expect(sendEmail).toHaveBeenCalledWith(
      '[email protected]',
      'Welcome to our app!'
    );
  });
});

Stubs#

A stub is a simplified stand-in that returns data you picked ahead of time. With a mock you usually check how it got called, and with a stub you mostly don't care. You just need it to hand back something predictable.

describe('PricingCalculator', () => {
  it('should apply discount for premium users', () => {
    // Stub: always returns 'premium' regardless of input
    const getUserTier = vi.fn().mockReturnValue('premium');
 
    const calculator = new PricingCalculator(getUserTier);
    const price = calculator.calculatePrice(100);
 
    expect(price).toBe(80); // 20% discount for premium
  });
});

Test Doubles Summary#

TypePurposeExample
MockReplace a dependency and verify interactionsFake API that records calls
SpyWatch a real function without changing itRecording how many times a function runs
StubProvide predetermined responsesAlways return the same user data
FakeWorking but simplified implementationIn-memory database instead of real one

Testing Edge Cases#

Sprout watches a toy windmill beside a brass camera, marking strokes on a notepad
A spy watches a function and records how it was called

Checking the happy path is only half the job. Good tests also check what happens when things go sideways.

describe('divide', () => {
  // Happy path
  it('should divide two numbers', () => {
    expect(divide(10, 2)).toBe(5);
  });
 
  // Edge cases
  it('should throw when dividing by zero', () => {
    expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
  });
 
  it('should handle negative numbers', () => {
    expect(divide(-10, 2)).toBe(-5);
  });
 
  it('should handle decimal results', () => {
    expect(divide(10, 3)).toBeCloseTo(3.333, 2);
  });
 
  it('should handle very large numbers', () => {
    expect(divide(Number.MAX_SAFE_INTEGER, 1)).toBe(Number.MAX_SAFE_INTEGER);
  });
});

The Given-When-Then Pattern#

Here's another popular way to lay out a test, and this one reads like a lil story.

describe('Shopping Cart', () => {
  it('should apply free shipping for orders over $50', () => {
    // Given: a cart with items totaling more than $50
    const cart = new Cart();
    cart.addItem({ name: 'Shoes', price: 60 });
 
    // When: we calculate the shipping cost
    const shipping = cart.calculateShipping();
 
    // Then: shipping should be free
    expect(shipping).toBe(0);
  });
});

Cleaning Up After Tests#

Always clean up your mocks and state after each test so they don't leak into the next one.

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 
describe('TimerService', () => {
  beforeEach(() => {
    vi.useFakeTimers(); // Take control of time
  });
 
  afterEach(() => {
    vi.restoreAllMocks(); // Put back anything you replaced with vi.spyOn
    vi.useRealTimers();   // Give time back
  });
 
  it('should trigger callback after delay', () => {
    const callback = vi.fn();
    startTimer(callback, 1000);
 
    vi.advanceTimersByTime(1000);
 
    expect(callback).toHaveBeenCalled();
  });
});

TL;DR#

  • Mocks swap real dependencies for fakes you control
  • Spies watch functions without changing what they do
  • Stubs hand back answers you picked ahead of time, so tests stay predictable
  • Always test the edge cases, like empty inputs, errors and boundaries
  • Clean up mocks after each test so they don't leak
  • Given-When-Then makes tests read like a story

What's Next?#

You've got the core patterns down. Next up is integration testing, where you check that the pieces of your app actually work together... and those patterns come right along with you.

This lesson ends with a short activity.