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

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.

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#
| Type | Purpose | Example |
|---|---|---|
| Mock | Replace a dependency and verify interactions | Fake API that records calls |
| Spy | Watch a real function without changing it | Recording how many times a function runs |
| Stub | Provide predetermined responses | Always return the same user data |
| Fake | Working but simplified implementation | In-memory database instead of real one |
Testing Edge Cases#

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.
