让 R 测试夹具自动恢复选项、目录与临时资源

原作者/来源:testthat 官方文档(本文未列个人作者;项目由 Hadley Wickham 与 Posit 开发)。原文:Test fixtures。本文为经授权的中文译稿;技术核验日期:2026-10-05。

测试也要善后

除了回忆,什么也不带走;除了足迹,什么也不留下。

——原文引作 Chief Si’ahl 的话

理想的测试结束后,外部世界应与测试开始前完全相同。但为了覆盖代码的各个部分,我们常常必须创建文件或目录、在外部系统上创建资源、设置 R 选项或环境变量、切换工作目录,或者修改被测包的某些状态。

怎样撤销这些变化,恢复干净的起点?认真清理不只是礼貌或讲究,它也直接帮助自己:第 i 个测试结束时的状态,正是第 i + 1 个测试的初始状态。随意改动状态的测试,最终会以难以调试的方式互相干扰。

大多数测试都隐含地假定某种初始状态,通常是所属领域中的“干净状态”。如果粗心的测试积累得足够多,就会出现相当于“谁忘了关烤箱”“谁没收拾残局”的编程问题。遇到这种情况,testthat 的 set_state_inspector() 可以帮助定位是哪个测试改变了状态。

设置和清理还应方便交互使用。测试失败后,我们希望迅速重建它当时的确切环境,再通过交互试验查明原因。测试夹具能同时解决隔离与复现的问题。下面先介绍现成工具,再理解它们的机制、夹具的含义和具体案例。

library(testthat)

local_ 辅助函数

withr 提供一组临时改变全局状态的函数,当前函数或测试结束时,它们会细致地撤销变化。最常用的五种是:

需要成对完成的操作 withr 函数
创建并清理临时文件 local_tempfile()
创建并清理临时目录 local_tempdir()
设置并恢复 R 选项 local_options()
设置并恢复环境变量 local_envvar()
切换并恢复工作目录 local_dir()

完整列表见 withr 文档。这些工具让原本难以控制的选项变得容易处理。比如,要测试 base R 根据有效数字设置打印数值的行为,可以写:

test_that("print() respects digits option", {
  x <- 1.23456789

  withr::local_options(digits = 1)
  expect_equal(capture.output(x), "[1] 1")

  withr::local_options(digits = 5)
  expect_equal(capture.output(x), "[1] 1.2346")
})
#> Test passed with 2 successes 🥇.

如果许多测试都重复这样的代码,可以把它封装成辅助函数,也就是测试夹具。withr 的本地函数通过 .local_envir 或 envir 参数控制清理时间;虽然底层机制比较复杂,常用模式却很简单:辅助函数应有一个默认值为 parent.frame() 的 env 参数,再把它传给 local_() 的 .local_envir:

local_digits <- function(sig_digits, env = parent.frame()) {
  withr::local_options(digits = sig_digits, .local_envir = env)

  # mark that this function is called for its side-effects not its return value
  invisible() 
}

末尾的 invisible() 表明这个函数是为副作用而调用,并不需要有意义的返回值。

理解背后的机制

先看一个粗心的 sloppy()。它通过修改 R 选项,以指定有效数字打印一个数:

sloppy <- function(x, sig_digits) {
  options(digits = sig_digits)
  print(x)
}

pi
#> [1] 3.141593
sloppy(pi, 2)
#> [1] 3.1
pi
#> [1] 3.1

调用前后,pi 的打印形式发生了变化。这是因为 sloppy() 修改的是全局 digits 选项,而不只是函数自身的局部状态。这正是我们希望避免的副作用。

原文脚注:作者在文档示例的幕后恢复了全局状态,具体来说就是 digits 选项。读者单独运行这个反例时,不应把这种未展示的恢复动作视为代码自身的行为。

on.exit()

base R 的 on.exit() 会在当前函数退出时执行第一个参数给出的代码,不论函数正常返回还是抛出错误。每次改变外部状态,都配上一段退出时执行的恢复代码,就能把 sloppy() 变成整洁的 neat():

neat <- function(x, sig_digits) {
  op <- options(digits = sig_digits)
  on.exit(options(op), add = TRUE, after = FALSE)
  print(x)
}

pi
#> [1] 3.141593
neat(pi, 2)
#> [1] 3.1
pi
#> [1] 3.141593

这里利用了 options() 的一个便利行为:options(digits = sig_digits) 不仅设置选项,还会以不可见方式返回原来的值。保存这个返回值,就能在退出时恢复原设置。

on.exit() 在测试内部也有效:

test_that("can print one digit of pi", {
  op <- options(digits = 1)
  on.exit(options(op), add = TRUE, after = FALSE)
  
  expect_output(print(pi), "3")
})
#> Test passed with 1 success 🎊.
pi
#> [1] 3.141593

不过,它有三个主要限制:

  • 应当习惯性地指定 add = TRUE 与 after = FALSE。前者把清理代码加入待执行列表,而不覆盖已有任务;后者将它放在栈顶,让清理顺序与设置顺序相反。只有多次调用时这些参数才会产生差异,但提前养成习惯可以避免后续问题。
  • 它不能在函数或测试之外正常承担清理职责。在全局环境运行下列代码不会报错,但恢复代码永远不会因此自动执行:
op <- options(digits = 1)
on.exit(options(op), add = TRUE, after = FALSE)

这会妨碍交互调试。第三个限制是,on.exit() 始终作用于当前函数,无法直接把重复的退出清理代码包装成可复用的辅助函数。为解决这些问题,可以使用 withr::defer()。

withr::defer()

defer() 默认就提供所需的叠加与逆序行为,不需要额外参数:

neat <- function(x, sig_digits) {
  op <- options(digits = sig_digits)
  withr::defer(options(op))
  print(x)
}

它也可以在全局环境调用。全局环境不像测试环境那样会结束,因此必须显式调用 deferred_run() 执行并清除待处理事件;也可以用 deferred_clear() 只清除注册、不执行它们:

withr::defer(print("hi"))
#> Setting deferred event(s) on global environment.
#>   * Execute (and clear) with `deferred_run()`.
#>   * Clear (without executing) with `deferred_clear()`.

withr::deferred_run()
#> [1] "hi"

最后,defer() 允许选择把清理绑定到哪个函数的环境,因此可以用它编写辅助函数。

让“局部”辅助函数活得足够久

如果许多函数都需要临时设置 digits,自然会想把这件事自动化。可是,直接把 on.exit() 放入辅助函数并不行:

local_digits <- function(sig_digits) {
  op <- options(digits = sig_digits)
  on.exit(options(op), add = TRUE, after = FALSE)
}
neater <- function(x, sig_digits) {
  local_digits(1)
  print(x)
}
neater(pi)
#> [1] 3.141593

这是一个故意失败的示例:local_digits() 一返回就立即恢复选项,清理发生得太早。neater() 还没有打印,局部设置就已经失效。

defer() 的 envir 参数可以控制清理时机。继续采用相同模式:让辅助函数的 env 默认等于 parent.frame(),并将它作为 defer() 的第二个参数:

local_digits <- function(sig_digits, env = parent.frame()) {
  op <- options(digits = sig_digits)
  withr::defer(options(op), env)
}

neater(pi)
#> [1] 3

现在清理绑定到调用者,因此与 on.exit() 和 defer() 本身一样,这个辅助函数也能在测试中使用:

test_that("withr lets us write custom helpers for local state manipulation", {
  local_digits(1)
  expect_output(print(exp(1)), "3")
  
  local_digits(3)
  expect_output(print(exp(1)), "2.72")
})
#> Test passed with 2 successes 🌈.

print(exp(1))
#> [1] 2.718282

这些函数都取名为 local_*,是因为状态变化只在所绑定函数或测试的生命周期内存在。还可以用 local() 把影响范围收缩到测试内部更小的一段:

test_that("local_options() only affects a minimal amount of code", {
  withr::local_options(x = 1)
  expect_equal(getOption("x"), 1)

  local({
    withr::local_options(x = 2)
    expect_equal(getOption("x"), 2)
  })

  expect_equal(getOption("x"), 1)
})
#> Test passed with 3 successes 🎊.

getOption("x")
#> NULL

内层环境结束后,选项从 2 恢复到外层的 1;测试结束后,再恢复到最初状态。

什么是测试夹具

测试教程常用输入与预期结果都能写在几行代码里的小函数;真实的软件包往往没有这么简单,很多函数依赖全局状态。比如这个 message() 变体,只有 verbose 选项为 TRUE 时才输出消息。该怎样验证关闭选项确实能静音?

message2 <- function(...) {
  if (!isTRUE(getOption("verbose"))) {
    return()
  }
  message(...)
}

有时可以把隐含的全局状态变成显式参数,例如将函数重构为:

message3 <- function(..., verbose = getOption("verbose")) {
  if (!isTRUE(verbose)) {
    return()
  }
  message(...)
}

把外部状态写成参数通常值得做,因为它让“哪些输入决定了输出”更加清楚,但很多情况下无法这样改。测试夹具就用于临时改变全局状态,以测试原本难以触达的行为。软件工程中早已有这个术语;维基百科将测试夹具描述为“为了持续一致地测试某个物品、设备或软件而使用的东西”。

在这里,夹具就是一个用于改变状态的 local_* 函数。用 withr::local_options() 测试 message2() 的例子如下:

test_that("message2() output depends on verbose option", {
  withr::local_options(verbose = TRUE)
  expect_message(message2("Hi!"))
  
  withr::local_options(verbose = FALSE)
  expect_message(message2("Hi!"), NA)
})
#> now dyn.load("/home/runner/work/_temp/Library/glue/libs/glue.so") ...
#> now dyn.load("/home/runner/work/_temp/Library/vctrs/libs/vctrs.so") ...
#> Test passed with 2 successes 🥇.

案例:usethis

usethis 大量使用测试夹具。它负责管理 R 项目、特别是 R 包的文件和目录;许多函数只有在包项目中才有意义,因此测试也必须在包项目内进行。我们需要迅速在临时目录中建立一个最小 R 包,对它运行测试,再销毁它。

为此,项目把夹具放在 R/test-helpers.R,使它既能用于测试,也便于交互试验:

local_create_package <- function(dir = file_temp(), env = parent.frame()) {
  old_project <- proj_get_()
  
  # create new folder and package
  create_package(dir, open = FALSE) # A
  withr::defer(fs::dir_delete(dir), envir = env) # -A
  
  # change working directory
  withr::local_dir(dir, .local_envir = env) # B + -B
  
  # switch to new usethis project
  proj_set(dir) # C
  withr::defer(proj_set(old_project, force = TRUE), envir = env) # -C
  
  dir
}
先创建目录 A、切换目录 B、切换项目 C,测试结束后按 C、B、A 的相反顺序恢复和删除
图 1:设置与清理次序。本次原创技术示意图,不是实际测试运行记录。

设置顺序是 A、B、C,清理会自动按 −C、−B、−A 展开。这一点非常重要:必须先创建目录,才能切换进去;也必须先恢复原工作目录,才能删除临时目录,不能在目录仍是当前工作目录时删除它。

原文说明,local_create_package() 被用于 170 多个测试。下面这个例子检查 usethis::use_roxygen_md() 是否完成了在包中使用 roxygen2 并开启 Markdown 支持所需的设置。三个断言都直接或间接读取 DESCRIPTION 文件,所以夹具预先创建的最小有效包十分方便。测试结束后,这个包也会消失:

test_that("use_roxygen_md() adds DESCRIPTION fields", {
  pkg <- local_create_package()
  use_roxygen_md()
  
  expect_true(uses_roxygen_md())
  expect_equal(desc::desc_get("Roxygen", pkg)[[1]], "list(markdown = TRUE)")
  expect_true(desc::desc_has_fields("RoxygenNote", pkg))
})

作用域:一个测试、一个文件,还是整个包

文件作用域

目前我们把夹具用于单个测试。如果把 local_*() 调用移到 test_that() 外,它会影响文件中后续的测试。因此,在文件开头调用夹具,可以改变所有测试的行为。

优点是:如果每个测试原本都要重复调用夹具,现在可以减少重复。代价是:测试失败后,交互复现时必须记得先执行文件顶部的全部设置代码。原作者通常更愿意在多个测试中重复调用夹具;虽然代码稍有重复,调试失败的测试却容易得多。

包作用域

要在所有测试之前执行代码,可以创建 tests/testthat/setup.R。如果其中的设置需要清理,可使用专门的 teardown_env():

# Run before any test
write.csv(mtcars, "mtcars.csv")

# Run after all tests
withr::defer(unlink("mtcars.csv"), teardown_env())

这类设置最适合创建很多测试都要使用的外部资源。应尽量保持最少,因为交互调试前仍需手动执行它们。

其他容易忽略的问题

  • 有些 base R 函数依赖无法控制的状态,因此难以测试。比如无法用夹具把 interactive() 任意切换为真或假;原作者通常改用 rlang::is_interactive(),因为它可由 rlang_interactive 选项控制。
  • 在函数里使用夹具时,要留意返回值。比如函数执行 dir <- create_local_package() 后,不应把 dir 当成仍可用的目录返回:函数退出后,它指向的目录已经不存在。原文在这句话使用 create_local_package(),前面的定义名则是 local_create_package();这里保留并指出命名差异,其生命周期提醒相同。

来源与权利说明:项目主页声明软件许可证为 MIT + file LICENSE(https://testthat.r-lib.org/LICENSE.html);本文未另列文章转载许可证。本稿保留项目署名,。

版权与许可全文

以下保留本页涉及的来源材料或示例代码的版权、许可条件与免责声明;各自适用范围依原声明。中文翻译及编辑标注:未完纪,2026-10-05。

LICENSE-testthat.txt

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.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容