Web 功能测试检查清单¶
这份清单不按“页面”组织,而按状态与边界组织。主流程能跑通只说明代码写完了,下面这些地方才决定线上会不会出事。所有示例基于 Chrome 120 / Firefox 121 / Safari 17.2、Node 20、Playwright 1.41。
一、表单¶
必填与空白¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 空值提交 | 直接点提交,不发请求 | 前端拦截,必填项高亮,焦点落到第一个错误项 | 只有 toast 没有字段定位,用户不知道哪一项错 |
| 纯空格 | 输入 3 个半角空格 | 视为空,拦截 | 前端 trim() 了、后端没 trim,接口直接落库 |
| 零宽字符 | 输入 \u200B(零宽空格) |
视为空 | String.trim() 不处理 \u200B,脏数据入库 |
| 全角空格 | 输入 \u3000 |
视为空 | 后端按字节长度校验通过,展示时行高异常 |
| 绕过前端 | curl 直接打接口,字段传空串 | 后端返回 400 + 明确字段名 | 后端信任前端,落库成功 |
\u200B 这一类是真实高频问题:ES 规范的 trim() 只去掉 WhiteSpace 和 LineTerminator,零宽空格属于格式字符,不在其中。
// 浏览器控制台验证
' \u200B'.trim().length // 1,说明没被清掉
/^\s*$/.test('\u200B') // false
// 正确做法:按业务白名单过滤,而不是依赖 trim
const clean = v.replace(/[\u200B-\u200D\uFEFF\u3000]/g, '').trim();
长度与字符¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 边界长度 | 输入 N-1、N、N+1 个字符 | N 通过,N+1 被拦截并提示剩余字数 | 前端 maxlength 与后端 varchar(N) 单位不一致 |
| emoji | 输入 😀 重复到上限 | 前后端提示一致 | JS .length 按 UTF-16 计 2,用户以为能输 20 个,实际 10 个 |
| 中文输入法 | 中文输入到上限后继续打拼音 | 拼音不被吞,可正常选词 | maxlength 在组合态截断,输入法丢字 |
| 特殊字符 | ' " < > & % \ / |
正常保存,展示时转义 | 昵称含 <script> 在后台列表执行,存储型 XSS |
| 换行 | 单行输入框粘贴多行文本 | 截断或替换为空格 | 数据库存了 \n,导出 Excel 串行 |
# 直接验证后端长度校验(绕过 maxlength)
curl -s -o /dev/null -w '%{http_code}\n' -X POST 'https://example.com/api/user/profile' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"nickname":"'"$(printf 'a%.0s' {1..300})"'"}'
# 预期 400;返回 200 说明后端无长度校验
重复提交与回车¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 双击提交 | 200ms 内连点两次 | 只产生 1 条记录,按钮置灰 | 请求发出后才置灰,两次都打到了服务端 |
| 慢网重复提交 | 3G 限速下连续点 | 幂等,返回同一结果 | 第二单覆盖第一单或产生两条订单 |
| 回车提交 | 单输入框内按 Enter | 提交一次 | 多输入框表单回车不提交;或误触“删除”按钮 |
| 输入法回车 | 中文输入态按 Enter 选词 | 只选词,不提交 | 未判断 isComposing,选词即提交 |
| 刷新重提 | 提交成功后按 F5 | 不重复提交 | 未做 PRG,弹“确认重新提交表单” |
Enter 的默认行为由 HTML 规范决定:表单只有一个“可提交”控件时才隐式提交;有多个输入框时不会。但把按钮写成 <button> 而不写 type,默认就是 submit,于是“取消”按钮也会提交。
// Playwright:双击 + 输入法回车
import { test, expect } from '@playwright/test';
test('重复提交只落一单', async ({ page }) => {
await page.goto('/order/create');
await page.getByLabel('收货人').fill('张三');
const btn = page.getByRole('button', { name: '提交订单' });
await Promise.all([btn.click(), btn.click({ force: true })]); // 并发双击
await expect(page.getByText('订单创建成功')).toBeVisible();
const count = await page.evaluate(async () => {
const r = await fetch('/api/order?page=1&pageSize=10');
return (await r.json()).total;
});
expect(count).toBe(1);
});
// 业务侧校验输入法组合态
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.isComposing || e.keyCode === 229)) {
e.preventDefault(); // 阻止选词时提交
}
});
案例 1:零宽空格导致昵称为空。 某后台允许昵称必填,前端 if (!nickname.trim()) 拦截。测试同学用输入法输入一个零宽字符提交,接口 200,列表页该行昵称为空、编辑页显示空白。根因是 Go 端 strings.TrimSpace 同样不处理 \u200B,两边都以为“非空”。修复是在入库前做字符白名单过滤,并对 char_length(trim(nickname))=0 加数据库约束。
二、链接与路由¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 死链 | 抓页面全部 <a href> 逐个请求 |
无 4xx/5xx | 运营配的活动链接已下线 |
| SPA 刷新 | 在 /order/1002 按 F5 |
200 并渲染详情 | history 模式未配 try_files,直接 404 |
| 外链安全 | 检查 target="_blank" |
带 rel="noopener noreferrer" |
无 noopener,外链页可通过 window.opener 改本页 |
| 大小写 | 手动把 /Order/Detail 改成小写 |
301 或正常 | 本地 Windows 不区分大小写,Linux 上 404 |
| 尾斜杠 | /list 与 /list/ |
统一跳转,不重复内容 | 两种 URL 各自渲染,埋点分裂 |
| 锚点 | #section 定位 |
滚动到对应位置 | 固定头部遮挡标题 |
# 死链扫描(含重定向后的最终状态码)
for u in $(curl -s https://example.com/sitemap.xml | grep -o '<loc>[^<]*' | sed 's/<loc>//'); do
code=$(curl -s -o /dev/null -L -w '%{http_code}' "$u")
[ "$code" = "200" ] || echo "$code $u"
done
Nginx 部署 SPA 的最小配置,缺这一行就是上线即 404:
案例 2:分享链接 404。 灰度环境用 npm run build 后把 dist 丢到 Nginx,首页正常,但从 App 分享出去的 /activity/detail?id=88 全部 404,因为服务器上没有这个物理路径。加 try_files 后恢复。附带问题是 index.html 被 CDN 缓存 7 天,导致发版后仍加载旧 index.html 引用已删除的 hash 资源,控制台报 GET /static/js/main.3f2a.js net::ERR_ABORTED 404,页面白屏。index.html 必须 Cache-Control: no-cache。
三、Cookie 与 Session¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 过期 | 把 Max-Age 改小后等待 |
到期后跳登录,回跳原页面 | 过期后接口 401 但前端不跳转,页面空白 |
| 属性 | DevTools → Application → Cookies | HttpOnly、Secure、SameSite 符合预期 |
登录态 Cookie 可被 JS 读取,XSS 直接盗号 |
| 跨站 | 从外部站点 POST 到本站 | SameSite=Lax 下不带 Cookie |
第三方支付回跳后掉登录 |
| 禁用 Cookie | Chrome 设置阻止全部 Cookie 后登录 | 明确提示“需启用 Cookie” | 无限 302,ERR_TOO_MANY_REDIRECTS |
| 多标签 | A 标签退出,B 标签继续操作 | B 标签随之失效 | B 标签内存中的 token 仍可用,直到刷新 |
| 容量 | 写入 5KB Cookie | 不写入或截断可见 | JWT 超 4096 字节被静默截断,验签失败 |
SameSite 从 Chrome 80 起默认 Lax,这是很多“本地正常、线上掉登录”的源头:本地 localhost 是同站,线上跨站 POST 不带 Cookie。
案例 3:JWT 塞 Cookie 被截断。 登录后偶发 401 Invalid signature,刷新几次又正常。抓包发现 Set-Cookie: token=eyJhbGci... 长度 4.3KB,浏览器实际只存了前 4096 字节。根因是把用户权限列表一起签进了 JWT。修复为只放 uid 和 exp,权限查缓存;Cookie 名改为 __Host-token,强制 Secure; Path=/; 且不带 Domain,顺带防子域覆盖。
# 观察 Set-Cookie 全量属性
curl -sI -X POST 'https://example.com/api/login' \
-H 'Content-Type: application/json' -d '{"u":"demo","p":"demo"}' | grep -i '^set-cookie'
// Playwright:无 Cookie 环境下的行为
const ctx = await browser.newContext({ storageState: undefined });
await ctx.clearCookies();
const page = await ctx.newPage();
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/); // 预期被踢回登录
await expect(page.getByText('登录已过期')).toBeVisible();
四、上传与下载¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 大小上限 | 传 1MB / 10MB / 100MB | 超限前端提示或后端 413 | Nginx 默认 client_max_body_size 1m,返回 413 HTML,前端 JSON.parse 崩 |
| 类型伪造 | 把 .exe 改名 .jpg 上传 |
服务端按文件头拒绝 | 只校验扩展名,恶意文件进对象存储 |
| 双后缀 | a.php.jpg |
拒绝或重命名为安全名 | 保存原始文件名,Apache 解析成 PHP |
| 文件名注入 | ../../etc/passwd |
落盘路径被规范化 | 路径穿越,写到 Web 目录外 |
| 中文名下载 | 下载“测试报告.pdf| 文件名正确 | 缺filename*=UTF-8''`,Safari 得到乱码 |
||
| 断点续传 | curl -r 0-1023 |
206 + Content-Range |
服务端忽略 Range 返回 200 全量,视频拖动失效 |
| 空文件 | 上传 0 字节 | 拒绝并提示 | 生成 0 字节附件,预览页报错 |
| 并发同名 | 两人同时传同名文件 | 各自独立存储 | 后者覆盖前者 |
# 断点续传验证
curl -sI -H 'Range: bytes=0-1023' 'https://example.com/files/report.pdf' | head -n 5
# 期望:HTTP/1.1 206 Partial Content / Content-Range: bytes 0-1023/1048576
五、分页与排序¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 非法页码 | page=0、-1、abc、999999 |
400 或回落第 1 页 | 500(NumberFormatException),或 page=0 时 offset 溢出返回全表 |
| 边界页 | 第 1 页、最后一页、空结果 | 页码与总数一致 | 删除末页最后一条后仍停留在第 5 页,显示“暂无数据” |
| 每页条数 | pageSize=100000 |
服务端强制上限 | 一次查出 20 万行,接口 8s 超时 |
| 排序稳定 | 同创建时间的 30 条分 3 页取 | 三页并集无重复无遗漏 | 无 tie-breaker,同值行顺序随机,翻页重复/漏数据 |
| 空值排序 | 字段为 NULL 的记录 | 与产品约定一致 | MySQL ASC 时 NULL 在前,PostgreSQL 默认在后 |
-- 不稳定的写法
SELECT id, title, created_at FROM article ORDER BY created_at DESC LIMIT 20 OFFSET 40;
-- 稳定写法:加唯一键兜底
SELECT id, title, created_at FROM article ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 40;
案例 4:翻页出现重复文章。 列表按 created_at DESC 排序,运营批量导入的 30 篇文章时间精确到秒相同。第 2 页出现了第 1 页的两条,另一条从未出现在任何页。MySQL 对同值行的顺序取决于执行计划,加索引后顺序还会变。补上 id DESC 后消失,同时把分页改为游标方式,深分页 LIMIT 100000,20 的 1.2s 降到 8ms。
六、搜索¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 通配符 | 搜 %、_ |
按字面匹配 | LIKE '%' 返回全表,用户以为搜到了全部 |
| SQL 注入 | 搜 ' OR 1=1-- |
正常当关键字处理 | 拼接 SQL,-- 后注释掉条件 |
| ES 特殊符 | 搜 a-b、(test) |
200 并返回结果 | query_string 解析失败,400 query_shard_exception |
| 空关键字 | 直接点搜索 | 不请求或返回默认列表 | 空串触发全表扫描 |
| 无结果 | 搜 zzzzzz |
空态文案 + 推荐/纠错 | 白屏,无任何提示 |
| 回显 XSS | 搜 <img src=x onerror=alert(1)> |
转义展示 | 结果页 <title> 执行脚本 |
| 大小写/全半角 | 搜 ABC 与 abc |
与产品约定一致 | 排序规则 utf8mb4_general_ci 与 _bin 不一致,结果不同 |
-- 错误:用户输入的 % 变成通配符
SELECT * FROM goods WHERE name LIKE CONCAT('%', #{kw}, '%');
-- 正确:转义后再拼
SELECT * FROM goods WHERE name LIKE CONCAT('%', REPLACE(REPLACE(#{kw}, '\\', '\\\\'), '%', '\\%'), '%') ESCAPE '\\';
搜索词还会进入这些地方,逐个检查:搜索历史(localStorage 明文,共用设备泄露)、<title>、分享卡片、埋点参数(URL 长度超限被截断)。
七、后退与刷新¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| bfcache | 详情页 → 返回列表 | 数据不过期或自动刷新 | Safari 从缓存恢复,仍显示已删除的数据 |
| 表单保留 | 填写一半后返回再前进 | 内容保留 | 输入全丢,用户重填 |
| 未保存提示 | 编辑后点返回 | 弹确认 | 直接退出,草稿丢失且无提示 |
| 刷新筛选 | 带筛选条件刷新 | 条件从 URL 恢复 | 筛选存在内存,刷新回到默认 |
| 滚动位置 | 列表滚到第 50 条进详情再返回 | 回到原位置 | 回到顶部 |
Safari 的 bfcache 恢复不会触发 load,要用 pageshow 判断:
window.addEventListener('pageshow', (e) => {
if (e.persisted) { // 从 bfcache 恢复
refreshList(); // 重新拉取,避免展示陈旧数据
}
});
beforeunload 只有在页面获得过“粘性用户激活”(点击、按键)后才可能弹窗,脚本自动触发会被浏览器忽略——这一点常被误判为“提示没生效”。
八、并发编辑¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 丢失更新 | A、B 同开一条记录,B 后保存 | 后保存者收到 409 并提示冲突 | 直接覆盖,A 的修改无声消失 |
| 乐观锁 | 修改 version 后再提交 |
影响行数 0 → 409 | 只查 version 不校验更新结果 |
| 重复保存 | 连续两次提交同一表单 | 第二次幂等 | 生成两条版本记录 |
| 死锁 | 两个事务反序更新两行 | 有重试,用户无感 | 直接 500 Deadlock found when trying to get lock |
UPDATE doc SET content = ?, version = version + 1
WHERE id = ? AND version = ?;
-- 应用侧判断 affected_rows = 0 → 返回 409 "文档已被他人修改"
Spring Data JPA 抛的是 ObjectOptimisticLockingFailureException,Hibernate 原生信息是 Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect)。这个异常必须映射成 409 而不是 500,否则前端无法区分“系统坏了”和“该刷新了”。
九、权限可见性¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 菜单隐藏 | 普通账号登录 | 看不到管理入口 | 只是 CSS 隐藏,DOM 里仍有链接 |
| 接口越权 | 用普通 token 请求管理接口 | 403 | 返回 200,前端隐藏等于没做 |
| 水平越权 | A 的 token 访问 B 的订单 ID | 403/404 | 只校验登录不校验归属 |
| 权限变更 | 移除角色后不重新登录 | 立即失效或 ≤1 个 token 周期 | JWT 内嵌 roles,15 分钟内仍是管理员 |
| 枚举 | 遍历 ID 看返回差异 | 统一 404 | 403 说明存在,404 说明不存在,可枚举 |
# 水平越权:换成 B 的资源 ID,用 A 的 token
curl -s -o /dev/null -w '%{http_code}\n' 'https://example.com/api/order/1002' \
-H 'Authorization: Bearer <A_TOKEN>'
# 预期 403 或 404;200 且返回他人订单即越权
权限判定要写在数据访问层(WHERE user_id = ?),而不是在 Controller 里先查后比。前者天然防越权,后者漏一个分支就出洞。
十、国际化¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 缺失文案 | 切到未翻译语言 | 回落默认语言,不显示 key | 页面出现 order.detail.title |
| 日期 | 切 en-US / en-GB / zh-CN | 格式随语言变化 | new Date('2026-10-22 10:00:00') 在 Safari 得到 Invalid Date |
| 时区 | 系统时区改为 UTC-5 | 显示本地时间 | 后端返回无时区字符串,前端按本地解析,差 13 小时 |
| 数字 | 1000.5 | 1,000.5 / 1.000,5 |
手写 toFixed(2) 与千分位混用 |
| 复数 | 1 item / 2 items | 正确复数形式 | 只做 {n} items,出现 1 items |
| 长文案 | 德语(比英语长约 30%) | 按钮不截断、不换行错位 | 固定宽度按钮文字溢出 |
| RTL | dir="rtl" |
布局镜像 | 用 margin-left 而非 margin-inline-start,箭头方向反了 |
| 排序 | 中文列表按名称排 | 按拼音 | 依赖数据库排序规则,utf8mb4_general_ci 与预期不符 |
// 时区安全的日期解析:只认 ISO 带时区,或显式拼装
new Date('2026-10-22T10:00:00+08:00'); // 所有浏览器一致
new Date('2026-10-22 10:00:00'); // Safari 返回 Invalid Date
十一、错误页¶
| 检查项 | 怎么测 | 预期 | 常见缺陷 |
|---|---|---|---|
| 404 | 访问不存在路径 | 自定义 404,状态码是 404 | 返回 200 + 空页(soft 404),监控与 SEO 全失真 |
| 500 | 构造后端异常 | 友好错误页 + 追踪 ID | 直接把 Java 堆栈吐给用户 |
| 静态资源 404 | 删掉某个 js 后访问 | 控制台明确 404 | 返回 index.html,报 Uncaught SyntaxError: Unexpected token '<' |
| 接口错误格式 | 后端异常时看响应体 | JSON + 统一错误码 | 返回 HTML 错误页,前端 Unexpected token < in JSON at position 0 |
| 502/504 | 停掉后端 | 网关错误页 | 浏览器裸奔,用户以为断网 |
案例 5:静态资源 404 返回 HTML。 发版后部分用户白屏,控制台 Uncaught SyntaxError: Unexpected token '<' (at main.js:1:1)。原因是 Nginx 把所有未命中路径都 try_files ... /index.html,旧的 hash 资源请求也返回了 HTML。修复是对 /static/ 单独配置 try_files $uri =404;,并对 index.html 关闭强缓存。
十二、执行顺序与坑位汇总¶
建议顺序:冒烟主流程 → 表单与接口校验(curl 直连)→ 会话与权限 → 上传下载 → 分页搜索 → 后退刷新与并发 → 错误页 → 国际化抽检。
| 坑位 | 表现 | 判定标准 |
|---|---|---|
| 只测前端 | 页面拦截了,接口仍可写 | 绕过 UI 的请求必须同样被拒 |
| 只测正常值 | 边界全靠用户发现 | 每个字段都有 N-1/N/N+1 三条用例 |
| 忽略状态残留 | 上次的筛选带到下次 | 每次操作后 URL、内存、缓存三者一致 |
| 把 200 当成功 | 业务码 500 但 HTTP 200 | 断言业务码,不只看状态码 |
| 不记录环境 | 缺陷无法复现 | 记录版本、浏览器、账号、时间、请求 ID |