测试文件变多之后,并行执行可以缩短等待时间,但前提是每个文件不依赖其他文件留下的状态。testthat 按文件分配任务,同一工作进程会先后执行多个文件;它不会在每个文件结束后自动清空所有选项、已加载包或全局对象。因此,迁移的核心是梳理隔离边界,再调整进程数和调度顺序。
本文依据 testthat 官方《Running tests in parallel》完整整理,核验版本为网站展示的 3.3.2。原文没有个人署名或首次发表日期;项目主页列 Hadley Wickham 为作者与维护者,本文将原文归属记为 testthat 项目文档。代码和配置仅做静态审查,未运行包测试或修改读者环境。

先启用第三版,再打开并行开关
并行测试要求使用 testthat 第三版行为。这里的“第三版”是需要明确选择的一组测试行为,不是说安装了 3.x 软件包就自动启用。可在包的 DESCRIPTION 中明确配置:
Config/testthat/edition: 3
Config/testthat/parallel: true
第一行依据 testthat 3e 文档补充;第二行来自并行测试原文。如果现有套件还未迁移到第三版,应先确认行为变化造成的失败,再判断并行是否引入问题,避免把两个迁移混在一起。
调试时可在当前 R 会话临时关闭并行:
Sys.setenv(TESTTHAT_PARALLEL = "false")
这是修改会话环境变量的示例,不是本文已经执行的动作。排查后应恢复此前设置,避免以后仍在无意间运行串行测试。
进程数与文件顺序要分别调整
默认进程数量取自 getOption("Ncpus", 2)。如果开发机适合更多并发,原文建议在自己的 .Renviron 中设置 TESTTHAT_CPUS。可用 usethis::edit_r_environ() 打开配置文件,再添加:
TESTTHAT_CPUS=4
4 只是配置示例,不是性能最优值。增加进程同时也会增加包加载、内存和外部资源的并发需求。将机器相关的并发数放在开发者个人配置里,可以避免把一台电脑的资源假设强加给所有使用者。
默认情况下,文件按字母顺序调度。若较慢的文件最后才开始,其他工作进程可能已经空闲,整体结束时间仍被它拖长。可在 DESCRIPTION 中用逗号分隔的 glob 模式,让慢文件优先启动:
Config/testthat/start-first: watcher, parallel*
这种调整改善的是任务启动顺序,不会改变单个测试本身的速度。模式应根据自己的文件名称设置,并用实际测量确认是否有效。
理解工作进程的生命周期
每个工作进程启动时先加载 testthat 和被测包,然后运行 setup 文件。现有 setup 若创建数据库、文件或其他共享资源,也必须能够在多个进程同时执行。工作池准备好后,主进程给各进程发送测试文件;一个进程完成当前文件,就接收下一份任务,直到所有文件完成。
隔离单位是进程,调度单位是文件;同一进程内连续运行的文件仍会共享残留状态。 文件 A 修改的选项,可能被随后分到同一进程的文件 B 看到。已经加载的包不会自动卸载,全局环境也不会自动清空。每个文件都有责任恢复自己改变的状态。
这也解释了为什么问题可能偶发:文件 B 有时分到干净的进程,有时分到执行过 A 的进程。串行时固定顺序可能掩盖这种依赖,并行时不同的完成时间则把它暴露出来。
从状态与临时资源两条线排查
如果测试时而成功、时而失败,先检查测试文件是否改变全局状态并让其他文件隐式依赖。原文推荐使用 set_state_inspector() 帮助定位。它是辅助检查工具,不能替代对测试设置与清理逻辑的阅读。
另一类问题是包级测试夹具共享了同一个资源。例如,以前在测试目录下创建一个临时数据库,串行时只有一个使用者;并行时多个进程可能同时初始化、写入或删除它。原文建议改在会话临时目录创建,使每个进程得到独立版本。
迁移时可按以下顺序检查:先看 setup 的副作用,再检查各测试文件改变的选项和全局对象,然后核对临时数据库或文件是否真正按进程隔离,最后核对清理是否覆盖失败路径。清理时只处理该测试创建且已经确认的资源,不应通过清空整个项目目录来制造“干净环境”。这段检查顺序是编辑补充建议。
短测试未必因更多进程而更快
并行也有成本。原文在作者自己的笔记本电脑上观察到:创建子进程的成本随进程数线性增长,约每个 50 毫秒;清理约每个 80 毫秒;传递一条期望结果消息的总成本约 2 毫秒。子进程还需要加载 testthat 和被测包,这部分虽可并行发生,也不能忽略。
这些数字只是原作者环境中的测量,既不是本次实测,也不是 testthat 的性能保证。原文举例,testthat 自己的套件串行约 10 秒,并行约 8 秒:有提升,但没有按进程数量成倍缩短。很多运行极短的文件,可能被启动、清理和消息传递开销抵消收益。
判断是否值得启用时,应比较相同环境、相同测试范围与相同报告器下的总耗时;检查正确性应先于追求更低的秒数。不能只看 CPU 核心多,就假设并行必定更快。
报告器会影响并行与调试体验
对于 devtools::test() 和 testthat::test_local(),默认串行报告器为 ProgressReporter,并行则为 ParallelProgressReporter。其他入口,例如 test_check() 或 test_file(),默认选择可能不同。
大多数报告器支持并行。如果在 devtools::test()、test_dir() 等函数中直接指定不支持并行的报告器,testthat 会顺序执行文件。原文列出的例外如下:
| 报告器 | 原文说明的限制 |
|---|---|
| DebugReporter | 当前无法以此方式交互调试子进程。 |
| JunitReporter | 需要每个测试块的计时信息,而当前这类信息只提供给能处理多个活动测试文件的报告器。 |
| LocationReporter | 并行成功结果不携带位置资料,以减少进程间消息。 |
| StopReporter | 用于交互式 expect_that() 调用。 |
支持并行也不表示显示方式完全相同。按失败次数提前停止的报告器,只能在一个测试文件结束时停止;不支持并行更新的报告器,会等整个文件完成后一次接收该文件的信息。例如 ProgressReporter 不能在文件执行到一半时逐条更新。
原文版本还明确指出,测试文件里的标准输出和标准错误,例如 print()、cat()、message(),在并行模式下会丢失。如果依赖打印调试,临时切回串行更直接。不要因为终端没有某行输出,就断定那一段代码没有执行。
自定义报告器:并行支持与并行更新是两种能力
自定义报告器若能在测试运行于子进程时工作,可在初始化方法中声明并行支持。下面是原文能力配置的结构性片段,并非可独立运行的完整类:
initialize = function(...) {
super$initialize(...)
self$capabilities$parallel_support <- TRUE
# 其余初始化逻辑
}
报告器本身仍在主进程运行,testthat 透明地转发子进程的结果。若它不支持并行更新,testthat 会先缓存一个文件的消息,等文件结束后调用 $start_file(),按原顺序转交 $start_test()、$end_test()、$add_result() 等消息,最后调用 $end_file()。
要像 ParallelProgressReporter 一样及时显示各进程传来的消息,还必须能够同时处理多个文件,并声明第二项能力:
initialize = function(...) {
super$initialize(...)
self$capabilities$parallel_support <- TRUE
self$capabilities$parallel_updates <- TRUE
# 其余初始化逻辑
}
此时消息不再按文件缓存。每条消息到来,testthat 会先调用 $start_file() 指明所属文件,再调用相应结果方法,所以同一文件可能多次收到 $start_file()。报告器不能把每次调用都当成一个从未出现过的新文件。
testthat 还会定期调用报告器的 $update(),即使没有新的子进程消息。原文说目标间隔约 100 毫秒,但不作保证;可用来显示活动指示,不应把它当成精确计时接口。
迁移完成的标志,是测试在明确的状态与资源边界下稳定运行,报告器能够如实反映结果,并且整体耗时确有收益。本文未执行任何测试;配置中没有硬编码秘密或系统命令注入入口,但共享资源与未恢复状态仍需结合具体测试代码检查。
原始版权与许可
testthat 软件及相关文档的 MIT 许可与原始版权声明保留如下;本篇为中文翻译与编辑整理。 官方许可来源
# MIT License Copyright (c) 2023 testthat authors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.












暂无评论内容