用 testthat 模拟依赖缺失、系统平台与时间推进

本文译写自 testthat 官方文档《Mocking》。页面未单独署个人作者名,项目页列有 Hadley Wickham 等开发者信息;这里保留文档团队归属,不据项目维护名单推定单篇作者。核验时站点标示版本 3.3.2,原文没有明确首发日期。

Mocking 指的是临时用一个更便于测试的实现替代原函数。它适合构造很难自然出现的失败场景,例如“依赖包 X 没有安装时会怎样”,也可以让测试更可靠、更快。这个词在这里是“做一个模拟版本”,并不是“嘲弄”测试。

不过,模拟也是一种高级技术,可能让测试变得脆弱,或者悄悄掩盖真正的问题。原作者建议,只有其他办法都不合适时才使用它。本文先用检查依赖的函数说明基本方法,再讨论 tidyverse 中的平台、发布工具和时钟案例,最后解释这种实现的取舍。

主要工具是 local_mocked_bindings(),用来模拟函数。testthat 另外提供 local_mocked_s3_method()、local_mocked_s4_method() 和 local_mocked_r6_class(),分别处理 S3 方法、S4 方法和 R6 类。理解函数模拟的基本思路后,就能把它应用到这些特殊情形。

阅读说明:以下代码和 #> 输出均来自原文,输出中的测试成功、时间数值和快照提示不代表本次执行结果。本次没有运行 R、修改命名空间、等待计时或调用 GitHub。

先看一个依赖开发机状态的测试

假设需要写一个类似 rlang::check_installed() 的函数:检查包是否安装,未安装时给出清楚的错误信息;还可以通过 min_version 指定最低版本。只用基础 R,可以这样写:

check_installed <- function(pkg, min_version = NULL) {
  if (!requireNamespace(pkg, quietly = TRUE)) {
    stop(sprintf("{%s} is not installed.", pkg))
  }
  if (!is.null(min_version)) {
    pkg_version <- packageVersion(pkg)
    if (pkg_version < min_version) {
      stop(sprintf(
        "{%s} version %s is installed, but %s is required.", 
        pkg, 
        pkg_version, 
        min_version
      ))
    }
  }

  invisible()
}

先测试不要求最低版本的情形。我们需要找一个肯定已安装的包,再找一个肯定没安装的包:

test_that("check_installed() checks package is installed", {
  expect_no_error(check_installed("testthat"))
  expect_snapshot(check_installed("doesntexist"), error = TRUE)
})
#> ── Warning: check_installed() checks package is installed ─────────────
#> Adding new snapshot:
#> Code
#>   check_installed("doesntexist")
#> Condition
#>   Error in `check_installed()`:
#>   ! {doesntexist} is not installed.
#> Test passed with 2 successes 🥇.

testthat 必然已经安装,所以这通常能工作;但 doesntexist 毕竟依赖外部状态。虽然可能性很小,哪天真的有人创建同名包,测试就不再有效。测试越少依赖无法控制的状态,通常就越稳定。

接着测试最低版本,仍需要构造两个看似安全的输入:

test_that("check_installed() checks minimum version", {
  expect_no_error(check_installed("testthat", "1.0.0"))
  expect_snapshot(check_installed("testthat", "99.99.999"), error = TRUE)
})
#> ── Warning: check_installed() checks minimum version ──────────────────
#> Adding new snapshot:
#> Code
#>   check_installed("testthat", "99.99.999")
#> Condition
#>   Error in `check_installed()`:
#>   ! {testthat} version 3.3.2 is installed, but 99.99.999 is required.
#> Test passed with 2 successes 🎊.

要求 99.99.999 版本大概不会意外满足,但错误快照里包含当前 testthat 的版本号。这意味着每次发布新版本,都可能需要更新快照。可以用 transform 把版本替换为固定的占位文字:

test_that("check_installed() checks minimum version", {
  expect_no_error(check_installed("testthat", "1.0.0"))
  expect_snapshot(
    check_installed("testthat", "99.99.999"), 
    error = TRUE, 
    transform = function(lines) gsub(packageVersion("testthat"), "<version>", lines)
  )
})
#> ── Warning: check_installed() checks minimum version ──────────────────
#> Adding new snapshot:
#> Code
#>   check_installed("testthat", "99.99.999")
#> Condition
#>   Error in `check_installed()`:
#>   ! {testthat} version <version> is installed, but 99.99.999 is required.
#> Test passed with 2 successes 🌈.

这样虽能处理快照变化,却不断增加补丁。换个方向:真正想测试的是 check_installed() 的决策,而不是当前机器到底装了哪些包。只要能控制 requireNamespace() 和 packageVersion() 的返回值,就能直接覆盖所需分支。

在自己的包命名空间中建立替换点

requireNamespace 和 packageVersion 不是我们自己的函数。为了模拟它们,需要先在自己的包命名空间中建立可以覆盖的绑定:

requireNamespace <- NULL
packageVersion <- NULL

对于“是否安装”测试,先把 requireNamespace() 替换成始终返回 TRUE 的函数,表示任何包都已安装;再替换成始终返回 FALSE,表示任何包都未安装。这样测试完全自包含,不再依赖机器上的实际包列表。

test_that("check_installed() checks package is installed", {
  local_mocked_bindings(requireNamespace = function(...) TRUE)
  expect_no_error(check_installed("package-name"))

  local_mocked_bindings(requireNamespace = function(...) FALSE)
  expect_snapshot(check_installed("package-name"), error = TRUE)
})
#> ── Warning: check_installed() checks package is installed ─────────────
#> Adding new snapshot:
#> Code
#>   check_installed("package-name")
#> Condition
#>   Error in `check_installed()`:
#>   ! {package-name} is not installed.
#> Test passed with 2 successes 🎊.

对于版本测试,让 requireNamespace() 固定返回 TRUE,同时让 packageVersion() 固定返回 numeric_version("2.0.0")。于是最低要求 1.0.0 应当通过,最低要求 3.4.5 应当报错;错误内容也不会随系统中真实安装的版本变化。

test_that("check_installed() checks minimum version", {
  local_mocked_bindings(
    requireNamespace = function(...) TRUE,
    packageVersion = function(...) numeric_version("2.0.0")
  )

  expect_no_error(check_installed("package-name", "1.0.0"))
  expect_snapshot(check_installed("package-name", "3.4.5"), error = TRUE)
})
#> ── Warning: check_installed() checks minimum version ──────────────────
#> Adding new snapshot:
#> Code
#>   check_installed("package-name", "3.4.5")
#> Condition
#>   Error in `check_installed()`:
#>   ! {package-name} version 2.0.0 is installed, but 3.4.5 is required.
#> Test passed with 2 successes 🥇.
被测检查函数在包命名空间中查找模拟绑定:安装状态与版本分别受控,测试结束后恢复;时间测试从 1 推进到 2
原创技术示意图:模拟替换的是包内查找所用的函数绑定;依赖状态与时钟可以在测试中受控。不是测试运行截图。

案例一:假装运行在另一种操作系统上

testthat::skip_on_os() 可以在指定操作系统上跳过测试。内部的 system_os() 是对 Sys.info()[["sysname"]] 的薄包装。要在任何机器上测试跳过逻辑,就需要控制它的返回值。

以下代码摘自 testthat 自身的包测试上下文,把系统固定成 Windows:

test_that("can skip on multiple oses", {
  local_mocked_bindings(system_os = function() "windows")

  expect_skip(skip_on_os("windows"))
  expect_skip(skip_on_os(c("windows", "linux")))
  expect_no_skip(skip_on_os("linux"))
})

这样,对 Windows 的跳过应发生,对 Windows 或 Linux 的跳过也应发生,而只针对 Linux 的跳过不应发生。原作者认为这里的逻辑足够简单,模拟一个平台已能说明和检查所需分支。

编者说明:这不是在操作系统层面把机器变成 Windows,也不会直接改变其他包的运行平台。若在自己的包中借用此模式,需要有自己的可替换包装函数;不能把这段针对 testthat 内部函数的测试片段视为任意脚本都可直接运行的独立程序。

案例二:减少测试中慢而不稳定的外部查询

usethis::use_release_issue() 会创建一个 GitHub issue,列出发布 R 包应完成的事项。一些条目取决于计算较慢的条件,因此它的测试会采用下面的模拟:

local_mocked_bindings(
  get_revdeps = function() character(),
  gh_milestone_number = function(...) NA
)

get_revdeps() 返回空字符向量,模拟没有反向依赖。真实的反向依赖计算可能很慢,也会随时间变化。gh_milestone_number() 返回 NA,模拟没有关联的 GitHub milestone,省去真实 API 调用及其变化。这些替代实现让相关测试更快,也把状态收回到测试自己控制的范围内。

编者说明:这里展示的是原测试中的两个替换点,不是对整个发布流程的完整隔离方案。use_release_issue() 本身具有向 GitHub 创建内容的副作用;不能因为模拟了两个查询,就认定调用完整流程不会产生外部写入。

案例三:让时间由测试手动推进

httr2::req_throttle() 用令牌桶一类的机制限制请求发出得过快,允许的请求量随时间增加。作者最初用 Sys.sleep() 测试这类逻辑,但每次等一两秒既拖慢测试,也可能因为实际经过时间超出预期而不可靠。

更好的办法是模拟一个读取时钟的函数,让它返回由测试控制的变量。先用一个较小的例子说明:返回自 1970 年 1 月 1 日零点以来经过秒数的 Unix 时间。

unix_time <- function() unclass(Sys.time())
unix_time()
#> [1] 1768230020

再写一个函数工厂,在创建时记下起点,返回一个能够计算已经经过多少秒的函数:

elapsed <- function() {
  start <- unix_time()
  function() {
    unix_time() - start
  }
}

timer <- elapsed()
Sys.sleep(0.5)
timer()
#> [1] 0.5019202

如果没有模拟,测试这个函数很容易让人觉得不值得。原作者也曾这样想,后来因为忽略两个 POSIXct 值相减的复杂性而引入了问题。模拟时钟后,就能准确推进时间而不用真实等待:

test_that("elapsed() measures elapsed time", {
  time <- 1
  local_mocked_bindings(unix_time = function() time)

  timer <- elapsed()
  expect_equal(timer(), 0)

  time <- 2
  expect_equal(timer(), 1)
})
#> Test passed with 2 successes 🎉.

创建计时器时 time 为 1,因此第一次调用得到 0;随后把 time 改为 2,再调用就应得到 1。这里验证的是时间差的逻辑,结果不依赖机器调度或睡眠精度。

模拟为何只修改包内绑定

模拟必须尽量保持作用范围清楚:它应影响被测包,而不是所有正在运行的代码。想象一下,如果替换了 testthat 自己也会使用的函数,就可能在测试自己的代码时意外破坏测试框架。

为此,local_mocked_bindings() 修改的是自己的包命名空间环境。下面的基础 R 代码展示这个想法:先保存旧实现,换上新实现,运行测试后再恢复。

old <- getFromNamespace("my_function", "mypackage")
assignInNamespace("my_function", new, "mypackage")

# run the test...

# restore the previous value
assignInNamespace("my_function", old, "mypackage")

上面只是原理示意。my_function、mypackage 和 new 是示例名称,手工执行时还必须保证异常退出也能恢复。日常测试应使用有局部作用域恢复机制的工具,而不是照此手工修改生产会话中的绑定。

这种实现带来两项限制:

  1. 包命名空间被锁定,不能临时向其中添加一个不存在的新绑定。因此,要模拟基础函数,必须预先留出可覆盖的绑定,例如 mean <- NULL。根据 R 的词法作用域及函数查找规则,这样的非函数绑定不会改变普通函数调用,却给模拟工具留下可替换的位置。
  2. 显式的 pkg::fun() 不会通过当前包命名空间中的同名绑定查找函数。若要模拟它,可以将 fun 导入自己的 NAMESPACE,例如使用 @importFrom pkg fun,或者定义一个自己的包装函数,并模拟这个包装层。

原作者认为这些限制符合预期:自己的函数容易临时替换;需要替换别人的函数时,则通过导入或包装,把作用范围限定在自己的包中。模拟可以验证难以制造的分支,但不能据此证明真实依赖、真实平台或网络集成也已经正常。

来源:testthat:Mocking。原文源文件为 vignettes/mocking.Rmd。项目主页列示软件许可为 MIT + file LICENSE;本文保留原文署名与来源;以下列明相关代码与文档的许可声明。

代码版权与许可声明

下列声明按对应项目原文保留,适用于文中相应代码;本稿的中文说明和编者校注不表示原作者对这些改动的认可。

testthat:MIT License

许可来源:上游项目许可原文。

# 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 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容