PostHog 测试编写实战指南:从真实事故形态目录反推「值得写」的测试
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
PostHog 的测试技能文档(writing-tests)沉淀了一份独一无二的资产:一份从合并的fix:/revert:PR 中逐条提取、并与生产事故交叉验证的bug 形态目录(mistakes-we-make.md)。本篇文章以这份目录为骨架,结合仓库源码与测试用例,系统讲解 PostHog 团队如何判断"一个测试是否值得写、应该写在哪一层",并逐类给出可复制的测试形态与真实事故案例,帮助你为自己的改动快速定位最廉价的回归防线。
读完本文,你将掌握:PostHog 反复踩坑的五大"廉价测试可拦截"的 bug 形态(错误分类、空输入崩溃、跨租户泄漏、时区错误、HogQL 类型强转)、四类"单元测试救不了"的真实故障、以及两套判断工具——测试金字塔和"两问门禁"。
这份目录从哪来:先有事故,后有测试
文档开头交代了目录的生成方法,这是它与泛泛而谈的测试指南最大的区别:
- 来源是真实的合并记录:逐条来自已合并的
fix:和revert:PR,且每个案例都在diff 层面核对过——这个 PR 是否真的补了回归测试、补在哪一层(单元 / Django TestCase / ClickHouse / Playwright)。 - 与生产事故交叉验证:不仅看 PR,还对照重复出现的生产事故,确保列出的都是"真的发生过"的失败模式。
因此它的使用方式很明确:用这份目录去瞄准一个我们真正撞过的失败模式,而不是虚构的假设。找到你的改动命中的形态,在标注的层级写测试;如果改动对不上目录里的任何形态、又不是真正的新行为,就要怀疑这个测试是否值得存在。
文档开篇给出了两条"头条结论",是全篇的判断纲要:
- 大多数能用廉价测试拦截的 bug 是边界与契约错误——错误被错误分类、null/畸形输入、跨租户泄漏、非 UTC 时间——它们被拦截在金字塔底部。
- 一些真实且代价高昂的失败模式无法用廉价单元测试覆盖(查询性能、迁移时序、异步收尾挂起、重构回归、资源耗尽)。为它们硬写单元测试只会带来虚假的安全感。动手写之前,先分清你面对的是哪一种。
这两条结论与 SKILL.md 中的"测试金字塔"一脉相承。金字塔的每一级大约比下一级慢/脆一个数量级:
纯函数 / 单元 → kea logic 测试 → Django TestCase → ClickHouse 支撑的测试 → Playwright e2e 最廉价 最昂贵追求的是比例而非上限:底部铺满廉价测试,顶部极少昂贵测试。"想要更多覆盖?在底部加。"而当某个逻辑难以廉价测试时,那是一个设计信号——应该抽取而不是升级测试层级。
廉价测试就能拦截的 bug——放心写
可重试错误 vs 不可重试错误的误分类
数据仓库导入源把永久性失败(认证失效、集成被删除、密码过期)映射成可重试错误,于是 Temporal 无限重试、刷爆错误追踪;或者反过来,一次瞬时抖动被判定为终止错误,静默禁用了客户的同步。这是fix:PR 中最常见的簇,也是测试收益最好的形态。
- 在哪一层拦截:纯单元测试,直接断言某个源把给定的异常字符串映射到了正确的(非)可重试类别。不需要 Temporal、不需要 ClickHouse,且双向都要断言——既断言"该终止的确实终止",也断言"该重试的确实重试"。
PostHog 的真实案例:
- PR #63677(Salesforce):已删除的 OAuth 集成抛出
Integration not found,而错误映射表没有匹配到这个字符串,于是它被当成可重试错误无限重试。修复测试同时断言:Integration not found是非可重试的,且Read timed out仍然是可重试的。 - PR #63681(Snowflake):
Specified password has expired不在非可重试集合里,密码过期被无限重试。修复把它加入非可重试集合。 - PR #63798(Postgres,反向案例):
SSL connection has been closed unexpectedly被错误地标记为非可重试,导致探测途中的一次掉线就永久禁用同步。修复保持其可重试性——这正是"双向断言"要防住的反向方向。
源码佐证:这套错误分类在仓库中确实是以"字符串匹配异常类名"的方式实现的。utils.py 中的handle_non_retryable_errors装饰器包裹批导出 activity,捕获异常后检查e.__class__.__name__ in non_retryable_error_types:命中则返回携带错误的BatchExportResult(终止,不重试);未命中则重新抛出(交给 Temporal 重试)。这正是文档所说"把某个异常字符串映射到正确的类别"的底层实现,也解释了为什么字符串写错/漏写一个类名就会产生目录里那些事故——代码注释里还留着一个 TODO:未来应该用异常类而不是字符串。
每个目标端都维护着自己的NON_RETRYABLE_ERROR_TYPES列表,例如 postgres_batch_export.py 中逐条注明了原因:NotNullViolation(重试无益)、UniqueViolation(用户表上的唯一约束与批导出的重复数据冲突)、StringDataRightTruncation(VARCHAR 列太小)等;而对应的make_retryable_with_exponential_backoff(retryable_exceptions=(psycopg.OperationalError, psycopg.errors.ConnectionTimeout))则明确把连接类瞬时错误留在可重试侧——与 PR #63798 的修复方向一致。Temporal 工作流里再通过RetryPolicy(non_retryable_error_types=[...])(见 batch_exports.py)兜底。写这种测试时,直接对着这份列表断言字符串映射,是最快也最贴近事故的方式。
Null / 空 / 畸形输入 → 崩溃
代码假定某个值存在或形态正确(数据库字段、JSON 请求体、group type),结果在遇到坏值时直接崩溃而不是优雅降级。这类问题很常见,相对其上线后的代价,防护成本极其低廉。
- 在哪一层拦截:能触达边界的最廉价层级——用坏值直接做纯单元调用,或用 Django
TestCase打端点。不需要 ClickHouse。
真实案例:
- PR #62757:序列化实验时处理 null 指标。一个
NULL的metrics列流到了enumerate(None),导致一行坏数据就把整个实验列表打成 500。测试把 null/空组合参数化后分别打到列表端点和详情端点。 - PR #11792:事件 properties 不是 JSON 时应返回 400 而不是 500。对非 dict 的
properties做下标访问抛出了TypeError;修复把它转成ValueError(映射为 400),而单元测试直接调用函数并断言这一行为——层级比端点测试还要低一格。 - PR #61076:为
wordPluralize防御 null 的group_type。null 的 group type 曾导致功能开关页面渲染崩溃;测试同时写在两个地方:helper 层(Jest)和产出该 null 的转换器层(Python 单元测试)。
源码佐证:前端工具函数 strings.ts 中的wordPluralize,其 Jest 测试 strings.test.ts 正是文档描述的双层写法——常规词形(company→companies、person→people、child→children)之外,专门断言了wordPluralize('')返回空串、wordPluralize(null as unknown as string)也返回空串。注意这里的形态:把坏值传进函数本身,而不是绕过它,这正是"在边界层测试边界"的范本。
租户隔离 / 作用域(IDOR)
某个查询或端点没有按请求方 team/org 限定作用域,导致跨租户读泄漏,甚至跨租户写。
- 在哪一层拦截:PostHog 标准的 IDOR 覆盖形态是一个 Django
TestCase——创建两个 org,断言 org B 既看不到也改不了 org A 的行。
真实案例:
- PR #61901:补作用域检查。价值体现在那些具体的两 org 测试里,例如
test_plugin_unused_does_not_leak_other_orgs(断言其他 org 的 plugin id 不在结果中),以及 dashboard-collaborator 的跨项目配对测试(断言 404、且权限行未被改动)。 - 文档特别提醒一个实操要点:这个 PR 本身捆绑了若干无关修复,所以引用时要引用具体测试,而不是裸引用 PR——这既是检索经验,也是评审经验。
时区 / 非 UTC 正确性
代码假定世界是 UTC 的:日期分桶、分页游标、图表标签在非 UTC 团队面前整体偏移一天——而 CI 里默认的TZ=UTC恰好掩盖了它。
- 在哪一层拦截:通常是金字塔最廉价的一级——一个参数化多个非 UTC 时区(外加一个 DST 边界)的纯单元测试。但注意例外:当时区截断发生在ClickHouse 内部时,廉价测试看不见它,必须真实执行 SQL。
真实案例(一廉价、一昂贵,恰好成对):
- PR #57593(廉价范例):时区安全的日期解析。前端解析读取了系统墙钟,导致 UTC 以东的地区日历回滚一天。一个覆盖 UTC/LA/Tokyo/Berlin 加 DST 边界的纯 Jest 测试就抓住了它——文档称之为"干净的廉价范例"。
- PR #61111(不廉价):
time_bucket截断必须钉在 UTC 上。toStartOfDay在会话时区网格上截断,而游标按 UTC 打印,导致非 UTCsession_timezone下 keyset 第二页变空。回归测试要插入行并用session_timezone=US/Pacific跑真实 SQL(ClickHouse 支撑的TestCase)。文档的忠告是:"分清你面对的是哪一种"——先判断截断发生在哪一侧,再决定测试层级,别把廉价假设套在不廉价的问题上。
HogQL 打印器 / 类型强转
HogQL 打印器(printer)产出的 SQL 让 ClickHouse 因 cast 错误崩溃,或返回错误结果。这类问题有一个经典陷阱:两种测试层级被混为一谈。
- SQL 形状变化→ 用打印 SQL 断言或
.ambr快照(test_printer.py),不执行。快,但它只证明 SQL 变了,不证明 ClickHouse 不再报错。 - 错误结果 / 硬错误→ 用 ClickHouse 支撑的
TestCase真实执行查询。
真实案例:
- PR #63220:让
toBool变成 null-safe。裸toBool对 UUID 形状的字符串会硬失败,修复改为accurateCastOrNull。测试用打印 SQL 的字符串断言加.ambr快照捕获;文档冷静地指出:"快照只证明 cast 包裹存在,仅此而已"——它防的是形状回归,不防执行错误。 - PR #58713:在空值检查之前强转 funnel 聚合目标。数值类型的属性撞上 ClickHouse 错误码 72(
Cannot read floating point value)。由于.ambrdiff 本身(一个toString()包裹)无法证明错误已消失,真正的测试是对 ClickHouse 执行.calculate()。
源码佐证:类型强转的落点在 base.py 的visit_type_cast——bool/boolean分支输出accurateCastOrNull(expr, 'Bool'),int走toInt64、text走toString。而 test_printer.py 中的参数化断言("toBool", "toBool(uuid)", "accurateCastOrNull(events.uuid, ...)")正是文档所说的"打印 SQL 字符串断言"形态,注释里也写明"它们都经由 accurateCastOrNull,因此不可解析的值会变成 NULL 而不是抛错"——形状测试与结果测试各司其职,这就是这条目录的核心教训。
单元测试救不了的真实昂贵 bug——别用错工具
这些失败模式真实且代价高,但廉价单元测试要么抓不住、要么给出虚假的安全感。应该换用正确的工具,而不是写一个证明不了任何东西的测试。
非索引友好 / 无界查询
一个函数把索引列包进表达式(toString(uuid) IN ...),或让team_id未被绑定,导致查询退化为全表扫描,在大团队上超时。正确的防线是assertNumQueries/ 查询次数上界,或人工审查打印出来的谓词——不是 happy-path 测试。
文档在这里有一段难得的诚实自省:PostHog 自己历史上这类问题大多是手工验证而非自动化上界拦截的——PR #61864(未绑定的team_id→ 扫描)只用 mock 钉住了一个 RPC 名称;PR #62417(索引列被包进toString)断言的是输入校验而不是查询形状。这个缺口,恰恰就是查询上界测试应该补上的地方。
迁移 / 应用时序
代码在迁移创建表之前就去读它。PR #59873 就是 ClickHouse 迁移在并行迁移下读取了尚不存在的posthog_instancesetting表。没有任何入库测试能守住这个;真正的安全网是 CI 里的迁移回放(migration replay),它专门验证应用顺序。文档直言:"不要写一个假装能守住的单元测试。"
异步 / Temporal 收尾挂起
fixture 收尾里的一个不可取消的sync_to_asyncgRPC 调用会挂起整个 job——PR #62339 的修复方式是移除该调用、依赖数据库 CASCADE,而不是加测试。
文档还点出一个相邻陷阱:一个 mock 掉了自己本该验证的边界的测试等于什么都没抓。PR #60302 修复了一个真实的工作流日志器崩溃,但它的 logger 测试 mock 了 logger——于是无论崩溃是否存在测试都会通过,回归时它也无法发现。测试边界的原则是"mock 真正的边界(网络、外部 API、时钟、队列),不要 mock 自己的内部实现",否则测试就退化成 SKILL.md 里点名的"变更检测器测试"。
逃过正确性测试的重构回归
"行为保持"的重构在一个测试没覆盖的轴上回归。这里的教训不是"补一个特征化测试",而是**"覆盖真正发生变化的那个行为"**。PR #59920 即便存在会执行数据的正确性测试仍被 revert——回归的行为恰好在该测试断言的覆盖之外;PR #56785 的 helper 测试和 API 测试在重构与 revert 两个方向上都干净通过。它们证明了什么?证明了测试断言的是不随行为变化的东西。
根本不是「测试形」的问题
最后这一类主导了事故数量,但它们的防线是监控、容量与告警——永远不是单元测试。
- 负载下的资源耗尽:OOM、连接池枯竭、事件循环阻塞。与负载相关,预防靠容量规划和告警。(一个已知的意外 O(n) 可以写定向性能测试;但负载本身无法用测试覆盖。)
- 基础设施 / 第三方故障:节点崩溃、DNS 或网络变更、磁盘耗尽、上游供应商宕机。根因在应用层之下,对策是 runbook 和监控。
实操:把目录变成你的测试决策流程
配合 SKILL.md 的门禁,这份目录可以落地成三步:
- 两问门禁:写任何测试前回答两句——"这个测试拦截的、且现有测试都没拦截的真实回归是什么?"回答不出具体 bug、路径和会触发的输入,就不要写;"为什么它不能作为现有最近测试的一个参数化 case(
@parameterized/test.each)?"新测试函数是扩展失败时的最后手段。 - 对照目录选层级:你的改动命中上面哪条形态?错误分类→纯单元双向断言;空输入→边界层直接传坏值;IDOR→两 org 的 Django
TestCase;时区→注意截断发生在进程内还是 ClickHouse 内;HogQL→先分清是形状测试还是结果测试。对不上目录、又不是新行为——质疑测试的价值。 - PR 描述里留一句自证:PR 模板的 "How did you test this code?" 处写一行即可,例如:"为
test_cohort_query新增空/单元素/超大 cohort 三个 case——守护刚修掉的 500;无法扩展现有测试,因为没有测试覆盖空路径。"写不出这句话,就说明这个测试不该进 PR。
五个"不写"同样值得牢记:不测框架行为、不写变更检测器测试、不用十个重复测试代替一个参数化测试、不为覆盖率数字追分支、绝不做跨语言源码爬取(Python 测试去 grep.ts文件之类的脆字符串耦合——两棵树的共识应该落在生成的产物或共享数据文件上)。
最后回到那份目录的定位:它是 PostHog 用fix:/revert:PR 和线上事故"反向工程"出来的失败形态档案。它的价值不在某个具体测试,而在于逼你先回答"我在防哪个真实事故",再决定写不写、写在哪一层。如果你对不上目录,先怀疑的不是目录,而是那个测试。
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考