TTeamDesk 学习手册
本章目录 展开导航 ↓

21 · 业务完整性测试:亲手验证状态、权限、并发和回滚#

第 19 章得到工单变化和历史,第 20 章解释成功与四类失败。这一章要回答更严格的问题:怎样证明记录失败时,数据库真的没有留下一个被错误推进状态的工单、一条孤立回复,或者一位被错误降级的客服?

本章提供独立测试库命令、逐个断言的读法、失败前后数据表,以及页面验收步骤。命令是供你执行的实验;文档生成和链接检查通过,不表示真实数据库用例已运行。是否执行成功应以对应测试输出为准。

1. 先把“正确”写成可观察条件#

不要从测试函数开始写,先写下这张工单必须保持的关系:

场景 正确结果 需要检查的证据
有效状态变化 新状态、新版本和对应历史共同成立 Ticket、最新 TicketEvent
路径或说明不合法 不产生有效修改 错误码、原版本、历史数量
两个旧版本请求竞争 恰好一个成功 settled 结果、最终历史
无变化分配 / 同编号回复重试 不新增假变化 版本序列、kind 序列、回复数量
写事件失败 本次相关业务写入回滚 工单数量、状态、版本、负责人、回复与成员角色
别人读历史 沿用工单的访问范围 客户归属、teamId 与 count

这些关系可称为业务不变量:它们不是某条 SQL 的写法,而是实现改变后仍应成立的事实。

“请求抛了异常”只覆盖其中一小部分。服务完全可能先提交数据,再抛一个错误;不检查数据库就发现不了。

2. 先准备可以安全制造故障的独立数据库#

需要 Docker 引擎已启动,并能运行项目的 PostgreSQL。使用单独的 teamdesk_stage4_test,不要复用你的日常工单库。

在项目根目录逐步执行:

bash
# 启动开发依赖,等待数据库健康
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:generate

db:generate 生成访问代码,不会建表;真正把现有 SQL 应用到这个库的是上一行 db:migrate。若连接错了,再漂亮的测试输出也不能说明你验收了预期环境。

本阶段不要求 Redis 执行任务;当前创建工单会保存数据库提醒待办,但不要将开发 Worker 指向这个测试库。

再运行整个阶段测试:

bash
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 组,包括同状态。

ts
// 源码节选:合法路径分支的一部分
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 章页面故事略有不同,它依次执行:

text
创建 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 操作:管理员改为处理中,客服分配负责人。

ts
// 源码节选:结果检查
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 抛错。概念上是:

sql
-- 概念示意:实际随机函数名和参数构造见测试源码,不作为手动建表命令
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 节完整文件,再可用测试名聚焦回滚:

bash
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage4_test \
pnpm exec vitest run tests/ticket-events.integration.test.ts \
-t '记录数据库写入失败' --reporter=verbose

beforeAll 仍会建立本轮用户与团队,所以这条命令不依赖前面某个 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:删掉事件唯一约束后,现有测试全绿能证明约束无用吗#

动手练习查看参考答案

参考答案:不能。现有用例覆盖的是有限调用路径,服务或并发策略未来变化后仍可能重复写同版本事件。约束提供数据库结构边界;是否删约束应有独立需求与影响分析,不能仅从几个成功测试推出结论。

第四阶段交付的证据链应是:页面意图 → 接口与资源权限 → 服务读取真实前值 → 团队锁和版本检查 → 业务与事件共同提交或回滚 → 数据库断言 → 界面读取与展示。 能说明每份证据覆盖什么、没覆盖什么,才算把原理与验证连起来。

把知识变成自己的理解

完成本章练习,再继续下一站。

TEAMDESK HANDBOOK · 从一张工单,走进全栈开发

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。