Zulip 测试哲学:让自动化测试成为大型开源项目快速迭代的引擎
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
本篇技术指南以 Zulip 仓库中的 docs/testing/philosophy.md 为骨架,系统梳理 Zulip 团队设计自动化测试套件的核心原则:如何用可靠、快速、易扩展的测试支撑"快速移动而不破坏任何东西"的工程策略,如何在"集成测试 vs 单元测试"之间做出正确取舍,以及如何通过共享测试代码与安全访问函数设计来减少重复劳动与安全漏洞。读完本文,你将理解 Zulip 整套测试工具链(tools/test-backend、tools/test-js-with-node、zerver/lib/test_runner.py)背后的设计动机,并可直接把这些方法论移植到自己的项目中。
有效的测试让我们能够快速移动
Zulip 的工程策略可以概括为"快速移动而不破坏任何东西"(move quickly without breaking things)。尽管 Zulip 维护者每天要评审大量来自新贡献者、对其改动代码缺乏深入理解的提交,但维护者把集成改动的大部分时间花在产品决策和代码结构/可读性上,而不是正确性、风格或底层问题上。
之所以能做到这一点,是因为项目多年来系统性地投资于测试、工具链、代码结构、文档和开发实践,确保贡献者写出的代码在提交评审前就已相对正确。测试环节的职责是:提供可靠、广泛、易于扩展的测试套件,覆盖大多数 bug 类别,把人工验证和排查改动是否正确的时间降到最低。
Zulip 的开发环境认证测试设施是一个典型例证:它允许在自动化测试和开发环境中使用mock LDAP 数据库(fakeldap),让重构和改进认证这一重要模块变得极其容易——而在过去,你需要先搭一个真正的 LDAP 服务器并灌入测试数据才能测试 LDAP 认证。从源码看,这个能力由 zerver/lib/test_helpers.py 中的MockLDAP(fakeldap.MockLDAP)类提供,开发环境侧则通过zproject/dev_settings.py中的FAKE_LDAP_MODE(取值为a/b/c,对应三种邮箱与用户名的映射关系)和FAKE_LDAP_NUM_USERS(默认 8 个用户)来开关与配置。
当然,并非 Zulip 的每个组件都有出色的测试套件,但许多组件都有。对这些组件而言,新贡献者往往能做出实质性修改,且在提交评审时大体正确;更重要的是,维护者把原本用于"验证改动正确"的时间节省下来,转而专注于代码库的可读性、良好结构和良好测试。
测试套件的性能与可靠性是生死攸关的
当自动化测试套件变慢或不可靠时,开发者会逃避运行它们,也会逃避改进它们(无论是改进系统还是单个测试)。而让测试变慢或变不可靠的改动,往往是其他开发的意外副作用,因此这类问题会随代码库增长而不断累积。如果不刻意防止,任何大型软件项目最终都会把测试套件腐烂成"又慢、又不可靠、不值得信任、人人讨厌"的状态——Zulip 的目标就是避免这一命运。
Zulip 把"每个自动化测试套件都必须既快又可靠(在 CI 和本地都能通过)"视为必要前提。具体的性能目标是:
| 测试套件 | 命令 | 性能目标 |
|---|---|---|
| 完整后端套件 | tools/test-backend | 约 1 分钟内完成 |
| 完整前端套件 | tools/test-js-with-node | 10 秒以内完成 |
达成性能目标的关键技术
原文档重点强调了以下几项技术,仓库源码给出了具体实现证据:
- 测试套件被设计为不访问互联网。
tools/test-backend用block_internet()上下文管理器(见 tools/test-backend)包裹全部测试:它通过mock.patch.object(responses, "ConnectionError", new=ZulipInternetBlockedError)和responses.RequestsMock(),一旦测试代码尝试访问未注册的 URL,就会抛出ZulipInternetBlockedError(tools/test-backend),提示"Zulip 测试中不允许发起外发网络请求"。凡测试需要用到出站 HTTP 请求的地方,一律用responses这类库 mock 掉响应。 - 严格避免测试间数据污染(PostgreSQL、Redis、memcached 等):
- 每个测试用例在访问 Redis 和 memcached 时,都会在其使用的所有 key 前加上唯一随机前缀。对应实现是 zerver/lib/test_runner.py 的
get_database_id(),它基于每次test-backend调用开始时生成的random.randint(1, 10000000)随机数来保证进程间 ID 唯一。 - 每个测试用例都运行在数据库事务中,测试结束后事务被回滚(abort)。每个测试进程只与一个专用模板数据库(
zulip_test_template)的全新副本交互,进程结束后该副本被销毁。这套逻辑分布在 zerver/lib/test_runner.py(destroy_test_databases/create_test_databases,并行模式下每个 worker 通过clone_test_db(suffix=database_id)克隆自己的库)与 zerver/lib/test_fixtures.py(BACKEND_DATABASE_TEMPLATE与update_test_databases_if_required)中;并行 worker 的初始化见 zerver/lib/test_runner.py 的init_worker。
- 每个测试用例在访问 Redis 和 memcached 时,都会在其使用的所有 key 前加上唯一随机前缀。对应实现是 zerver/lib/test_runner.py 的
- 对非确定性(间歇性)失败的测试,按产品级 bug 的优先级严格调查,绝不放过。
此外,tools/test-backend还提供--rerun(只重跑上一次失败的测试,读取var/last_test_failure.json)、--coverage(计算并强制覆盖率)、-x/--stop(首个失败即停止)、--parallel N(并行进程数,默认等于逻辑 CPU 数)等参数;运行时还会调用tools/webpack --test预先构建测试所需的前端产物。这些细节共同保证了"1 分钟跑完"仍然能提供足够可信的反馈。
集成测试还是单元测试?
开发者经常纠结该写"集成测试"还是"单元测试"。Zulip 的观点是:测试应该针对你已经在依赖其保持稳定的接口来写——也就是你自己或他人已经在指望它"除了兼容性变更外基本不变"的那些接口。
针对 Zulip 的端到端 API 写服务端测试就是一个绝佳范例:大量代码都是基于 Zulip API 编写的,这些代码都指望 API 大体上继续按它们使用的方式工作。即使 API 的唯一用户只是本项目自己的客户端(如移动 App)也成立——因为已有大量已安装的移动 App 副本在指望 API 不会突然发生不兼容的变化。
为什么面向接口测试如此重要
针对接口写测试有一个重要原因:这些测试会成为你每次改动该接口时的成本——你得去更新一大批测试。
- 在大代码库里,如果有很多针对小型内部函数的"单元测试",那么每次你重构并改变内部接口时——即使这些接口完全是你自己发明的、完全内部、改了也不会破坏任何东西——你也得去编辑一堆测试来适配新接口。认真对待测试时,这尤其费劲,因为你要判断"测试失败到底是不是在告诉你该听的东西"。这会导致测试感觉像无意义的杂活(busywork),开发者也就不愿用心维护和扩展测试套件。
- 但如果你的测试是针对外部 API写的,那么你做了某个重构改动后一批测试失败……这是在告诉你非常真实的东西!你当然可以改测试,但测试是那些你够不着的真实用户和真实代码的替身,它们会以同样的方式坏掉。于是你仍然可以改动,但必须为外面那些真实用户想好迁移或向后兼容策略——改测试反而是其中最简单的一步。同时,对测试的改动也是对代码评审者的友好提醒:你改动了某个接口,评审者需要仔细思考这些接口变更是否会破坏既有客户端、是否已反映在接口文档中。
这一哲学在不同项目中的形态
- 如果是一个以 API 为主的 Web 服务,就应该为 API 写测试。
- 如果是 CLI 程序,就针对 CLI 写测试。
- 如果是编译器、解释器等,则基本上所有测试都应是"示例程序",附带少量元数据(如"这一行应该报错"或"应该能构建并运行,且产生如下输出")。
在 Zulip 中的具体落地
- Zulip 的 Web 应用、移动客户端和第三方 API 客户端使用同一个 API,因此服务端测试大多是针对 Zulip API 写的。
- 入站 webhook 的测试方式是:把从真实第三方服务捕获的实际 payload发送到 webhook 端点,再验证 webhook 是否产生了预期的 Zulip 消息输出——测试的正是真实接口本身。仓库中 zerver/webhooks/ 目录下大量
*.json文件(超过 1000 个)就是这些真实捕获 payload 的固件。
总结:Zulip 的集成 vs 单元测试取舍
- 尽管 Zulip 追求覆盖服务端每一条重要代码路径(这通常与"单元测试"关联),但大多数测试实质上是集成测试:向 Zulip 服务器发送一个完整的 HTTP API 查询,然后检查 HTTP 响应以及请求之后服务器的内部状态是否正确。
- 遵循系统设计中的端到端原则(end-to-end principle),只要可能就写执行完整流程的测试(例如"注册一个新 Zulip 账号"),而不是逐个测试单个函数的实现。
- 为此,Zulip 投资于性能,一方面是为了给用户好的体验,另一方面同样重要的是让测试套件快到足以支撑这种测试写法。
避免复制具有安全影响的代码
开发出安全漏洞很少的软件极其困难。Zulip 避免安全逻辑 bug 的重要策略是:为所有处理不受信任用户输入的代码设计出"无需写(和评审!)无穷无尽的测试、也不要求每个开发者都擅长思考安全边界情况"的模式。
具体做法是:编写少量精心设计的函数(如access_stream_by_id),对它们进行仔细测试,然后用 lint 及其他编码约定强制要求:所有可能把数据分享给用户的代码路径,对数据的访问都必须经由这些函数。这样,与其让每个视图函数各自实现"用户能否访问某频道"的安全检查、再逐个测试这些复制出来的逻辑,不如对每种主要数据结构、每种访问级别只做一次这项工作。
access_by函数的设计风格
这些access_*_by_*函数有专门的编写风格:
- 每个条件判断独占一行,以便测试覆盖率工具帮助验证每个分支都被测到;
- 有详细的注释;
- 精心设计错误处理,避免泄露"所请求的频道 ID 是否存在"之类的信息。
以 zerver/lib/streams.py 的access_stream_by_id为例,可以看到这种风格的完整实现:它先get_stream_by_id_in_realm取频道,取不到时抛出统一的_("Invalid channel ID")错误;随后进入access_stream_common(zerver/lib/streams.py)。access_stream_common的 docstring 明确写道:"一个设计目标是:对无法访问的频道与不存在的频道,返回的错误信息相同",并逐一检查跨 realm 访问(AssertionError防御)、订阅关系、频道停用状态、基础访问权限(check_basic_stream_access,含require_active_channel与require_content_access两个可选开关)。频道 ID 是int类型;users.py中还有对应的access_user_by_id等函数,覆盖用户数据访问。
Zulip 也会为给定视图写"非法访问时给出恰当错误"的测试,但这些测试属于纵深防御;防止非法访问频道的主要手段是:不提供让服务端代码绕过安全校验函数直接拿到Stream对象的途径。
共享测试设置代码
为权限检查或错误处理代码写测试非常常见。此时最好的做法是:在成功测试与失败测试之间共享测试设置代码。
例如,当测试一个返回布尔值的函数(而不是抛出带特定错误消息的异常)时,通常更适合写一个测试函数test_foo,让它多次调用被测函数,并针对各个测试条件验证输出。
这样做的好处是:能保证测试设置只在有意为之的地方不同。做得好时,可以避免那种极其常见的失败模式——test_foo_failure测试因错误的原因而通过(例如,操作失败不是因为权限检查,而是因为某个必需的 HTTP 参数只被加到了相邻的test_foo_success里)。
换句话说:成功用例与失败用例共享同一套 setup,意味着两者之间的唯一差异就是你要验证的那个条件,从而让每个测试都真正测到了它声称要测的东西。
没有测试过的东西,大概率是坏的
即使是最优秀的程序员也会不断犯错。而且,如果大规模重构(对保持代码库可读、愉快、正确非常重要)会带来很高的"制造隐蔽 bug"的风险,那么重构就无法进行。
因此,每个改动都需要测试。对于业务逻辑,最佳选择通常是高质量的自动化测试——设计成对未来重构健壮。
但有些事情(如文档和 CSS)只能通过在浏览器里查看元素并尝试那些可能不工作的东西来测试。测什么取决于什么容易坏:例如,对 Zulip 的 Markdown 文档做重大改动后,如果你没有在视觉上验证每一种特殊格式、没有点击过每一个新链接,那么你很可能会引入 bug。仓库中的 tools/check-templates、zerver/tests/test_templates.py(模板渲染与浅层测试的排除列表)以及tools/test-backend运行结束时对"没有任何测试渲染过的模板"的报错检查,正是对这一原则的工程化落实。
人工测试不仅能抓住 bug,还能帮助开发者更了解系统、思考他们正在做的功能的既有语义。因此:
- 当提交影响 UI 的 Pull Request 时,展示一段功能工作时的录屏(screencast)非常有帮助,它能让评审者省下本会用于手动测试你改动的时间。
把测试哲学应用到自己的项目中
从 Zulip 的实践中可以提炼出几条可直接迁移的行动准则:
- 把测试速度当作一等公民:为全量后端套件、全量前端套件设定明确的耗时预算(Zulip 分别是 1 分钟与 10 秒);任何让套件变慢的改动都要被严肃对待。
- 隔离一切外部依赖:让测试离线可跑,外发请求一律 mock;为数据库/缓存设置隔离机制(事务回滚、随机 key 前缀、模板数据库副本)。
- 面向稳定的接口写测试,而不是面向内部实现:优先测试端到端 API、CLI、真实 webhook payload,而不是被发明出来、随时可能变化的内部函数。
- 把安全逻辑收敛为少量精心设计的函数,用风格约定强制所有访问路径都经过它们,让"测一次、处处生效"。
- 共享成功与失败用例的设置代码,防止失败测试因错误原因通过。
- 承认"没有测试过的东西大概率是坏的":能自动化的业务逻辑用高质量自动化测试;文档、CSS 等只能靠人工验证的,就把人工验证清单(视觉检查、逐个点击链接)纳入改动的完成标准。
这些原则共同解释了为什么 Zulip 能在保持高贡献者流动性的同时维持代码质量——正如 docs/testing/philosophy.md 所说,自动化测试正是"让项目能够持续取得进展"的巨大部分;而本文所引用的 tools/test-backend、tools/test-js-with-node、zerver/lib/test_runner.py、zerver/lib/streams.py 等源码,就是这套哲学在工程实践中的具体注脚。
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考