mcpField
T
testing-qa

testing-qa

github.com/jmsktm测试与质量10204 次安装40 周前更新
代码仓库

关于 testing-qa

testing-qa 是面向 Next.js 应用的测试技能,适用于编写测试、排查失败和搭建测试基础设施。内容涵盖 Playwright 端到端测试、Jest 单元测试及 React Testing Library 组件测试,并提供模拟与调试模式。

主要功能

  1. 01Playwright 端到端测试
  2. 02Jest 单元测试
  3. 03React Testing Library 组件测试
  4. 04模拟依赖与外部 API
  5. 05测试调试与可访问性检查

适用场景

  1. 01为 Next.js 用户流程编写端到端测试
  2. 02验证组件交互与异步状态
  3. 03测试 API 路由和工具函数
  4. 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 编写组件测试。

测试理念

测试金字塔
  1. 端到端测试(10%):关键用户旅程
  2. 集成测试(30%):组件交互
  3. 单元测试(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