跳转至

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:

location / {
    try_files $uri $uri/ /index.html;
}

案例 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。

检查项 怎么测 预期 常见缺陷
过期 把 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

相关阅读