21 · 业务完整性测试:亲手验证状态、权限、并发和回滚#
第 19 章得到工单变化和历史,第 20 章解释成功与四类失败。这一章要回答更严格的问题:怎样证明记录失败时,数据库真的没有留下一个被错误推进状态的工单、一条孤立回复,或者一位被错误降级的客服?
本章提供独立测试库命令、逐个断言的读法、失败前后数据表,以及页面验收步骤。命令是供你执行的实验;文档生成和链接检查通过,不表示真实数据库用例已运行。是否执行成功应以对应测试输出为准。
1. 先把“正确”写成可观察条件#
不要从测试函数开始写,先写下这张工单必须保持的关系:
| 场景 | 正确结果 | 需要检查的证据 |
|---|---|---|
| 有效状态变化 | 新状态、新版本和对应历史共同成立 | Ticket、最新 TicketEvent |
| 路径或说明不合法 | 不产生有效修改 | 错误码、原版本、历史数量 |
| 两个旧版本请求竞争 | 恰好一个成功 | settled 结果、最终历史 |
| 无变化分配 / 同编号回复重试 | 不新增假变化 | 版本序列、kind 序列、回复数量 |
| 写事件失败 | 本次相关业务写入回滚 | 工单数量、状态、版本、负责人、回复与成员角色 |
| 别人读历史 | 沿用工单的访问范围 | 客户归属、teamId 与 count |
这些关系可称为业务不变量:它们不是某条 SQL 的写法,而是实现改变后仍应成立的事实。
“请求抛了异常”只覆盖其中一小部分。服务完全可能先提交数据,再抛一个错误;不检查数据库就发现不了。
2. 先准备可以安全制造故障的独立数据库#
需要 Docker 引擎已启动,并能运行项目的 PostgreSQL。使用单独的 teamdesk_stage4_test,不要复用你的日常工单库。
在项目根目录逐步执行:
# 启动开发依赖,等待数据库健康
pnpm db:up
# 仅首次创建;如果该测试库已存在,跳过这一行,不要删库重建
docker compose exec -T postgres createdb -U teamdesk teamdesk_stage4_test
# 临时环境变量只影响这一条命令,不改写 .env
DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage4_test pnpm db:migrate
pnpm db:generatedb:generate 生成访问代码,不会建表;真正把现有 SQL 应用到这个库的是上一行 db:migrate。若连接错了,再漂亮的测试输出也不能说明你验收了预期环境。
本阶段不要求 Redis 执行任务;当前创建工单会保存数据库提醒待办,但不要将开发 Worker 指向这个测试库。
再运行整个阶段测试:
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage4_test \
pnpm exec vitest run tests/ticket-events.integration.test.ts --reporter=verbose若报找不到 Docker socket,先启动 Docker 引擎,再检查 docker compose ps;若报连接失败,先检查容器、5433 端口与 URL;若缺列,检查是否迁移到了同一个测试库。不要把这些环境问题通过修改业务规则“修好”。
3. 读测试夹具:它怎样建立可信前提#
打开 ticket-events.integration.test.ts,先读 describe.skipIf、beforeAll、create、history、prepare 和 afterAll,再读每个 it。
| 夹具 | 做什么 | 为什么重要 |
|---|---|---|
| describe.skipIf | 无 TEST_DATABASE_URL 时跳过 | 普通单元测试不意外连接真实库 |
| beforeAll | 创建本轮用户、甲乙团队、角色和客服 Membership | 测试不依赖你已有的账号或工单 |
| api(userId) | 用明确 ctx 调用 appRouter.createCaller | 聚焦服务端输入、权限与业务 |
| create / history | 创建客户工单、读取指定工单历史 | 使场景操作短且清楚 |
| prepare(status) | 通过合法服务路径准备起始状态 | 起点本身已有正确版本和历史 |
| afterAll | 按本轮 IDs 清理并断连接 | 避免清空整个数据库 |
createCaller({ db, userId }) 是测试在可信边界注入上下文,不是浏览器可自报身份的 HTTP API。它不经过 Cookie 传输、Origin 检查和 React 页面;这些证据不能由本文件代替。
4. 路径矩阵如何避免测试和实现一起写错#
测试单独列出允许路径集合,而不是从 allowedTransitions 导入生成预期。四个状态两两组合得到 16 组,包括同状态。
// 源码节选:合法路径分支的一部分
expect(updated.version).toBe(ticket.version + 1);
const events = await history(ticket.id);
expect(events.total).toBe(before + 1);
expect(events.items[0]).toMatchObject({
kind: 'STATUS_CHANGED',
fromStatus: from,
toStatus: to,
actorRole: 'AGENT',
reason: '状态路径测试说明',
});prepare('RESOLVED') 已经先经过 OPEN → IN_PROGRESS → RESOLVED,所以不能假设每组起点都是 v0 或只有一条历史。用 before 和 ticket.version 计算本次增量,才能测“这一次动作”而非误把夹具也算进去。
非法分支显式断言 BAD_REQUEST、历史数量不变、版本不变;它没有在每一组都直接断言 status 不变。状态保持是合理预期,但说明已有覆盖时要按真实断言说话,可在后续练习补充检查。
若错误实现把 OPEN → RESOLVED 放开,独立 permitted 集合仍认为不合法,这组就会失败。反过来,测试若直接用实现的同一集合决定答案,就可能一起放行而漏检。
5. 重复操作与伪造身份用例怎样追踪#
现有“有效修改顺序记录”用例的动作与第 19 章页面故事略有不同,它依次执行:
创建 v0 → 分配 v1 → 用有效 v1 重复同一分配(仍 v1)
→ 回复 v2 → 相同 clientRequestId 重试(仍 v2)
→ 取消分配 v3因此断言倒序 kind 为 UNASSIGNED、REPLIED、ASSIGNED、CREATED,版本为 [3, 2, 1, 0]。这是解释重复动作的证据,不能把这段结果误当成“最后状态已处理中”。
用例又以 pageSize=2 读取前两页,验证 total 一致、合并后 4 个不同事件 ID;这个过程没有新增记录,因而适合检查分页。真实用户翻页期间仍可能有人修改,不承诺跨请求固定快照。
随后构造带 actorName: '伪造管理员' 的输入,实际按管理员上下文调用,断言最新事件仍是服务端查询到的“操作管理员”。重点不是 UUID 好不好猜,而是输入字段没有成为可信操作者。
6. 并发场景:状态修改与分配抢同一个旧版本#
现有并发用例不是两个窗口恰巧同时点击,而是同一张新工单上用 Promise.allSettled 发出两项 v0 操作:管理员改为处理中,客服分配负责人。
// 源码节选:结果检查
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(
1,
);
expect((await history(ticket.id)).total).toBe(2);两个动作都能单独合法执行,但它们都基于 v0。先完成有效写入者把版本变 v1,另一请求不能覆盖。历史应只有创建和赢家的一条记录。
现有用例检查“一次成功 + 两条历史”,没有进一步为两种赢家分别断言最终 status / assignee。可以从服务推导正确结果,再作为补充练习;不要宣称这些字段断言已经写在用例中。
用固定 sleep 让 A 总是先赢,会弱化对竞争行为的验证。这里不要求某一方获胜,只要求业务边界始终成立。
7. 故障注入:让数据库拒绝写事件#
定位“记录数据库写入失败时”用例。测试先准备两张工单,再安装触发器:
| 实验前记录 | 初始事实 |
|---|---|
| ticket | OPEN / v0,未分配,只有创建历史 |
| assigned | 已分配给客服,v1 |
| 客服成员关系 | AGENT |
| before | 当前测试团队工单数量 |
触发器挂在 TicketEvent 插入前,函数内部只对本轮 teamId 抛错。概念上是:
-- 概念示意:实际随机函数名和参数构造见测试源码,不作为手动建表命令
BEFORE INSERT ON "TicketEvent"
IF NEW."teamId" = 本轮测试团队 THEN
RAISE EXCEPTION '模拟操作记录存储失败';
END IF;它确实让数据库插入失败,随后错误传回同一事务。测试并未替换业务方法,让它只“假装抛错”。函数和触发器名由随机 UUID 生成,使用 rawUnsafe 是本测试专门构造标识符的做法,不是处理用户输入的业务 SQL 范例。
触发器安装在表上,条件只让本测试团队失败,并非创建了另一张孤立的 TicketEvent 表。正因如此,独立数据库与清理很重要。
8. 逐项读回滚证据,而不只数五个 reject#
用例依次尝试创建、改状态、分配、发回复、将已分配客服降为客户。每条路径的业务写入可能已经在事务里执行过,最终都因写事件失败而回滚。
| 真正回查的断言 | 证明哪种半完成结果没有留下 |
|---|---|
| 工单 count 仍等于 before | 创建失败没有留下新工单 |
| ticket 仍 OPEN / v0 / 无负责人 | 失败状态修改与分配没有留下部分字段 |
| ticket 回复 total=0 | 插入回复后的后续失败撤销了回复 |
| ticket 历史 total=1 | 仍只有成功的初始创建历史 |
| assigned 仍关联原客服 Membership | 降级中清空负责人这一步也回滚 |
| 该 Membership 仍为 AGENT | 目标成员没有被部分降级 |
本例在成员操作中测试的是降级为客户,不是另外执行一次 removeMember;它覆盖共享成员变更路径中的关键联动,不能把未调用的动作也列成实测。
清理触发器放在 finally,无论断言是否成功都执行。若进程被强杀,finally 不一定完成;失败重跑前应确认自己测试库的残留对象,不去修改日常业务库。
拓展阅读回滚后工单编号跳了一号,算失败吗?
不一定。自增编号背后的序列取号与业务行的事务回滚不同,失败插入可能消耗号码。验收应检查记录没有留下、状态与关联一致,而不是要求 number 连续。编号空缺不能单独证明数据库漏了一张工单。
9. 分开执行实验,并如实读测试输出#
先执行第 2 节完整文件,再可用测试名聚焦回滚:
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage4_test \
pnpm exec vitest run tests/ticket-events.integration.test.ts \
-t '记录数据库写入失败' --reporter=verbosebeforeAll 仍会建立本轮用户与团队,所以这条命令不依赖前面某个 it 先跑过。聚焦运行会使其他用例因名称不匹配被跳过;检查选中用例确实 passed,别把它和“缺数据库连接导致整个组跳过”混淆。
| 输出或错误 | 应怎样处理 |
|---|---|
| 全组 skipped,没设置 TEST_DATABASE_URL | 未执行数据库验证,补配置后重跑 |
| beforeAll 连接失败 | 排查容器、端口与指定库,不算业务断言失败 |
| 缺少表 / 列 | 对同一个测试连接执行迁移与 Client 生成 |
| 无权创建函数或触发器 | 为隔离测试库提供所需能力,当前测试未完成 |
| 模拟写记录失败的业务调用抛错,用例仍 passed | 这是预期拒绝,关键看后续回滚断言 |
| 行数 / 版本 / 角色不符合断言 | 可能真的破坏原子性,沿对应事务路径检查 |
若排查时看到 SQL 日志先 UPDATE、后 ROLLBACK,不要只截 UPDATE 就判断已提交。需要看事务结束和后续真实读取。
10. 页面验收与三道需要推理的练习#
数据库用例通过后,还要回到浏览器补证据:用第 19 章新工单走四步,按第 20 章解决 / 重新打开 / 关闭;在 Network 比对更新请求与历史查询,在 Prisma Studio 只读比对最终状态。再用同团队另一客户与其他团队验证不能读取历史。
页面重点看输入提示、错误信息、冲突恢复、历史展示与 Query 刷新;这些不在 createCaller 的覆盖范围。手动并发容易被焦点刷新影响,稳定版本竞争仍交给测试。
练习 A:事件失败却 catch 后 return 成功,会在哪暴露#
动手练习查看参考答案
参考答案:先区分错误来源。如果在数据库仍可提交时吞掉应用异常,例如读取操作者姓名后的业务检查失败,前面的业务写入可能被提交,数据回查断言会暴露不一致。本章触发器抛的是 PostgreSQL 语句错误,会让当前事务进入失败状态;catch 并不能把它恢复成可提交事务,数据库仍会回滚或事务客户端报告失败。若错误被层层吞掉而接口假报成功,预期拒绝的断言也会失败。不能笼统推断“catch 一定提交”,要同时检查返回结果与真实数据。
练习 B:怎样补强非法路径与并发测试#
动手练习查看参考答案
参考答案:非法路径在原版本和历史数量之外直接检查状态保持。并发测试读取最终工单与最新事件:若状态修改赢,状态为 IN_PROGRESS 且无新增分配;若分配赢,状态仍 OPEN 且负责人正确;两种都应为 v1,仅一条新的业务事件。以上是新增断言设计,不是当前文件已有代码。
练习 C:删掉事件唯一约束后,现有测试全绿能证明约束无用吗#
动手练习查看参考答案
参考答案:不能。现有用例覆盖的是有限调用路径,服务或并发策略未来变化后仍可能重复写同版本事件。约束提供数据库结构边界;是否删约束应有独立需求与影响分析,不能仅从几个成功测试推出结论。
第四阶段交付的证据链应是:页面意图 → 接口与资源权限 → 服务读取真实前值 → 团队锁和版本检查 → 业务与事件共同提交或回滚 → 数据库断言 → 界面读取与展示。 能说明每份证据覆盖什么、没覆盖什么,才算把原理与验证连起来。