关于 writing-tests
面向 PostHog 工程开发的技能,帮助判断应新增哪些测试以及放在哪里。它强调覆盖具体回归、扩展相关现有测试,并选择成本较低且能验证行为的测试层级。
主要功能
- 01判断测试能否捕获真实回归
- 02优先扩展相关的现有测试
- 03避免冗余及绑定实现的测试
- 04选择成本较低的测试层级
- 05涵盖 Python 与前端测试实践
适用场景
- 01在增加覆盖前评估测试价值
- 02为现有测试添加行为变体
- 03选择单元、数据库或浏览器测试
- 04避免不必要的数据库设置来测试校验
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ posthog/ posthog --skill writing-tests安装后在 Claude Code 中输入/writing-tests确认已加载。
附带文件references/database-free-test-classes.mdreferences/mistakes-we-make.md
详细介绍
编写值得保留的测试
其背后的理由以及面向人类读者的相同规则见手册:后端编码规范 › 测试(docs/published/handbook/engineering/conventions/backend-coding.md)。
本技能是可操作的关卡——在编写测试之前运行它。它包含决策流程,以及我们实际交付过的 bug 形态目录。
这个关卡针对的是新增的覆盖,而不是新的测试函数。往现有代码块中添加的一个 fixture 和一个断言,同样要经过这两个问题,因为那正是本技能希望你优先采用的形态。
关卡:两个问题
在编写任何测试之前,各用一句话回答这两个问题。
1. 它配得上自己的位置吗?
这个测试能捕获到什么现实的回归,是现有测试尚未捕获的?
如果你无法具体地回答——说出 bug、代码路径、会触发问题的输入——就不要写这个测试。 "提高了覆盖率"、"良好实践"以及"这个函数存在"都不是答案。
一个好的答案听起来是这样的:"如果有人让 parse_filters 丢掉 team_id 子句,这个测试就会失败" 或 "空 cohort 输入过去会返回 500;这个测试把 400 锁定下来。"
那才是一个值得保留的测试。
让每个测试都针对我们实际遇到过的失败模式,而不是假设中的失败模式。 PostHog 交付后又回滚的 bug 可归为少数几种形态——每一种都配有能捕获它的测试,以及不应由单元测试捕获的失败模式,详见 references/mistakes-we-make.md。 如果你的测试无法对应到其中任何一种,就要怀疑它是否值得保留。
2. 它应该放在哪里?
通过第一个问题换来的只是_覆盖_,而不是一个新的测试函数。 找到已经覆盖最接近行为的那个测试,然后回答:
为什么这个用例不能作为那个测试中的一个 case?
当你的用例是那种行为的一个变体时,就扩展它:Python 中的 @parameterized case,Jest 中的 test.each 行。
新的独立测试是你在扩展行不通时才写的,而且你应该能用一句话说明原因:setup 不同、该行为属于另一个单元,或者不存在相关测试。
"作为独立测试更清晰"本身不是理由;要指明上述三种情况中的哪一种适用。
扩展是为了消除重复,而不是为了省下 setup 时间。
参数化的 case 仍然是一次独立的测试调用,所以 setUp 和 beforeEach 会像对待独立函数一样为它运行。
这也划定了界限:把同一行为的变体并入其中,不要把关于无关行为的断言硬塞进一个已经通过的测试里。
先搜索,再编写。 如果你没有去找最接近的现有测试,你就无法回答这个问题。
不要写它——五个“不”
大多数低价值测试都会落入以下某一类。识别它们,然后跳过。
-
琐碎 / 框架行为。 不要测试 getter、setter、常量、dataclass 字段赋值、Django 保存了一行、DRF 序列化了一个字段,或者某个库做了它文档里说的事情。 你测的是别人的代码,不是你的。
-
变更检测器测试。 一个只是照搬实现的测试——断言调用了哪些私有方法、以什么顺序、用与当前代码匹配的 mock 接好线——会在每次重构时失败,却抓不到任何真实缺陷。 要通过公共接口测试_可观察行为_(返回值、持久化状态、发出的事件、HTTP 响应),而不是产生它的那套编排。 参见 Change-Detector Tests Considered Harmful 和 Prefer Testing Public APIs。
-
冗余覆盖。 十个用不同数据走同一条路径的测试,就是一个参数化测试。 如果一个新测试只是已有测试的变体,那它就是一个
@parameterized用例(Python)或一行test.each(Jest)——而不是一个新的测试函数。 AI 生成的测试套件首先在这里膨胀:数百个几乎相同的用例,把 30s 的套件变成 3 分钟。 -
追逐覆盖率。 不要为了达到某个数字而添加测试;未覆盖的行是信息,不是缺陷。 如果一个分支需要测试的唯一理由是覆盖率报告,那这个分支大概不需要测试——或者这段代码是死代码,应该删掉而不是测试。
-
跨语言抓取源码。 永远不要从一种语言的测试中读取或用正则解析另一种语言的源码——一个 Python 测试用
Path(...).read_text()读取.ts文件并匹配category: '...',一个 TS 测试 grep 一个.py文件,诸如此类。 它通过脆弱的字符串匹配把两棵代码树耦合在一起,会在不影响行为的改动(重命名、重新格式化、移动文件、改注释)上失败,而且它对运行时什么也证明不了——两侧在测试中从未真正一起运行。 如果两侧确实必须就一组值达成一致,就给它们单一事实来源——一个生成的产物或一个双方都会导入的入库数据文件——并针对它断言。否则,就让偏差在两侧真正相遇的地方暴露出来(API 契约测试、渲染输出、往返测试),而不是通过抓取另一种语言的源码来匹配字符串。
当答案是“不要写它”时,正确的做法往往是扩展现有测试(添加一个参数化用例)或删除代码,而不是去测试它。 两者都能缩小测试套件的覆盖面。
如果你要写测试——就把它压到金字塔底部
一个有存在价值的测试,仍然必须足够廉价。 成本是一架梯子;每一级大约比下一级慢一个数量级,也更不稳定:
pure function / unit → kea logic test → Django TestCase → ClickHouse-backed test → Playwright e2e
cheapest most expensive
目标是比例,而不是上限:底层有很多测试,顶层极少。 一千个纯单元测试比十个启动 Django 的测试更便宜、更可靠,而后者又比一次 Playwright 运行更便宜。 想要更多覆盖率?在底层加。
当某个东西很难廉价地测试时,这就是一个设计信号——去抽取,而不是升级。 如果测试你的逻辑的唯一方式是搭起一个数据库、一个请求和一次渲染,那么这段逻辑就与其依赖纠缠在了一起。 把它抽成一个纯函数或一个 kea logic,然后直接测试_它_:你立刻就能同时得到更快的测试和更好的代码分层。 升级到下一级是最后手段,而不是默认做法。
- 如果逻辑位于(或可以移到)一个纯函数中,就测试这个函数——不要去搭起数据库、请求或渲染。
- 前端:只要行为位于 kea logic 中,就测试 kea logic(
logic.actions/logic.values),而不是完整的组件渲染。render()+ DOM 查询测试只用于只有 DOM 才能展示的东西。 - 只有当回归确实存在于 ClickHouse 或浏览器中时,才去动用它们——而不是因为这是你想到测试时的第一种方式。
Python(pytest / Django)
-
使用
TestCase,而非TransactionTestCase,除非你确实需要它。TransactionTestCase会在每个测试之间清空数据库,而不是回滚事务——速度大幅降低,并且是跨测试相互干扰的常见来源。 这是 Postgres 隔离性层面的选择,与你访问哪个数据存储无关:基于 ClickHouse 的测试仍然是普通的TestCase(由ClickhouseTestMixin负责搭建 ClickHouse),因此访问 ClickHouse 并不是切换的理由。 人们通常借助TransactionTestCase解决的常见场景,往往有更廉价的替代方案:- 测试
transaction.on_commit副作用 → 使用TestCase+self.captureOnCommitCallbacks(execute=True)。 - 需要让连接在一个真正独立的线程(
thread_sensitive)中可见 → 使用async_to_sync(...),而不是asyncio.run(...)。 仅当回归测试确实需要TestCase所隐藏的已提交事务边界时,才使用TransactionTestCase。
- 测试
-
测试类只会使用它用到的数据库。
BaseTest和APIBaseTest继承 Django 的TestCase,因此基于其中任何一个的测试类运行时都需要数据库:setUpTestData会为它写入一个 organization、一个 project、一个 team 和一个 user,并且每个测试方法都在事务中运行。一个只做纯断言的测试类要付出所有这些代价,却不会读取任何一行数据。请把这类用例放到django.test.SimpleTestCase上,它会拒绝访问数据库,从而证明这些用例从不需要数据库。posthog/test/repo_invariants/test_database_free_test_classes.py这道棘轮会强制执行这一点:它保存了已存在的此类类的冻结列表,并会让新增此类类的 pull request 失败。当它触发时,references/database-free-test-classes.md 说明了该失败的含义、三种应对方式,以及如何重新生成基线。 -
专用的数据迁移测试是临时性的。 当所有受支持的环境都已应用该迁移、回滚窗口已关闭,且没有任何受支持的升级依赖旧的数据状态后,请移除该专用测试。请删除已过期的测试,而不是将其标记为跳过。对于迁移工具、安全检查、可复用的回填系统,以及人们仍可运行的回填,请保留迁移文件和测试。使用
/django-migrations进行迁移安全相关工作流。 -
DRF 的输入验证应放在
SimpleTestCase中,而不是APIBaseTest的往返测试中。 一个向端点发送格式错误的请求体并断言返回 400 的测试,会让APIBaseTest在 Postgres 中构建 Organization + Team + User 并将测试包裹在事务中——仅仅是为了执行完全在内存中运行的验证。 DRF 的字段验证器(required、类型转换、choices、min/max、正则)和validate_<field>方法在Serializer(data=...).is_valid()中运行,不需要数据库,也不需要请求:字段级验证发生在to_internal_value中,_先于_通常需要self.context的对象级validate()。因此,无效字段的用例永远不会触及访问数据库的代码。 直接测试 serializer 并断言.errors:class TestTeamValidation(SimpleTestCase): # no DB — not APIBaseTest def test_sample_rate_too_many_digits(self) -> None: s = TeamSerializer(data={"session_recording_sample_rate": "30001"}, partial=True) assert not s.is_valid() assert s.errors["session_recording_sample_rate"][0].code == "max_digits"当你把用例矩阵下沉到序列化器时,要保留(或添加)一个依赖数据库的端点测试作为接线守卫——即验证 viewset 确实调用了这个序列化器,从而让错误请求以 400 被拒绝。无数据库的序列化器测试证明了校验逻辑;它并_不能_证明 viewset 已接线到该序列化器(一次丢掉
serializer_class、跳过is_valid()或不再调用is_valid(raise_exception=True)的重构,会让每个SimpleTestCase都通过,却仍然发布一个破损的端点)。一个端点用例就能补上这个缺口;矩阵仍留在SimpleTestCase中。对于内联实例化的查询序列化器(例如Serializer(data=request.query_params).is_valid(raise_exception=True)),接线守卫就是一个错误查询参数 → 400 的断言。 还有两个注意事项。第一,.errors携带的是 DRF 的_原始_ code(invalid、max_digits);{"attr", "code", "detail", "type"}HTTP 信封是稍后由exceptions-hog渲染的(它把invalid映射为invalid_input)——该渲染是框架行为,所以不要在每个用例里重复断言它(接线守卫测试会覆盖一次信封)。第二,真正需要数据库的校验留在端点处——唯一性检查、PrimaryKeyRelatedField的 queryset 查询、关联对象存在性、权限/团队范围划分、密码哈希检查。不要把这些强行塞进SimpleTestCase。 -
参数化重复的断言,使用
parameterized库——不要复制粘贴测试主体。 -
当用例在测试运行前已知时使用
parameterized用例,只有在未知时才使用self.subTest,例如对查询返回的每一行做一次检查。 我们的 pytest 配置让self.subTest快速失败,以便 CI 可以重试该测试。 不要使用 pytest 的subtestsfixture:在重跑通过后,它会在 junit 文件中留下一个失败记录。 -
Python 测试中不要写文档注释(内部规则)。
-
只 mock 真正的边界——网络、外部 API、时钟、队列。 不要 mock 你自己的内部辅助函数(变更探测器式测试就是这么诞生的)。
-
**Person/group/cohort 数据:**使用
posthog/test/persons.py中的辅助函数(create_person、create_group、create_group_type_mapping、add_cohort_members等)——绝不要直接调用Person.objects.create()或类似的 ORM 调用。 完整 API 参考和理由见posthog/test/AGENTS.md。 -
**全仓库守卫放在
posthog/test/repo_invariants/。**如果某个测试的输入是整个仓库——它遍历apps.get_models()、在冷启动django.setup()后检查sys.modules、枚举每条路由或信号接收器,或者对整棵树rglob并与基线比对——那么任何位置的任何文件都可能让它失败,基于差异的测试选择也无法选中它。该目录在repo-checksCI 任务中于每个后端 PR 上无条件运行(包括仅产品的和草稿的),不需要 Postgres/ClickHouse,而 Core 分片会跳过它。输入有界的测试则留在它们所覆盖的代码旁边。
前端(Jest)
- 每个文件一个顶层
describe块(团队规范)。 - 优先逻辑测试而非组件渲染测试(见上方阶梯)。
- 使用
test.each处理变体,而非复制测试体。 - 避免在渲染大型 DOM 的组件上使用
*ByRole(..., { name: ... })(日历、分类筛选器、整个场景)。 Role 查询会遍历每个元素,执行基于getComputedStyle的检查和可访问名称计算——在 jsdom 中每次查询耗时数秒,再乘以每次waitFor/findBy*重试;这种模式在 CI 中曾产生单个测试 p95 耗时 40 秒的情况。 优先使用getByText、getByTestId/data-attr选择器或getByLabelText;如果确实需要 role 查询,用within(<小容器>)限定范围。jest-no-byrole-name-queriessemgrep 规则会标记此问题。 - 禁止巨型快照。 对大型渲染树或序列化 blob 的快照是变更检测测试,会膨胀仓库并在每次无关变更时失败。 对小的、有意的值进行快照,或改为断言特定字段。
始终——确定性与隔离
- 控制状态转换,而不是与之竞争。 选择与受测行为相匹配的控制方式:
- 对于耗时行为,在开始工作之前启用假定时器,并精确推进该行为所需的时长。
- 对于最终的异步结果,在可观察的结果上使用
wait_for/waitFor。 - 对于进行中的状态,让 mock 的边界等待一个由测试控制的 promise。断言挂起状态,释放 promise,然后断言最终状态。
不要假设防抖、
setTimeout、kea breakpoint 或快速 mock 在下一行仍然处于挂起状态。测试设置可以缩短内部延迟,而不改变生产行为。
- 不要用
time.sleep/ 任意等待。 sleep 是一次迟早会发生的 flake,而且它会拖慢每一次运行。用上面匹配的控制方式或time_machine.travel替换它。 - 测试中的绝对日期是一颗定时炸弹,除非你固定时钟。
一个 fixture 日期只有在真实时钟停留在你设定的位置时才保持其含义。
如果被测的任何东西用
now来衡量那个日期——一个年龄、一个窗口、一个"最近"标志、一个过期时间——那么断言今天成立,几周后就会失败,而且是在每个打开的分支上同时失败。 把一个日期固定进应用状态并不等于固定时钟:一个测试把"评估时间"设为一个固定时刻,却让墙上时钟保持真实,当真实时间漂移越过窗口时仍然会失败,因为代码会重新读取now,两者不再一致。 将进程时钟固定到 fixture 所表述的时刻——Python 中用time_machine.travel(..., tick=False),Jest 中用jest.useFakeTimers()配合jest.setSystemTime(),并在finally中用jest.useRealTimers()释放——或者相对于now编写 fixture(now - 2 days),这样测试所声明的就是那个距离。 固定时钟只能覆盖进程内读取它的部分;下一条规则覆盖其余部分。 对测试中的每一个绝对日期都要这样问,而不仅仅是显式冻结的时钟:当今天已经过去一年时,这断言的是什么? 如果答案不是"同样的东西",那就在提交之前修好它。 - 冻结时钟并不能冻结基础设施。
time_machine.travel修补的是你进程内的时钟;其外的一切仍然运行在真实时钟上——ClickHouse TTL、Postgresnow()默认值、S3 生命周期规则、另一个服务的令牌过期检查。 所以冻结到一个绝对日期,然后写入会被某些东西按年龄评判的行,就会构建出一颗定时炸弹:绿了几周,然后在墙上时钟漂移越过保留窗口后永远变红。 它会在每个分支上同时失败,所以读起来像是你面前的那个 PR 造成的——而时间就浪费在这种错误归因上,而不是修复上。 大多数 ClickHouse 表已经是安全的:它们通过ttl_period()(posthog/clickhouse/kafka_engine.py)构建 TTL,该函数在settings.TEST下返回"",所以测试根本不会获得 TTL。 硬编码TTL子句的表则选择退出该防护——ai_events就是其中之一。要检查而不是假设:grep -rlE '^\s*TTL ' posthog/models/ posthog/clickhouse/ --include=*.py。 取而代之,让行的生命周期独立于环境时钟——在插入时固定保留列,就像bulk_create_ai_events写入retention_days=10000那样。把冻结日期向前移动只会重置计时器。 - 连续两次写入在时间上并没有顺序。
不要从一次写入记录的 timestamp 推导出一个截止点,并期望下一次写入落在它之后。
时钟比看起来更粗糙:库的 timestamp 是毫秒级,而在 Linux 上文件 mtime 来自内核的粗粒度时钟,它会落后进程内时钟一个调度 tick。
库也可能读取与你采样的时钟不同的时钟——delta-rs 的时间旅行是依据
_delta_log提交文件的 mtime 来解析 datetime 的,而不是DeltaTable.history()报告的timestamp。 这样的测试在 macOS 上通过,但在快速的 Linux runner 上每次重跑都失败,所以重试救不了它,而且它会归咎于合并队列中的任意一个 PR。 自己设置时间——对文件用os.utime,对行用显式的created_at——相隔几分钟,并在它们之间选一个截止点。这也正是生产中的形态,其中两个事件相隔几秒。 - 测试不得继承生产的超时、延迟预算或重试截止时间。
带有时间预算的代码会从配置中读取它,而调用该代码却未覆盖它的测试,断言的其实是 CI runner 有多快。
调用点上没有任何东西显示这一点。
hog 模板测试中的
tester.invoke(...)从CDP_WATCHER_HOG_COST_TIMING_UPPER_MS取得其 550ms 预算,而executeLogTransformation(bytecode, record, globals, {})从DEFAULT_LOG_TRANSFORMATION_TIMEOUT_MS取得 10ms;两者都定义在两个文件之外,测试中都没有提到它们的名字。 这样的测试在空闲的笔记本电脑上以很大余量通过,却在负载较重的 shard 上失败,并把超时报为错误结果:耗尽预算的 hog 转换返回status: 'failed',所以断言读到Expected: "mutated" / Received: "failed",既没有指出时钟也没有指出预算。 显式传入预算,足够大以至于它不可能成为约束。这样测试断言的是行为,而生产数字仍然是生产决策。 断言整个结果对象,而不是它的某一个字段:expect(outcome).toMatchObject({ status: 'mutated' })在失败时打印错误,expect(outcome.status).toEqual('mutated')则会把它隐藏起来。 - 不要断言墙上时钟时长。
expect(elapsedMs).toBeLessThan(2000)衡量的是 runner,而不是代码,而在共享 CI 上,runner 就是任何同时在它上面运行的东西。 它通常也是失效的:当被测代码已经有一个更短的内部预算时,那个预算会先触发,所以时长断言永远不可能是失败的那一个。 如果单元测试必须覆盖规模,就断言规模下的输出——每个键都在、没有任何截断——把计时留给 benchmark。 - 不要用真实网络 / 在线外部服务。 mock 边界。
- 不要跨测试排序。 测试必须能以任意顺序、隔离地通过;不要依赖前一个测试留下的状态。
- 测试不得继承 CI 矩阵模式。
后端 CI 会在多种模式下运行同一个测试,例如 persons-on-events 开启和关闭,而本地运行使用其中之一。
如果被测代码根据模式分支,就在测试中设置模式,为每个重要的模式写一个
@parameterized用例。 - 你添加的、在 CI 中失败的测试会阻塞合并,即使 job 是绿的。 Trunk 可以隔离重复失败,而被隔离的失败不会让 job 失败。
- 不要用
@skip/xfail/.skip,除非有一行原因和一个关联的 issue。 永久跳过的测试是死重——删除它或修好它。 - 永远不要提交
.only(it.only/describe.only)。 它不会跳过一个测试,而是跳过文件中的每一个_其他_测试——让整个套件在一小片之上变绿。
在打开 PR 之前
PR 模板会在“你是如何测试这段代码的?”下提示你填写这一点 —— 请把理由写在审查者能看到的地方。 每组测试写一行就够了:
“向
test_cohort_query添加了 3 个用例,覆盖空 / 单个 / 超大 cohort —— 守护我们刚刚修复的 500;无法扩展现有测试,因为没有任何测试覆盖空路径。”
如果你写不出这一行,那你就发现了一个不该出现在 PR 中的测试。
相关技能
- 修复现有的不稳定测试 → 使用
/fixing-flaky-tests(复现、定位根因、验证)。仅当修复会新增或大幅改变覆盖率时,才同时使用本技能。 该技能会追溯应用上述门槛:不稳定测试已经证明了它的成本,所以如果你说不出它捕获的回归,删除它在那里就是一个有效的结果 —— 而不仅仅是一项稳定化工作。 - 编写非不稳定的 Playwright 测试 → 首先使用本技能判断浏览器测试是否值得其成本;如果值得,再使用
/playwright-test了解具体机制。