The Reality of Testing: Escaping the Integration Trap
Unit testing a basic math utility is trivial. Real-world engineering, however, is messy. Most production code I touch interacts with Stripe APIs, PostgreSQL databases, or AWS S3 buckets. If your test suite tries to hit a real payment gateway, it will eventually fail due to a 401 error or a slow network. Flaky tests are a productivity killer; I’ve seen teams lose hours of dev time to a single unstable CI pipeline.
Mocking and stubbing solve this by simulating external behavior. These techniques make tests deterministic and lightning-fast. In modern ESM-based projects, Vitest is the clear winner over Jest. It often cuts execution time by 30-50% because it leverages Vite’s transformation pipeline. It feels snappy, especially when your suite grows to hundreds of files.
To avoid confusion, let’s define our tools clearly:
- Stubbing: You provide a “canned” response. If the code calls
getExchangeRate(), the stub simply returns1.2without doing any math. - Mocking: This focuses on behavior. You don’t just provide a fake value; you verify that the function was called exactly twice with the correct API key.
Setup: Preparing Your Vitest Environment
Setting up Vitest in a TypeScript project takes less than two minutes. If you’re already using Vite, the integration is seamless. Even as a standalone runner, it requires minimal boilerplate.
Install the core package and the UI dashboard for a better developer experience:
npm install -D vitest @vitest/ui @vitest/coverage-v8
TypeScript users often run into “module not found” errors when using global test functions. While you can enable globals in your config, I recommend explicit imports. It makes your code easier to navigate and provides better IntelliSense support in VS Code. Update your package.json with these essential scripts:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
Practical Implementation: Mocks and Stubs in Action
Consider a userService.ts that fetches a profile and updates a local cache. We want to test the logic without actually making HTTP requests.
1. Mocking External Modules
When using axios or fetch, you should mock the entire module at the top of your test file. This creates a “safety bubble” around your test environment.
// userService.ts
import axios from 'axios';
export const getUser = async (id: number) => {
const response = await axios.get(`https://api.myapp.com/users/${id}`);
return response.data;
};
In the test, use vi.mock() to intercept the network call. Note how vi.mocked() provides full type safety for the mocked methods:
// userService.test.ts
import { describe, it, expect, vi } from 'vitest';
import axios from 'axios';
import { getUser } from './userService';
vi.mock('axios');
describe('getUser', () => {
it('returns user data on success', async () => {
const mockUser = { id: 42, name: 'Alex' };
// TypeScript knows axios.get is now a mock
vi.mocked(axios.get).mockResolvedValue({ data: mockUser });
const result = await getUser(42);
expect(result).toEqual(mockUser);
expect(axios.get).toHaveBeenCalledWith('https://api.myapp.com/users/42');
});
});
2. Monitoring Methods with Spies
You don’t always need to replace a whole module. Sometimes you just need to watch a specific function. For example, you might want to ensure an analytics event fires when a user clicks “Purchase.” vi.spyOn() is perfect for this.
it('tracks the checkout event', () => {
const spy = vi.spyOn(tracker, 'sendEvent');
processCheckout({ total: 99.99 });
expect(spy).toHaveBeenCalledWith('checkout_completed', { amount: 99.99 });
spy.mockRestore(); // Always restore to avoid polluting other tests
});
3. Controlling Time
Testing a password reset token that expires in 15 minutes? Don’t actually wait 15 minutes. Use vi.useFakeTimers() to manipulate the clock. This allows you to jump forward in time instantly.
it('expires the session after 30 minutes', () => {
vi.useFakeTimers();
const session = startSession();
// Fast-forward 31 minutes
vi.advanceTimersByTime(31 * 60 * 1000);
expect(session.isValid()).toBe(false);
vi.useRealTimers();
});
Maintaining Test Integrity
A common pitfall is the “leaky mock.” This happens when a mock from one test affects the results of the next. It leads to those frustrating scenarios where tests pass individually but fail when run as a group.
Automating the Cleanup
I suggest configuring Vitest to reset everything automatically. This keeps your tests isolated and predictable. Add these settings to your vitest.config.ts:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
clearMocks: true, // Clears call history
mockReset: true, // Resets to an empty function
restoreMocks: true, // Restores original implementation
},
});
Smart Assertions
Avoid testing every single key in a large JSON response. If an API returns a 50-field object, but you only care about the email and id, use asymmetric matchers. This makes your tests resilient to API changes that don’t affect your specific logic.
expect(apiResponse).toHaveBeenCalledWith(
expect.objectContaining({
email: '[email protected]',
id: expect.any(Number)
})
);
Mastering these patterns turns a brittle test suite into a robust safety net. You can refactor complex logic or upgrade dependencies with total confidence, knowing your core business rules are protected from external volatility.

