GraphQL 契约测试
概述
测试位于层与层之间的接缝上,当一侧移动而另一方未同意时就会失败——这是应用于边界的测试即控制(tests-as-a-control)。核心规则:对契约——消费者依赖的数据形状和访问语义——进行断言,绝不要针对查询字符串本身。 因格式化的查询编辑而失败的测试是一种变更检测反模式;因保证的形状或权限变动而失败的测试才是一种控制。
§1 — GraphQL 查询契约
- 每个操作仅作为类型文档在已知位置定义一次,导出以供导入。 UI 导入它;测试导入完全相同的制品——绝不复写或重新输入。将查询复制到测试中会悄然杀死契约(编辑 UI 查询不再破坏测试)。DRY 原则承担起了责任。
- 它通过构造在双向中起作用:
- 数据库 → UI 漂移,在编译时。
graphql-codegen根据实时模式生成 TypeScript 类型。重命名或删除一个已使用的字段,tsc会在每个使用点和共享文档上失败——在用户看到空白面板之前构建就失败了。这就是为什么强制要求针对实时模式进行 codegen。 - UI → 数据库 / 权限漂移,在运行时。 共享文档通过真实的调用路径,在角色上下文中对种子测试数据库运行,并对返回的形状和访问结果进行断言——而不是查询文本。
- 数据库 → UI 漂移,在编译时。
- 契约测试和 RLS 角色矩阵是同一个测试框架。 将同一份文档通过 PostGraphile 以角色 A(期望数据)和角色 B(期望被拒绝/空数据)运行。一个机制证明三件事:查询仍然匹配模式,UI 假设仍然成立,RLS 仍然强制执行边界。
实际例子——真实 bug 如何被捕获
- 数据库→UI: 一次迁移移除了
equipment.is_active。Codegen 根据新模式重新生成;生成的类型失去该字段;tsc在共享文档和每个使用该字段的组件上失败——在构建时、合并前就被捕获。 没有测试断言“查询文本 == X”。 - UI→数据库: 一名开发者拓宽了 UI 查询以拉取
cancellation_reason(RLS 仅向高级角色暴露它)。以普通成员身份运行的运行时测试断言普通成员可见的形状,现在得到了 null/被拒绝,并失败——强制要求回答“我们是否打算暴露这个?”
§2 — 路由契约和冒烟测试
- 路由从单一来源枚举。 每个路由都有一个冒烟测试:无错误渲染 + 显示最小必需元素集(一个标题、一个地标、一个关键控件——不是详细的交互)。
- 添加/删除路由会强制匹配的测试变更 — 新 URL 缺少冒烟测试就不完整;删除的 URL 会删除其测试。URL 从不悄然出现或消失。
- 明确不进行详细的 UI/交互测试 — 有意的,范围受限。问题是“这个 URL 能否工作并渲染核心内容”,别无他求。
强制执行: CI 中的 graphql-codegen + tsc(阻塞)用于编译时部分;CI 中针对种子数据库的运行时契约 + 路由冒烟测试用于运行时部分。
完整原理说明:宪法第十五条,打包在 engineering-constitution/references/engineering-constitution.md 中。