关于 testing-qa
testing-qa 是面向 Next.js 应用的测试技能,适用于编写测试、排查失败和搭建测试基础设施。内容涵盖 Playwright 端到端测试、Jest 单元测试及 React Testing Library 组件测试,并提供模拟与调试模式。
主要功能
- 01Playwright 端到端测试
- 02Jest 单元测试
- 03React Testing Library 组件测试
- 04模拟依赖与外部 API
- 05测试调试与可访问性检查
适用场景
- 01为 Next.js 用户流程编写端到端测试
- 02验证组件交互与异步状态
- 03测试 API 路由和工具函数
- 04排查测试失败并模拟依赖
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ jmsktm/ claude-settings --skill testing-qa安装后在 Claude Code 中输入/testing-qa确认已加载。
详细介绍
测试与 QA 技能
概述
此技能可帮助你使用 Playwright 编写 Next.js 应用的端到端测试,使用 Jest 编写单元测试,并使用 React Testing Library 编写组件测试。
测试理念
测试金字塔
- 端到端测试(10%):关键用户旅程
- 集成测试(30%):组件交互
- 单元测试(60%):独立函数和工具
测试内容
- 应该:测试行为,而非实现方式
- 应该:测试用户交互和结果
- 应该:测试错误状态和边缘情况
- 应该:测试无障碍性
- 不应该:测试内部实现细节
- 不应该:测试第三方库
- 不应该:过度测试简单的展示型组件
Playwright 端到端测试
设置
// 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',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
基本端到端测试
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('should sign up new user', async ({ page }) => {
await page.goto('/signup')
// Fill form
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'password123')
await page.fill('input[name="confirmPassword"]', 'password123')
// Submit
await page.click('button[type="submit"]')
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard')
// Verify welcome message
await expect(page.getByText('Welcome')).toBeVisible()
})
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'wrongpassword')
await page.click('button[type="submit"]')
// Verify error message
await expect(page.getByText('Invalid credentials')).toBeVisible()
})
})
高级 Playwright 模式
// Page Object Model
// e2e/pages/login.page.ts
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.page.fill('input[name="email"]', email)
await this.page.fill('input[name="password"]', password)
await this.page.click('button[type="submit"]')
}
async getErrorMessage() {
return await this.page.locator('[role="alert"]').textContent()
}
}
// Usage
test('login with page object', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('[email protected]', 'password')
await expect(page).toHaveURL('/dashboard')
})
// Fixtures for authenticated state
// e2e/fixtures.ts
export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
// Login
await page.goto('/login')
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'password')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
await use(page)
},
})
// Usage
test('dashboard test', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
// Test authenticated functionality
})
常见 Playwright 模式
// Wait for network
await page.waitForResponse(resp => resp.url().includes('/api/items'))
// Test file upload
await page.setInputFiles('input[type="file"]', 'path/to/file.jpg')
// Test download
const downloadPromise = page.waitForEvent('download')
await page.click('button:has-text("Download")')
const download = await downloadPromise
await download.saveAs('/path/to/save')
// Mock API responses
await page.route('**/api/items', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ items: [] }),
})
})
// Screenshot for debugging
await page.screenshot({ path: 'debug.png', fullPage: true })
// Test responsive design
await page.setViewportSize({ width: 375, height: 667 }) // iPhone size
// Test accessibility
const accessibilityScanResults = await new AxeBuilder({ page }).analyze()
expect(accessibilityScanResults.violations).toEqual([])
组件测试
设置 React Testing Library
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
}
// jest.setup.js
import '@testing-library/jest-dom'
基本组件测试
// components/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './button'
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByText('Click me')).toBeDisabled()
})
it('applies variant styles', () => {
render(<Button variant="destructive">Delete</Button>)
const button = screen.getByText('Delete')
expect(button).toHaveClass('bg-red-600')
})
})
测试异步组件
// components/user-profile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './user-profile'
// Mock fetch
global.fetch = jest.fn()
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('displays loading state initially', () => {
(fetch as jest.Mock).mockImplementation(() =>
new Promise(() => {}) // Never resolves
)
render(<UserProfile userId="123" />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('displays user data when loaded', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ name: 'John Doe', email: '[email protected]' }),
})
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('[email protected]')).toBeInTheDocument()
})
})
it('displays error message when fetch fails', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Failed to fetch'))
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('Error loading user')).toBeInTheDocument()
})
})
})
测试表单
// components/contact-form.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactForm } from './contact-form'
describe('ContactForm', () => {
it('validates required fields', async () => {
render(<ContactForm />)
const submitButton = screen.getByText('Submit')
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Email is required')).toBeInTheDocument()
expect(screen.getByText('Message is required')).toBeInTheDocument()
})
})
it('submits form with valid data', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn()
render(<ContactForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText('Email'), '[email protected]')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByText('Submit'))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: '[email protected]',
message: 'Test message',
})
})
})
it('disables submit button while submitting', async () => {
const user = userEvent.setup()
render(<ContactForm />)
await user.type(screen.getByLabelText('Email'), '[email protected]')
await user.type(screen.getByLabelText('Message'), 'Test message')
const submitButton = screen.getByText('Submit')
await user.click(submitButton)
expect(submitButton).toBeDisabled()
})
})
测试 Hook
// hooks/use-counter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './use-counter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments count', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('resets count', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})
单元测试
测试工具函数
// lib/utils.test.ts
import { formatDate, slugify, truncate } from './utils'
describe('formatDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-01-15')
expect(formatDate(date)).toBe('January 15, 2024')
})
it('handles invalid date', () => {
expect(formatDate(new Date('invalid'))).toBe('Invalid Date')
})
})
describe('slugify', () => {
it('converts string to slug', () => {
expect(slugify('Hello World')).toBe('hello-world')
expect(slugify('Next.js App!')).toBe('next-js-app')
})
it('removes special characters', () => {
expect(slugify('Test@#$%')).toBe('test')
})
})
describe('truncate', () => {
it('truncates long strings', () => {
const text = 'This is a very long text'
expect(truncate(text, 10)).toBe('This is a...')
})
it('does not truncate short strings', () => {
const text = 'Short'
expect(truncate(text, 10)).toBe('Short')
})
})
测试 API 路由
// app/api/items/route.test.ts
import { GET, POST } from './route'
import { NextRequest } from 'next/server'
describe('/api/items', () => {
describe('GET', () => {
it('returns items', async () => {
const request = new NextRequest('http://localhost:3000/api/items')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.items).toBeDefined()
expect(Array.isArray(data.items)).toBe(true)
})
})
describe('POST', () => {
it('creates new item', async () => {
const request = new NextRequest('http://localhost:3000/api/items', {
method: 'POST',
body: JSON.stringify({ title: 'Test Item' }),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.item).toBeDefined()
expect(data.item.title).toBe('Test Item')
})
it('validates required fields', async () => {
const request = new NextRequest('http://localhost:3000/api/items', {
method: 'POST',
body: JSON.stringify({}),
})
const response = await POST(request)
expect(response.status).toBe(400)
})
})
})
模拟
模拟 Supabase
// __mocks__/supabase.ts
export const createClient = jest.fn(() => ({
from: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
single: jest.fn().mockResolvedValue({
data: { id: '1', title: 'Test' },
error: null,
}),
})),
auth: {
getUser: jest.fn().mockResolvedValue({
data: { user: { id: '123', email: '[email protected]' } },
error: null,
}),
},
}))
模拟 Next.js 路由器
// Mock useRouter
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => '/test-path',
useSearchParams: () => new URLSearchParams(),
}))
模拟外部 API
// Use MSW (Mock Service Worker)
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.get('/api/items', (req, res, ctx) => {
return res(
ctx.json({
items: [
{ id: '1', title: 'Item 1' },
{ id: '2', title: 'Item 2' },
],
})
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
测试清单
端到端测试
- 测试关键用户旅程(注册、登录、结账)
- 在多种浏览器上测试
- 在移动端视口下测试
- 测试错误状态
- 在网络较慢时测试
- 在禁用 JavaScript 的情况下测试(适用时)
- 测试无障碍性
组件测试
- 测试渲染
- 测试用户交互
- 测试属性
- 测试条件渲染
- 测试错误状态
- 测试加载状态
- 测试无障碍性
单元测试
- 测试纯函数
- 测试边缘情况
- 测试错误处理
- 使用各种输入进行测试
- 测试边界条件
常见测试模式
使用测试 ID 实现可靠的选择
// Component
<button data-testid="submit-button">Submit</button>
// Test
const button = screen.getByTestId('submit-button')
无障碍查询(优先使用)
// By role (best)
screen.getByRole('button', { name: /submit/i })
// By label
screen.getByLabelText('Email')
// By text
screen.getByText('Welcome')
// By placeholder
screen.getByPlaceholderText('Enter email')
测试加载状态
it('shows loading then content', async () => {
render(<Component />)
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument()
// Wait for content
await waitFor(() => {
expect(screen.getByText('Content')).toBeInTheDocument()
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})
})
测试错误边界
it('renders error boundary on error', () => {
const spy = jest.spyOn(console, 'error').mockImplementation()
render(
<ErrorBoundary>
<ComponentThatThrows />
</ErrorBoundary>
)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
spy.mockRestore()
})
调试测试
调试输出
import { screen, render } from '@testing-library/react'
// Print component tree
render(<Component />)
screen.debug()
// Print specific element
screen.debug(screen.getByRole('button'))
Playwright 调试
# Run in debug mode with browser
npx playwright test --debug
# Run specific test
npx playwright test auth.spec.ts --debug
# Run with headed browser
npx playwright test --headed
常见问题
找不到元素:
- 检查元素是否存在:
screen.getByText与screen.queryByText - 对异步元素使用
findBy:screen.findByText - 检查无障碍树:
screen.debug()
时序问题:
- 对异步更新使用
waitFor - 使用
findBy查询(内置等待) - 需要时增加超时时间
状态更新未反映出来:
- 手动更新状态时,将操作包装在
act()中 - 使用
userEvent替代fireEvent,以模拟更真实的事件
性能测试
// Lighthouse CI
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
},
},
},
}
何时使用此技能
在以下情况下调用此技能:
- 编写新测试
- 调试测试失败
- 设置测试基础设施
- 测试特定场景(表单、异步操作、身份验证)
- 实施端到端测试
- 测试无障碍性
- 模拟依赖项
- 提高测试覆盖率
评论
支持 Markdown