32.2 Vitest: Zero-Config TypeScript Testing
Alright, let’s talk about Vitest. If you’ve ever felt the cold, bureaucratic weight of configuring Jest for TypeScript—the ts-jest or babel-jest rigmarole, the type-checking that’s either slow or non-existent—then Vitest is your liberation. It’s the test runner that looks at the existing, brilliant tooling of the Vite ecosystem and says, “Yeah, I’ll just use that, thanks.” It’s the zero-config dream that actually delivers, and it’s so fast it feels like it’s cheating. Which, in a good way, it is.
The Zero-Config Magic Trick
The “zero-config” claim isn’t marketing fluff; it’s architectural genius. Vite already has a deeply integrated understanding of how to process TypeScript, ES Modules, JSX, and more using esbuild and Rollup. Vitest, being built by the same team, simply piggybacks on your existing vite.config.ts. It uses the same resolve aliases, the same plugins, the same everything. This means if your app builds with Vite, your tests will run with Vitest. No duplication, no lies.
Your setup is often as simple as:
npm install -D vitest
And then adding a single script to your package.json:
{
"scripts": {
"test": "vitest"
}
}
Run npm test and it just works. It will automatically pick up any file named *.test.ts or *.spec.ts. No jest.config.js. No transpilers. No tears.
Writing Your First Vitest
A test looks reassuringly familiar, just… better. You’ll typically want to use the describe/it or test pattern alongside expect for assertions.
// calculator.test.ts
import { describe, it, expect } from 'vitest';
import { add, subtract } from './calculator';
describe('calculator module', () => {
it('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('subtracts two numbers', () => {
expect(subtract(5, 3)).toBe(2);
});
});
The key difference? Those imports are from vitest, not @jest/globals. This is a conscious design choice to keep you in the standard ESM world, not the Jest globals world. It feels cleaner and more modern.
The Killer Feature: In-Source Testing
Here’s where Vitest gets genuinely weird and wonderful. You can write your tests in the same file as your code. Before you recoil in horror, hear me out. This isn’t for production code; it’s for quickly testing a single utility function, a React hook, or during intense debugging. Vitest will only run these tests if you pass the --includeSource flag, and you can guard them so your build tool won’t include them in the final bundle.
// deep-thought.ts
export function getAnswer() {
return 42;
}
// In-source test suite. Only runs if you use --includeSource
if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest;
describe('getAnswer', () => {
it('returns the ultimate answer', () => {
expect(getAnswer()).toBe(42);
});
});
}
This is a power tool. Use it responsibly. It’s perfect for those “does this single function even work?” moments without the ceremony of a new file.
Watch Mode: This is Where it Shines
Run vitest without any flags and it kicks into watch mode by default. This is the best way to use it. The performance is obscene. It uses the same module graph as Vite, so it only reruns the tests for the files that changed and their dependencies. It’s near-instantaneous. It makes TDD feel like a continuous conversation with your code, not a formal meeting you have to schedule every few minutes.
Handling the Weird Stuff: Mocks and Timers
Vitest has a fantastic mocking system that’s both powerful and intuitive. It leans heavily on the native ESM import system.
// test.ts
import { describe, it, expect, vi } from 'vitest';
import { fetchData } from './api';
import { externalDep } from './external-lib';
// Mock the entire module
vi.mock('./external-lib');
describe('API module', () => {
it('mocks an external dependency', async () => {
// Mock the implementation for this test
vi.mocked(externalDep).mockResolvedValue('fake data');
const result = await fetchData();
expect(result).toBe('fake data');
expect(externalDep).toHaveBeenCalledOnce();
});
});
The vi object is your gateway to all mocking, stubbing, and spying. It’s Jest’s jest object but, again, just feels more integrated and less clunky.
For timers, Vitest provides the same fake timer functionality, which is essential for testing anything involving setTimeout or setInterval without actually waiting in real time.
it('handles timers correctly', () => {
vi.useFakeTimers();
let count = 0;
setTimeout(() => count++, 1000);
vi.advanceTimersByTime(1000);
expect(count).toBe(1); // The callback has run
vi.useRealTimers(); // Don't forget to clean up!
});
Pitfall Alert: Always, always call vi.useRealTimers() after your test or in an afterEach hook. If you don’t, you will leak fake timers into other tests and cause bewildering, hair-pulling failures. I speak from experience.
Configuration: For When You Do Need It
While it’s zero-config, you’re not locked out. You can configure Vitest extensively inside your vite.config.ts. This is where you’d set up test-specific environment globals (like jsdom for DOM testing), global setup files, or change the output format.
// vite.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom', // For testing UI components
globals: true, // If you *really* prefer Jest-style global APIs
setupFiles: './tests/setup.ts', // For global setup
},
});
The beauty is it’s all in one place. Your aliases @/* -> ./src/*? Already work. Your SVG plugin? Already works. It’s a cohesive system, not a bolted-on afterthought. Vitest isn’t just a test runner; it’s a logical extension of your Vite development environment. It acknowledges that testing isn’t a separate activity—it’s part of the flow. And it’s designed to make that flow incredibly, delightfully fast.