组件测试与可访问性
问题:按钮存在,为什么流程仍可能坏掉
断言页面里有一个 <button>,不能证明用户能找到它、用键盘激活它,或在输入非法时获得可理解的错误。更稳的测试从角色和可访问名称找到控件,执行用户动作,再检查页面与业务回调的可观察结果。
工具边界与完整配置
下面是受控的第三方工具分支,不是 React 零依赖示例。已有 Vite React 19 项目还需安装:
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 字段:
// 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',
},
})// 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 中提供可重复入口:
{
"scripts": {
"test": "vitest run"
}
}完整组件与行为测试
// 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>
)
}// 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、重复提交、成功和失败四条路径。
下一篇
性能剖析与渲染预算会把“感觉卡”改成可重复采样与解释。