Skip to content

组件测试与可访问性 ​

问题:按钮存在,为什么流程仍可能坏掉 ​

断言页面里有一个 <button>,不能证明用户能找到它、用键盘激活它,或在输入非法时获得可理解的错误。更稳的测试从角色和可访问名称找到控件,执行用户动作,再检查页面与业务回调的可观察结果。

工具边界与完整配置 ​

下面是受控的第三方工具分支,不是 React 零依赖示例。已有 Vite React 19 项目还需安装:

bash
npm install --save-dev vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
  • Vitest 是测试运行器;jsdom 在 Node 中模拟 DOM,但不做真实布局、绘制或读屏。
  • React Testing Library 提供渲染和按角色查询;user-event 模拟更接近用户的输入序列;jest-dom 增加 DOM 断言。
  • React 19 的 act 从 react 导出。Testing Library 的渲染和交互工具已经包装 act;不要从已弃用的 react-dom/test-utils 导入它,也不要使用其中已移除的其他工具。

在现有 vite.config.js 中合并 test 字段:

js
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: './src/test/setup.js',
  },
})
js
// src/test/setup.js
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'

afterEach(() => cleanup())

这里显式导入测试 API,不依赖 globals: true。每个测试结束后,cleanup 卸载本次创建的 React 树并移除测试容器;否则第一条测试的表单可能留到第二条,按角色查询会找到两个“邮箱”。测试之间的隔离也是运行契约,不能靠只运行单条测试回避。

并在 package.json 的 scripts 中提供可重复入口:

json
{
  "scripts": {
    "test": "vitest run"
  }
}

完整组件与行为测试 ​

jsx
// src/SignupForm.jsx
import { useState } from 'react'

export default function SignupForm({ onSave }) {
  const [email, setEmail] = useState('')
  const [error, setError] = useState('')

  function submit(event) {
    event.preventDefault()
    const normalized = email.trim()
    if (!normalized.includes('@')) {
      setError('请输入有效邮箱')
      return
    }
    setError('')
    onSave(normalized)
  }

  return (
    <form onSubmit={submit} noValidate>
      <label htmlFor="signup-email">邮箱</label>
      <input
        id="signup-email"
        name="email"
        type="email"
        value={email}
        aria-invalid={Boolean(error)}
        aria-describedby={error ? 'email-error' : undefined}
        onChange={event => setEmail(event.target.value)}
      />
      {error && <p id="email-error" role="alert">{error}</p>}
      <button type="submit">创建账户</button>
    </form>
  )
}
jsx
// src/SignupForm.test.jsx
import { expect, test, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SignupForm from './SignupForm'

test('空邮箱显示关联错误且不保存', async () => {
  const user = userEvent.setup()
  const onSave = vi.fn()
  render(<SignupForm onSave={onSave} />)

  await user.click(screen.getByRole('button', { name: '创建账户' }))

  const input = screen.getByRole('textbox', { name: '邮箱' })
  expect(input).toHaveAttribute('aria-invalid', 'true')
  expect(input).toHaveAccessibleDescription('请输入有效邮箱')
  expect(screen.getByRole('alert')).toHaveTextContent('请输入有效邮箱')
  expect(onSave).not.toHaveBeenCalled()
})

test('键盘提交时保存规范化邮箱并清除错误', async () => {
  const user = userEvent.setup()
  const onSave = vi.fn()
  render(<SignupForm onSave={onSave} />)

  const input = screen.getByRole('textbox', { name: '邮箱' })
  await user.click(screen.getByRole('button', { name: '创建账户' }))
  expect(screen.getByRole('alert')).toHaveTextContent('请输入有效邮箱')
  expect(onSave).not.toHaveBeenCalled()
  await user.type(input, '  ada@example.com  ')
  await user.keyboard('{Enter}')

  expect(onSave).toHaveBeenCalledOnce()
  expect(onSave).toHaveBeenCalledWith('ada@example.com')
  expect(screen.queryByRole('alert')).not.toBeInTheDocument()
  expect(input).toHaveAttribute('aria-invalid', 'false')
  expect(input).not.toHaveAttribute('aria-describedby')
})

运行 npm test -- SignupForm.test.jsx。把 <label> 删除后,按名称查找文本框的断言应失败;把非法分支误写成仍调用 onSave,否定断言会失败。再临时删除成功路径的 setError(''),第二条测试会在恢复检查处失败:它先制造错误,再修正输入,验证的是状态转换,单独测试一次合法输入发现不了这个遗漏。实验后恢复组件。

本例的 includes('@') 只是便于观察流程的教学规则,不是完整邮箱校验。noValidate 关闭浏览器原生提交拦截,使测试和页面都执行同一条示例规则;真实应用应明确前后端共享的输入契约。

同步查询、等待出现与确认消失 ​

getByRole 表示“现在必须存在且唯一”,找不到会立刻抛错;queryByRole 允许不存在,适合断言错误已消失。findByRole 返回 Promise,会等待异步更新使元素出现。选择依据是状态何时变,不是为了让失败的测试多等一会儿。

事件处理器同步设置表单错误时,等待 user.click 后即可查询。保存操作若等待网络,则需要先检查 pending,再主动完成请求,最后等待成功提示。不要把一次真实的一秒网络请求换成“睡两秒”:响应更慢会偶发失败,响应更快又浪费时间。练习使用可手动兑现的 Promise,能把完成顺序固定下来。

从一次测试追踪到组件边界 ​

onSave 是父组件交给表单的能力。这里用 vi.fn() 替身记录调用次数和参数,因此不必搭建账户后端,就能证明表单传出了规范化输入。若把 useState 自身替换掉,测试可能与组件内部写法绑死,重构状态结构便会误报。

组件测试证明“用户动作 → 页面与回调”的这一段;它不证明服务器真正创建了账户。后一段应在请求层或集成测试中验证。每一层保留真实的本层行为,只替换跨层的不可控依赖,失败时才容易知道是哪条契约断了。

为什么按角色查询能推动可访问结构 ​

getByRole('textbox', { name: '邮箱' }) 依赖 label 形成可访问名称。错误文本再通过 aria-describedby 与输入关联;role="alert" 让动态错误具备通知语义。测试并不证明某个具体读屏软件一定正确朗读,但会防止重构时把这些连接悄悄拆掉。

浏览器手工分支 ​

自动化之后仍在真实浏览器完成一次:只用 Tab/Shift+Tab 到达输入和按钮;Enter 提交;检查焦点是否可见;放大到 200% 后内容是否可操作;用至少一种目标平台读屏确认标签和错误通知。jsdom 没有真实布局、CSS 可见性、浏览器默认验证气泡或辅助技术树,不能替代这一步。

练习:阻止重复提交 ​

把 onSave 改为返回 Promise。提交期间按钮显示“提交中…”并禁用;重复 Enter 不得再次调用;成功后显示 role="status" 的“账户已创建”;拒绝时显示 role="alert" 的错误并恢复按钮。用一个可手动兑现的 Promise 测试 pending、重复提交、成功和失败四条路径。

核对第 01 篇答案

下一篇 ​

性能剖析与渲染预算会把“感觉卡”改成可重复采样与解释。

用心记录每一步成长