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.
Example
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.
Example
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 an existing method and records how it was called. You can keep its real behavior or replace its result for a test. Restore it afterward so another test does not inherit the spy.
Example
const notifier = {
sendEmail(address: string, subject: string) {
return `${subject} sent to ${address}`;
},
};
it('records the call to a real method', () => {
const spy = vi.spyOn(notifier, 'sendEmail');
try {
notifier.sendEmail('alice@example.com', 'Welcome');
expect(spy).toHaveBeenCalledWith('alice@example.com', 'Welcome');
} finally {
spy.mockRestore();
}
});
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.
Here's another popular way to lay out a test, and this one reads like a lil story.
Example
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.
Example
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.