Unit Testing with Vitest: A Fast, Modern Test Runner
Vitest is a lightning-fast test runner for JavaScript and TypeScript, designed to work seamlessly with Vite. If you have used Jest, you will feel right at home, but with better performance and modern defaults.
Unit testing with Vitest gives you instant feedback while you write code. This tutorial walks through setting up Vitest, writing your first tests, and exploring features like snapshot testing, mocking, and test coverage.
Why Vitest?
Vitest offers several advantages over traditional test runners:
- Near-instant hot module replacement (HMR): tests run in the same process as your dev server
- Native ES modules: no need for Babel or complex transformations
- Jest-compatible API: most Jest tests work with zero changes
- TypeScript support out of the box: no additional configuration needed
Setting up Vitest
First, install Vitest in your project. The -D flag adds it as a dev dependency since tests are not needed at runtime.
npm install -D vitest
Add a test script to your package.json. The vitest command starts watch mode, while vitest run executes tests once and exits — useful for CI pipelines.
{
"scripts": {
"test": "vitest",
"test:run": "vitest run"
}
}
Create a vitest.config.js in your project root. This file tells Vitest which environment to use and where to find test files. The include pattern picks up both .test.js and .spec.js naming conventions.
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['**/*.test.js', '**/*.spec.js'],
},
});
Now create your first test file. This example defines a sum function and two test cases: one for the normal case and one for negative inputs. The describe block groups related tests, and it defines individual assertions using expect.
// sum.test.js
import { describe, it, expect } from 'vitest';
function sum(a, b) {
return a + b;
}
describe('sum()', () => {
it('adds two numbers correctly', () => {
expect(sum(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(sum(-1, -1)).toBe(-2);
});
});
Run your tests. The default watch mode re-runs tests when files change, giving you instant feedback during development.
Understanding Vitest globals
Vitest provides global APIs by default. No imports needed:
// These are available globally
describe('Math operations', () => {
it('multiplies numbers', () => {
expect(3 * 4).toBe(12);
});
it('checks equality', () => {
expect({ a: 1 }).toEqual({ a: 1 });
});
it('checks truthiness', () => {
expect('hello').toBeTruthy();
expect(null).toBeFalsy();
});
});
If you prefer explicit imports, disable globals in your config. This is useful in projects where you want to control exactly which test utilities are available in each file, avoiding accidental reliance on implicit global APIs.
export default defineConfig({
test: {
globals: false,
},
});
Test matchers
Vitest includes all Jest-compatible matchers plus some extras. These matchers cover equality, truthiness, numbers, strings, and arrays — the full set you need for most assertions.
import { it, expect } from 'vitest';
it('demonstrates common matchers', () => {
// Equality
expect(10).toBe(10);
expect({ a: 1 }).toEqual({ a: 1 });
// Truthiness
expect('test').toBeTruthy();
expect('').toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
// Numbers
expect(10).toBeGreaterThan(5);
expect(10).toBeLessThan(20);
expect(10).toBeGreaterThanOrEqual(10);
// Strings
expect('hello world').toContain('hello');
expect('hello').toMatch(/^hel/);
// Arrays
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
});
Async testing
Testing async code is straightforward with Vitest. You can use async/await directly in test functions, or use the resolves and rejects helpers for promise assertions. Both patterns produce clear failure messages when expectations are not met.
import { it, expect } from 'vitest';
function fetchUser(id) {
return Promise.resolve({ id, name: 'Alice' });
}
it('handles async functions', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
it('handles promises with resolves/rejects', () => {
expect(Promise.resolve('success')).resolves.toBe('success');
expect(Promise.reject(new Error('fail'))).rejects.toThrow('fail');
});
Mocking
Vitest includes a powerful mocking API accessed through vi. You can mock individual functions, entire modules, and even control timers for testing time-dependent behavior. The mock API tracks call counts, arguments, and return values automatically.
import { vi, it, expect } from 'vitest';
// Mock a function
const fetchData = vi.fn(() => Promise.resolve({ data: 'mocked' }));
it('mocks a function', async () => {
const result = await fetchData();
expect(fetchData).toHaveBeenCalled();
expect(result.data).toBe('mocked');
});
// Mock modules
vi.mock('./api', () => ({
getUser: () => Promise.resolve({ name: 'Mocked User' }),
}));
// Mock timers
it('tests setTimeout', async () => {
vi.useFakeTimers();
const callback = vi.fn();
setTimeout(callback, 1000);
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
vi.useRealTimers();
});
Snapshot testing
Snapshots capture rendered output and compare against saved versions. Vitest stores snapshots in a __snapshots__ directory and flags differences during test runs. Run vitest --update to regenerate snapshots after intentional changes.
import { it, expect } from 'vitest';
function formatUser(user) {
return 'Name: ' + user.name + ', Age: ' + user.age;
}
it('matches snapshot', () => {
const output = formatUser({ name: 'Alice', age: 30 });
expect(output).toMatchSnapshot();
});
// Update snapshots with: vitest update
Running tests
Vitest provides several ways to run tests. Watch mode re-runs relevant tests on file changes, the --coverage flag generates code coverage reports, and the -t flag filters tests by name pattern.
# Watch mode (default in dev)
npm test
# Run once
npm run test:run
# Run specific file
npx vitest run src/utils.test.js
# Run with coverage
npx vitest run --coverage
# Run tests matching a pattern
npx vitest run -t "adds two numbers"
Configuration options
Here is a comprehensive vitest.config.js example covering environment, file patterns, setup files, coverage, and timeout settings. Each option maps to a common testing need.
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Test environment
environment: 'node', // or 'jsdom', 'happy-dom'
// Glob patterns for test files
include: ['**/*.test.js', '**/*.spec.js'],
// Exclude patterns
exclude: ['**/node_modules/**', '**/dist/**'],
// Global setup
setupFiles: ['./vitest.setup.js'],
// Coverage configuration
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
// Test timeout (ms)
testTimeout: 5000,
// Hook timeout
hookTimeout: 10000,
},
});
Choosing test boundaries
Good Vitest suites are selective. Start with code that has business rules, edge cases, or behavior that is easy to break during refactors. Pure helpers usually deserve direct unit tests, while UI code often benefits from a mix of unit and component tests. A stable test suite does not try to cover every line in equal depth. Instead, it focuses on the paths that matter most to users and to future maintainers. That usually means checking normal behavior, error handling, and a few boundary values such as empty input, zero, or missing fields.
It also helps to keep one test file close to the module it covers. When the test and the source file sit together, you can see intent at a glance and update them together during refactors. Use small helper functions inside the test file when the setup starts to repeat. That keeps the assertions easy to scan and lowers the chance that setup noise hides the real failure. If a test needs a lot of fixtures, it is often a sign that the production code wants smaller pieces.
Reading failures well
When a Vitest run fails, the most useful output is the assertion message, not the stack trace alone. Shape your tests so the failure points to one behavior at a time. Prefer a few direct expectations over one large assertion that checks too much. If you are testing an object, focus on the fields that matter for the behavior under test instead of comparing every property by default. That makes failures easier to understand and reduces the work needed after a legitimate change.
It is also worth using clear test names that describe behavior in plain language. A name like returns the cached value when the key exists gives more context than works correctly. That context matters when the suite grows and a failure arrives from a file you have not opened in months. For async tests, wait for the promise you care about and avoid hidden timing assumptions. When a test depends on timers or mocks, clean them up in the same file so later tests start from a known state.
Writing maintainable suites
Long-lived suites tend to share a few habits. They keep setup close to the assertions, avoid clever test helpers that hide the behavior, and favor simple data over large fixture trees. They also treat test code as production code: names should be clear, helper functions should do one job, and repeated setup should be refactored once it starts to slow people down. If a test reads like a story from top to bottom, the next person can usually fix it with less guesswork.
When a module depends on browser APIs, file system calls, or network behavior, use mocks only as far as needed to isolate the unit under test. Mocking too much can make tests pass while the real code still breaks in integration. A balanced suite usually has a few small unit tests, a handful of integration-style checks, and maybe a snapshot or two when the output is stable and visual. That mix gives you fast feedback without losing confidence in how the pieces fit together.
Final testing note
One useful habit is to read the test file as if you were seeing the feature for the first time. If the setup or the assertions feel confusing, the next maintainer will probably feel the same way. A short comment can explain why a mock exists or why a boundary value matters, but the test body should still carry the main story on its own. That keeps the suite easy to scan when a failure lands in CI and you need to understand the problem quickly.
Vitest also rewards a steady rhythm. Run the smallest useful subset while you are editing, then run the wider suite before you call the work done. That pattern makes it easier to catch mistakes early and keeps changes from piling up into a long debugging session. The more often you see the test results while the code is fresh in your head, the easier it is to keep the suite healthy over time.
Summary
You have learned how to:
- Set up Vitest in your project
- Write tests using the Jest-compatible API
- Use matchers for assertions
- Test async code with promises
- Mock functions and modules
- Create snapshot tests
- Configure Vitest for different scenarios
Vitest speed and developer experience make it an excellent choice for modern JavaScript projects. The zero-config setup and Vite integration mean you can start testing in seconds.
In the next tutorial, we will explore testing asynchronous code in more depth.