Vitest Pattern

Configure and use Vitest for fast, modern testing in Next.js applications with TypeScript, React Testing Library, and coverage reporting.

Overview#

Vitest is a blazing-fast test runner built on Vite, providing excellent TypeScript and ESM support out of the box. It's the recommended testing framework for modern Next.js applications.

When to use:

  • Unit testing utility functions and classes
  • Component testing with React Testing Library
  • Testing Server Actions and API routes
  • Testing custom hooks

Key features:

  • Native ESM and TypeScript support
  • Jest-compatible API
  • Fast HMR-powered watch mode
  • Built-in code coverage
  • Snapshot testing

Code Example#

Setup Configuration#

1// vitest.config.ts 2import { defineConfig } from 'vitest/config' 3import react from '@vitejs/plugin-react' 4import path from 'path' 5 6export default defineConfig({ 7 plugins: [react()], 8 test: { 9 environment: 'jsdom', 10 globals: true, 11 setupFiles: ['./vitest.setup.ts'], 12 include: ['**/*.test.{ts,tsx}'], 13 coverage: { 14 provider: 'v8', 15 reporter: ['text', 'json', 'html'], 16 exclude: ['node_modules/', '*.config.*'] 17 } 18 }, 19 resolve: { 20 alias: { 21 '@': path.resolve(__dirname, './src') 22 } 23 } 24})
1// vitest.setup.ts 2import '@testing-library/jest-dom/vitest' 3import { cleanup } from '@testing-library/react' 4import { afterEach, vi } from 'vitest' 5 6afterEach(() => { 7 cleanup() 8}) 9 10// Mock next/navigation 11vi.mock('next/navigation', () => ({ 12 useRouter: () => ({ 13 push: vi.fn(), 14 replace: vi.fn(), 15 back: vi.fn() 16 }), 17 usePathname: () => '/', 18 useSearchParams: () => new URLSearchParams() 19}))

Unit Testing Functions#

1// lib/utils.test.ts 2import { describe, it, expect } from 'vitest' 3import { formatPrice, calculateDiscount, validateEmail } from './utils' 4 5describe('formatPrice', () => { 6 it('formats cents to dollars', () => { 7 expect(formatPrice(1000)).toBe('$10.00') 8 expect(formatPrice(1599)).toBe('$15.99') 9 }) 10 11 it('handles zero', () => { 12 expect(formatPrice(0)).toBe('$0.00') 13 }) 14}) 15 16describe('calculateDiscount', () => { 17 it('applies percentage discount', () => { 18 expect(calculateDiscount(100, 10)).toBe(90) 19 expect(calculateDiscount(50, 50)).toBe(25) 20 }) 21 22 it('returns original price for zero discount', () => { 23 expect(calculateDiscount(100, 0)).toBe(100) 24 }) 25}) 26 27describe('validateEmail', () => { 28 it('accepts valid emails', () => { 29 expect(validateEmail('user@example.com')).toBe(true) 30 expect(validateEmail('user+tag@domain.co.uk')).toBe(true) 31 }) 32 33 it('rejects invalid emails', () => { 34 expect(validateEmail('invalid')).toBe(false) 35 expect(validateEmail('no@domain')).toBe(false) 36 expect(validateEmail('')).toBe(false) 37 }) 38})

Testing React Components#

1// components/button.test.tsx 2import { describe, it, expect, vi } from 'vitest' 3import { render, screen, fireEvent } from '@testing-library/react' 4import { Button } from './button' 5 6describe('Button', () => { 7 it('renders with children', () => { 8 render(<Button>Click me</Button>) 9 expect(screen.getByText('Click me')).toBeInTheDocument() 10 }) 11 12 it('calls onClick when clicked', () => { 13 const handleClick = vi.fn() 14 render(<Button onClick={handleClick}>Click</Button>) 15 16 fireEvent.click(screen.getByText('Click')) 17 expect(handleClick).toHaveBeenCalledTimes(1) 18 }) 19 20 it('is disabled when loading', () => { 21 render(<Button loading>Submit</Button>) 22 expect(screen.getByRole('button')).toBeDisabled() 23 }) 24 25 it('applies variant classes', () => { 26 render(<Button variant="destructive">Delete</Button>) 27 expect(screen.getByRole('button')).toHaveClass('bg-red-500') 28 }) 29})

Testing Async Components#

1// components/user-profile.test.tsx 2import { describe, it, expect, vi } from 'vitest' 3import { render, screen, waitFor } from '@testing-library/react' 4import { UserProfile } from './user-profile' 5 6// Mock the fetch function 7vi.mock('@/lib/api', () => ({ 8 fetchUser: vi.fn() 9})) 10 11import { fetchUser } from '@/lib/api' 12 13describe('UserProfile', () => { 14 it('shows loading state initially', () => { 15 vi.mocked(fetchUser).mockImplementation(() => new Promise(() => {})) 16 17 render(<UserProfile userId="123" />) 18 expect(screen.getByText('Loading...')).toBeInTheDocument() 19 }) 20 21 it('displays user data after loading', async () => { 22 vi.mocked(fetchUser).mockResolvedValue({ 23 name: 'John Doe', 24 email: 'john@example.com' 25 }) 26 27 render(<UserProfile userId="123" />) 28 29 await waitFor(() => { 30 expect(screen.getByText('John Doe')).toBeInTheDocument() 31 expect(screen.getByText('john@example.com')).toBeInTheDocument() 32 }) 33 }) 34 35 it('shows error state on failure', async () => { 36 vi.mocked(fetchUser).mockRejectedValue(new Error('Failed')) 37 38 render(<UserProfile userId="123" />) 39 40 await waitFor(() => { 41 expect(screen.getByText('Error loading profile')).toBeInTheDocument() 42 }) 43 }) 44})

Testing Server Actions#

1// actions/posts.test.ts 2import { describe, it, expect, vi, beforeEach } from 'vitest' 3import { createPost, deletePost } from './posts' 4import { prisma } from '@/lib/db' 5import { auth } from '@/lib/auth' 6 7vi.mock('@/lib/db', () => ({ 8 prisma: { 9 post: { 10 create: vi.fn(), 11 delete: vi.fn(), 12 findUnique: vi.fn() 13 } 14 } 15})) 16 17vi.mock('@/lib/auth', () => ({ 18 auth: vi.fn() 19})) 20 21vi.mock('next/cache', () => ({ 22 revalidatePath: vi.fn() 23})) 24 25describe('createPost', () => { 26 beforeEach(() => { 27 vi.clearAllMocks() 28 }) 29 30 it('creates post for authenticated user', async () => { 31 vi.mocked(auth).mockResolvedValue({ userId: 'user-123' }) 32 vi.mocked(prisma.post.create).mockResolvedValue({ 33 id: 'post-1', 34 title: 'Test Post', 35 authorId: 'user-123' 36 }) 37 38 const formData = new FormData() 39 formData.set('title', 'Test Post') 40 41 const result = await createPost(formData) 42 43 expect(result.success).toBe(true) 44 expect(prisma.post.create).toHaveBeenCalledWith({ 45 data: expect.objectContaining({ 46 title: 'Test Post', 47 authorId: 'user-123' 48 }) 49 }) 50 }) 51 52 it('returns error for unauthenticated user', async () => { 53 vi.mocked(auth).mockResolvedValue({ userId: null }) 54 55 const formData = new FormData() 56 formData.set('title', 'Test') 57 58 const result = await createPost(formData) 59 60 expect(result.success).toBe(false) 61 expect(result.error).toBe('Not authenticated') 62 }) 63})

Testing Hooks#

1// hooks/use-debounce.test.ts 2import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 3import { renderHook, act } from '@testing-library/react' 4import { useDebounce } from './use-debounce' 5 6describe('useDebounce', () => { 7 beforeEach(() => { 8 vi.useFakeTimers() 9 }) 10 11 afterEach(() => { 12 vi.useRealTimers() 13 }) 14 15 it('returns initial value immediately', () => { 16 const { result } = renderHook(() => useDebounce('hello', 500)) 17 expect(result.current).toBe('hello') 18 }) 19 20 it('debounces value changes', () => { 21 const { result, rerender } = renderHook( 22 ({ value }) => useDebounce(value, 500), 23 { initialProps: { value: 'hello' } } 24 ) 25 26 rerender({ value: 'world' }) 27 expect(result.current).toBe('hello') // Still old value 28 29 act(() => { 30 vi.advanceTimersByTime(500) 31 }) 32 33 expect(result.current).toBe('world') // Now updated 34 }) 35})

Usage Instructions#

  1. Install dependencies: npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom
  2. Create config: Add vitest.config.ts with your settings
  3. Add setup file: Create vitest.setup.ts for global mocks and cleanup
  4. Add scripts: Add "test": "vitest" to package.json
  5. Write tests: Create *.test.ts or *.test.tsx files

Running Tests#

1# Run all tests 2npm test 3 4# Watch mode 5npm test -- --watch 6 7# Coverage report 8npm test -- --coverage 9 10# Run specific file 11npm test -- button.test.tsx 12 13# Run tests matching pattern 14npm test -- --grep "createPost"

Best Practices#

  1. Use globals - Enable globals: true for cleaner test files
  2. Mock external dependencies - Mock Next.js modules in setup
  3. Clean up after tests - Use cleanup() in afterEach
  4. Group related tests - Use describe blocks for organization
  5. Test behavior, not implementation - Focus on what components do
  6. Use async utilities - Use waitFor for async operations
  7. Keep tests fast - Mock slow dependencies like databases