| name | testing |
| description | Testing with Vitest, Jest, and Playwright. Use for unit tests, integration tests, E2E tests, and coverage reports. |
| allowed-tools | Bash, Read, Edit |
| user-invocable | true |
testing
Testing with Vitest (unit/integration) and Playwright (E2E).
Vitest (Recommended for Vite/Next.js)
Installation
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
})
vitest.setup.ts
import '@testing-library/jest-dom'
Commands
npx vitest
npx vitest run
npx vitest run --coverage
npx vitest run src/utils
npx vitest run -t "test name"
npx vitest --ui
package.json scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}
Writing Tests
Basic Test
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
describe('Calculator', () => {
it('adds two numbers', () => {
expect(1 + 2).toBe(3)
})
it('handles edge cases', () => {
expect(0 + 0).toBe(0)
expect(-1 + 1).toBe(0)
})
})
React Component Test
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Button } from './Button'
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
it('calls onClick when clicked', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
Async Test
import { describe, it, expect, vi } from 'vitest'
import { fetchUser } from './api'
describe('API', () => {
it('fetches user data', async () => {
const user = await fetchUser(1)
expect(user).toMatchObject({
id: 1,
name: expect.any(String),
})
})
it('handles errors', async () => {
await expect(fetchUser(-1)).rejects.toThrow('User not found')
})
})
Mocking
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { sendEmail } from './email'
import * as api from './api'
vi.mock('./api', () => ({
fetchUser: vi.fn(),
}))
describe('Email', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('sends email to user', async () => {
vi.mocked(api.fetchUser).mockResolvedValue({
id: 1,
email: 'test@example.com'
})
await sendEmail(1, 'Hello')
expect(api.fetchUser).toHaveBeenCalledWith(1)
})
})
Spying
import { vi, describe, it, expect } from 'vitest'
describe('Logger', () => {
it('logs to console', () => {
const spy = vi.spyOn(console, 'log')
console.log('test message')
expect(spy).toHaveBeenCalledWith('test message')
spy.mockRestore()
})
})
Testing Hooks
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('increments counter', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
})
Matchers
expect(value).toBe(expected)
expect(value).toEqual(expected)
expect(value).toStrictEqual(expected)
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()
expect(value).toBeGreaterThan(3)
expect(value).toBeLessThan(5)
expect(value).toBeCloseTo(0.3, 5)
expect(value).toMatch(/pattern/)
expect(value).toContain('substring')
expect(array).toContain(item)
expect(array).toHaveLength(3)
expect(array).toContainEqual({ id: 1 })
expect(obj).toHaveProperty('key')
expect(obj).toMatchObject({ a: 1 })
expect(() => fn()).toThrow()
expect(() => fn()).toThrow('error message')
expect(promise).rejects.toThrow()
expect(value).toMatchSnapshot()
expect(value).toMatchInlineSnapshot()
expect(element).toBeInTheDocument()
expect(element).toBeVisible()
expect(element).toBeDisabled()
expect(element).toHaveTextContent('text')
expect(element).toHaveAttribute('href', '/path')
expect(element).toHaveClass('active')
Playwright (E2E)
Installation
npm init playwright@latest
playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
Commands
npx playwright test
npx playwright test --headed
npx playwright test --ui
npx playwright test login.spec.ts
npx playwright test --project=chromium
npx playwright show-report
npx playwright codegen
E2E Test Example
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('user can login', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('Welcome')
})
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'wrong@example.com')
await page.fill('input[name="password"]', 'wrongpass')
await page.click('button[type="submit"]')
await expect(page.locator('.error')).toBeVisible()
await expect(page.locator('.error')).toContainText('Invalid credentials')
})
})
Page Object Model
import { Page, Locator } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.locator('input[name="email"]')
this.passwordInput = page.locator('input[name="password"]')
this.submitButton = page.locator('button[type="submit"]')
}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
test('login with page object', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('test@example.com', 'password')
await expect(page).toHaveURL('/dashboard')
})
Coverage
Vitest Coverage
npx vitest run --coverage
Coverage Thresholds (vitest.config.ts)
export default defineConfig({
test: {
coverage: {
provider: 'v8',
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
})
CI/CD Integration
GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run test:run
- run: npm run test:coverage
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
Best Practices
- Test behavior, not implementation
- Use descriptive test names
- Arrange-Act-Assert pattern
- One assertion per test (when possible)
- Mock external dependencies
- Keep tests fast and isolated
- Use fixtures for shared setup
- Test edge cases and errors