toolchains/typescript/testing/vitest/SKILL.md
Vitest - Modern TypeScript testing framework with Vite-native performance, ESM support, and TypeScript-first design
npx skillsauth add bobmatnyc/claude-mpm-skills vitestInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Vitest is a next-generation test framework powered by Vite, designed for modern TypeScript/JavaScript projects. It provides blazing-fast test execution through HMR-based test running, native ESM support, and first-class TypeScript integration.
Key Features:
Installation:
npm install -D vitest
# TypeScript types (usually auto-detected)
npm install -D @vitest/ui # Optional: UI mode
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // Use describe/it/expect globally
environment: 'node', // or 'jsdom' for DOM testing
coverage: {
provider: 'v8', // or 'istanbul'
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.test.ts',
'**/*.spec.ts',
],
},
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],
},
});
tsconfig.json:
{
"compilerOptions": {
"types": ["vitest/globals"] // For global describe/it/expect
}
}
Alternative (without globals):
import { describe, it, expect } from 'vitest';
{
"scripts": {
"test": "vitest run", // CI mode (single run)
"test:watch": "vitest", // Watch mode (default)
"test:ui": "vitest --ui", // UI mode
"test:coverage": "vitest run --coverage"
}
}
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('Calculator', () => {
let calculator: Calculator;
beforeEach(() => {
calculator = new Calculator();
});
it('adds two numbers correctly', () => {
const result = calculator.add(2, 3);
expect(result).toBe(5);
});
it('handles negative numbers', () => {
expect(calculator.add(-5, 3)).toBe(-2);
});
});
import { describe, it, expectTypeOf, assertType } from 'vitest';
interface User {
id: number;
name: string;
email: string;
}
describe('Type Safety', () => {
it('ensures correct types', () => {
const user: User = {
id: 1,
name: 'Alice',
email: '[email protected]',
};
// Type assertions
expectTypeOf(user.id).toBeNumber();
expectTypeOf(user.name).toBeString();
expectTypeOf(user).toMatchTypeOf<User>();
// Assert type at compile time
assertType<User>(user);
});
it('checks function return types', () => {
function getUser(): User {
return { id: 1, name: 'Bob', email: '[email protected]' };
}
expectTypeOf(getUser).returns.toMatchTypeOf<User>();
});
});
import { describe, it, expect, vi } from 'vitest';
import { fetchUser } from './api';
import { UserService } from './UserService';
// Mock entire module
vi.mock('./api', () => ({
fetchUser: vi.fn(),
}));
describe('UserService', () => {
it('fetches user data', async () => {
const mockUser = { id: 1, name: 'Alice' };
vi.mocked(fetchUser).mockResolvedValue(mockUser);
const service = new UserService();
const user = await service.getUser(1);
expect(fetchUser).toHaveBeenCalledWith(1);
expect(user).toEqual(mockUser);
});
});
import { describe, it, expect, vi } from 'vitest';
class Logger {
log(message: string) {
console.log(message);
}
}
describe('Logger Spy', () => {
it('tracks method calls', () => {
const logger = new Logger();
const spy = vi.spyOn(logger, 'log');
logger.log('Hello');
logger.log('World');
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith('Hello');
expect(spy).toHaveBeenLastCalledWith('World');
spy.mockRestore(); // Restore original implementation
});
});
import { describe, it, expect, vi } from 'vitest';
describe('Mock Implementation', () => {
it('provides custom mock implementation', () => {
const mockFn = vi.fn((x: number) => x * 2);
expect(mockFn(5)).toBe(10);
expect(mockFn).toHaveBeenCalledWith(5);
// Change implementation
mockFn.mockImplementation((x: number) => x + 10);
expect(mockFn(5)).toBe(15);
// One-time implementation
mockFn.mockImplementationOnce((x: number) => 100);
expect(mockFn(5)).toBe(100);
expect(mockFn(5)).toBe(15); // Back to default
});
});
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('Timer Mocking', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('fast-forwards time', () => {
const callback = vi.fn();
setTimeout(callback, 1000);
vi.advanceTimersByTime(500);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(1);
});
it('runs all timers', async () => {
const callback = vi.fn();
setTimeout(callback, 1000);
setTimeout(callback, 2000);
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledTimes(2);
});
});
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
npm install -D jsdom # For DOM environment
vitest.config.ts (React):
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
});
src/test/setup.ts:
import '@testing-library/jest-dom';
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
afterEach(() => {
cleanup();
});
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter Component', () => {
it('renders initial count', () => {
render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('increments counter on button click', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('calls onChange callback', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<Counter initialCount={0} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(onChange).toHaveBeenCalledWith(1);
});
});
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
});
it('increments counter', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('resets counter', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
npm install -D @vue/test-utils @vitejs/plugin-vue
npm install -D happy-dom # Faster alternative to jsdom
vitest.config.ts (Vue):
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'happy-dom',
setupFiles: './src/test/setup.ts',
},
});
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';
describe('Counter.vue', () => {
it('renders initial count', () => {
const wrapper = mount(Counter, {
props: { initialCount: 5 },
});
expect(wrapper.text()).toContain('Count: 5');
});
it('increments on button click', async () => {
const wrapper = mount(Counter, {
props: { initialCount: 0 },
});
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('Count: 1');
});
it('emits update event', async () => {
const wrapper = mount(Counter, {
props: { initialCount: 0 },
});
await wrapper.find('button').trigger('click');
expect(wrapper.emitted('update')).toBeTruthy();
expect(wrapper.emitted('update')?.[0]).toEqual([1]);
});
});
import { describe, it, expect } from 'vitest';
describe('Async Operations', () => {
it('resolves promises', async () => {
const result = await Promise.resolve(42);
expect(result).toBe(42);
});
it('rejects promises', async () => {
await expect(Promise.reject(new Error('Failed'))).rejects.toThrow('Failed');
});
it('uses resolves matcher', async () => {
await expect(Promise.resolve(42)).resolves.toBe(42);
});
});
import { describe, it, expect, vi } from 'vitest';
async function fetchData(id: number): Promise<string> {
const response = await fetch(`/api/data/${id}`);
return response.json();
}
describe('Async Functions', () => {
it('fetches data successfully', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve('data'),
} as Response)
);
const data = await fetchData(1);
expect(data).toBe('data');
expect(fetch).toHaveBeenCalledWith('/api/data/1');
});
it('handles fetch errors', async () => {
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
await expect(fetchData(1)).rejects.toThrow('Network error');
});
});
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { UserCard } from './UserCard';
describe('UserCard Snapshots', () => {
it('matches snapshot', () => {
const { container } = render(
<UserCard name="Alice" email="[email protected]" />
);
expect(container.firstChild).toMatchSnapshot();
});
it('matches inline snapshot', () => {
const user = { id: 1, name: 'Bob' };
expect(user).toMatchInlineSnapshot(`
{
"id": 1,
"name": "Bob",
}
`);
});
});
import { describe, it, expect } from 'vitest';
expect.addSnapshotSerializer({
test: (val) => val && typeof val.toISOString === 'function',
print: (val) => `Date(${(val as Date).toISOString()})`,
});
describe('Custom Serializers', () => {
it('serializes dates consistently', () => {
const data = {
timestamp: new Date('2024-01-01T00:00:00.000Z'),
user: 'Alice',
};
expect(data).toMatchSnapshot();
});
});
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
exclude: [
'node_modules/',
'dist/',
'**/*.test.ts',
'**/*.spec.ts',
'**/*.config.ts',
'**/types/',
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
all: true, // Include untested files in coverage report
},
},
});
# Generate coverage
npx vitest run --coverage
# Coverage with UI
npx vitest --coverage --ui
# Specific threshold enforcement
npx vitest run --coverage --coverage.lines=90
Vitest provides Jest-compatible API:
// Jest syntax works in Vitest
import { describe, it, expect, jest } from 'vitest';
// Note: Use 'vi' instead of 'jest' for new code
import { describe, it, expect, vi } from 'vitest';
// Both work, but vi is preferred
const mockFn = vi.fn(); // Preferred
const mockFn2 = jest.fn(); // Also works
1. Update Dependencies:
npm uninstall jest @types/jest ts-jest
npm install -D vitest @vitest/ui
2. Update package.json:
{
"scripts": {
"test": "vitest run", // Was: jest
"test:watch": "vitest" // Was: jest --watch
}
}
3. Replace jest.config.js with vitest.config.ts:
// Old: jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
// New: vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
4. Update Test Files:
// Change imports
- import { jest } from '@jest/globals';
+ import { vi } from 'vitest';
// Update mocks
- jest.fn()
+ vi.fn()
- jest.spyOn()
+ vi.spyOn()
- jest.mock()
+ vi.mock()
import { describe, it, expect } from 'vitest';
describe.concurrent('Parallel Tests', () => {
it('test 1', async () => {
await slowOperation();
expect(true).toBe(true);
});
it('test 2', async () => {
await slowOperation();
expect(true).toBe(true);
});
// Both tests run in parallel
});
import { describe, it, expect, beforeEach } from 'vitest';
interface TestContext {
user: { id: number; name: string };
api: ApiClient;
}
describe<TestContext>('With Context', () => {
beforeEach((context) => {
context.user = { id: 1, name: 'Alice' };
context.api = new ApiClient();
});
it<TestContext>('uses context', ({ user, api }) => {
expect(user.name).toBe('Alice');
expect(api).toBeDefined();
});
});
import { expect } from 'vitest';
expect.extend({
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling;
return {
pass,
message: () =>
pass
? `expected ${received} not to be within range ${floor} - ${ceiling}`
: `expected ${received} to be within range ${floor} - ${ceiling}`,
};
},
});
// Usage
expect(100).toBeWithinRange(90, 110);
vitest run for CI, not watch mode❌ Not using CI mode in CI/CD:
// WRONG - watch mode hangs in CI
"test": "vitest"
// CORRECT - single run
"test": "vitest run"
✅ Correct approach:
{
"scripts": {
"test": "vitest run", // CI-safe
"test:watch": "vitest", // Development
"test:ui": "vitest --ui" // Debugging
}
}
❌ Forgetting to await async tests:
// WRONG - test passes before assertion
it('fetches data', () => {
fetchData().then(data => {
expect(data).toBeDefined(); // Never runs!
});
});
// CORRECT
it('fetches data', async () => {
const data = await fetchData();
expect(data).toBeDefined();
});
❌ Not cleaning up mocks:
// WRONG - mocks leak between tests
it('test 1', () => {
vi.spyOn(console, 'log');
// No cleanup!
});
// CORRECT
import { afterEach } from 'vitest';
afterEach(() => {
vi.restoreAllMocks();
});
❌ Using wrong environment:
// WRONG - testing DOM in node environment
test: {
environment: 'node', // Can't test React components!
}
// CORRECT
test: {
environment: 'jsdom', // For React/Vue components
}
When using Vitest, consider these complementary skills:
// Type-safe test factories with generics
function createMockData<T extends Record<string, unknown>>(
defaults: T,
overrides?: Partial<T>
): T {
return { ...defaults, ...overrides };
}
const mockUser = createMockData(
{ id: 1, name: 'Test', email: '[email protected]' },
{ name: 'Alice' }
);
// Runtime validation with Zod in tests
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
test('API returns valid user', async () => {
const response = await fetch('/api/user/1');
const data = await response.json();
// Runtime validation + type inference
const user = UserSchema.parse(data);
expect(user.email).toContain('@');
});
// Const type parameters for literal inference
const createTestConfig = <const T extends Record<string, unknown>>(config: T): T => config;
const testEnv = createTestConfig({ mode: 'test', debug: false });
// Type: { mode: "test"; debug: false } (literals preserved)
// React Testing Library with Vitest
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, test, expect, vi } from 'vitest';
// Component testing
describe('UserProfile', () => {
test('renders user information', () => {
const user = { id: 1, name: 'Alice', email: '[email protected]' };
render(<UserProfile user={user} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
test('handles form submission', async () => {
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
const user = userEvent.setup();
await user.type(screen.getByLabelText('Name'), 'Bob');
await user.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ name: 'Bob' });
});
});
});
// Hook testing
import { renderHook, act } from '@testing-library/react';
test('useCounter hook increments', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
RED → GREEN → REFACTOR Cycle:
RED Phase: Write Failing Test
test('should authenticate user with valid credentials', () => {
const user = { username: 'alice', password: 'secret123' };
const result = authenticate(user);
expect(result.isAuthenticated).toBe(true);
// This fails because authenticate() doesn't exist yet
});
GREEN Phase: Make It Pass
function authenticate(user: User): AuthResult {
// Minimum code to pass the test
if (user.username === 'alice' && user.password === 'secret123') {
return { isAuthenticated: true };
}
return { isAuthenticated: false };
}
REFACTOR Phase: Improve Code
function authenticate(user: User): AuthResult {
// Clean up while keeping tests green
const hashed = hashPassword(user.password);
const storedUser = database.getUser(user.username);
return {
isAuthenticated: storedUser?.passwordHash === hashed
};
}
Test Structure: Arrange-Act-Assert (AAA)
test('creates user successfully', async () => {
// Arrange: Set up test data
const userData = { username: 'alice', email: '[email protected]' };
// Act: Perform the action
const user = await createUser(userData);
// Assert: Verify outcome
expect(user.username).toBe('alice');
expect(user.email).toBe('[email protected]');
});
Vitest-Specific TDD Features:
// Watch mode with HMR (instant feedback)
// vitest --watch
// UI mode for visual debugging
// vitest --ui
// Run only changed tests
// vitest --changed
// Benchmark mode for performance testing
import { bench } from 'vitest';
bench('authenticate performance', () => {
authenticate({ username: 'alice', password: 'secret' });
});
[Full TypeScript, React, and TDD workflows available in respective skills if deployed together]
development
Optimize web performance using Core Web Vitals, modern patterns (View Transitions, Speculation Rules), and framework-specific techniques
development
Best practices for documenting APIs and code interfaces, eliminating redundant documentation guidance per agent.
development
Comprehensive API design patterns covering REST, GraphQL, gRPC, versioning, authentication, and modern API best practices
development
Visual verification workflow for UI changes to accelerate code review and catch ...