前端测试:证明用户能完成任务,而不是证明函数被调用
测试写了很多,用户还是打不开文章。常见原因是测试只检查内部 setter 调用,却没有走过真实入口。先定义用户想完成的事,再选择测试层次。
三层各自守住什么
纯函数测试覆盖搜索归一、排序、状态转移和响应解析,快且容易定位。组件测试覆盖输入、空结果、失败提示、焦点与按钮行为。端到端测试从首页进入目录再读文章,检查路由、网络、数据和渲染真正接通。
不要每层都重复相同的二十种情况。大量边界放在低层,关键集成和跨页面流程用少量高价值 E2E。截图有助于发现视觉问题,但截图正确不能证明保存确实写进数据库。
测试可见行为
下面是针对双框架练习页面的 Playwright 片段,/vue.html 是 Vue 入口,React 版改为 /react.html:
import { test, expect } from '@playwright/test'
test('读者可以筛选并恢复文章列表', async ({ page }) => {
await page.goto('/vue.html')
await page.getByLabel('搜索文章').fill('React')
await expect(page.getByRole('listitem')).toHaveCount(1)
await expect(page.getByRole('listitem')).toContainText('React')
await page.getByLabel('搜索文章').fill('找不到的词')
await expect(page.getByText('没有匹配的文章,换个词试试。')).toBeVisible()
await page.getByLabel('搜索文章').fill('')
await expect(page.getByRole('listitem')).toHaveCount(2)
})
label 和 role 更接近用户理解,也顺便推动可访问性。避免依赖“第三个 div 下面第二个 span”这种脆弱定位。等待结果出现而不是固定 sleep 两秒,网络稍慢时才不会随机失败。
故障演练比只点成功更有价值
| 注入故障 | 应看到什么 |
|---|---|
| 网络离线 | 可理解的失败提示与重试入口 |
| 返回空数组 | 空状态,不是假报错误 |
| A 请求晚于 B | B 的结果不被 A 覆盖 |
| 保存返回 403 | 保留草稿,说明权限问题 |
| 同时编辑同一文章 | 提示版本冲突或采用明确合并策略 |
| 刷新深层路由 | 仍显示正确文章 |
测试中可拦截请求以稳定制造延迟,但这只能证明前端应对该响应的行为。至少一条集成检查应访问真实测试数据源,避免模拟接口已经和实际契约脱节。
和本项目对应起来
仓库现有 Vitest 覆盖 Markdown、目录、文件名等逻辑,Playwright 覆盖公开浏览流程。新增课程需要额外证明:首页能进入新模块、章节正文与本地一致、目录锚点完整、代码块可读、手机宽度不溢出。数据库导入成功不等于读者已经能读到。
练习与参考
先写一个会失败的旧请求覆盖测试,再修实现使它通过。随后故意删掉清理逻辑,确认测试真的会红;这比只看“全绿”更能证明测试有用。
从一条需求推导测试,而不是从函数名推导
需求是“搜不到时仍能恢复列表”。可观察步骤是输入不存在的词、看见空状态、清空、重新看见文章。这个测试不需要知道组件内部用了 computed 还是 useMemo,因此重构实现后仍有价值。
如果要验证请求竞态,模拟 A 慢、B 快,并断言最终只显示 B;然后故意取消旧请求失效检查,确认测试失败。这能证明测试确实覆盖所担心的问题,而不是因为当前测试数据太简单碰巧通过。
测试数据库写入时,接口返回 200 只是一个信号。还应以另一个读取入口重新获取目标记录,确认内容、版本和权限符合预期。隔离测试数据,并清楚测试是否允许写生产环境。示例数据与真实发布验收承担不同责任。
最后给失败分类:断言失败说明行为不符,环境启动失败说明测试没跑到行为,超时可能来自加载慢、选择器不对或服务故障。不要把重试后偶尔变绿当成修复;保留截图、响应状态和错误日志来定位原因。