前端测试:证明用户能完成任务,而不是证明函数被调用

测试写了很多,用户还是打不开文章。常见原因是测试只检查内部 setter 调用,却没有走过真实入口。先定义用户想完成的事,再选择测试层次。

三层各自守住什么

纯函数测试覆盖搜索归一、排序、状态转移和响应解析,快且容易定位。组件测试覆盖输入、空结果、失败提示、焦点与按钮行为。端到端测试从首页进入目录再读文章,检查路由、网络、数据和渲染真正接通。

不要每层都重复相同的二十种情况。大量边界放在低层,关键集成和跨页面流程用少量高价值 E2E。截图有助于发现视觉问题,但截图正确不能证明保存确实写进数据库。

测试可见行为

下面是针对双框架练习页面的 Playwright 片段,/vue.html 是 Vue 入口,React 版改为 /react.html:

TypeScript
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 只是一个信号。还应以另一个读取入口重新获取目标记录,确认内容、版本和权限符合预期。隔离测试数据,并清楚测试是否允许写生产环境。示例数据与真实发布验收承担不同责任。

最后给失败分类:断言失败说明行为不符,环境启动失败说明测试没跑到行为,超时可能来自加载慢、选择器不对或服务故障。不要把重试后偶尔变绿当成修复;保留截图、响应状态和错误日志来定位原因。