包功能中的单元测试和检查:我们是否同时进行检查?

unit tests and checks in package function: do we do checks in both?

我是 R 和包开发的新手,请多多包涵。我正在编写测试用例以保持包符合标准做法。但是我很困惑,如果我在 testthat 中进行检查,我是否应该不在包函数中进行 if/else 检查?

my_function<-function(dt_genetic, dt_gene, dt_snpBP){

if((is.data.table(dt_genetic) & is.data.table(dt_gene) & is.data.table(dt_snpBP))== FALSE){
stop("data format unacceptable")
}
## similary more checks on column names and such

} ## function ends

在我的测试中-data_integrity.R

## create sample data.table
test_gene_coord<-data.table(GENE=c("ABC","XYG","alpha"),"START"=c(10,200,320),"END"=c(101,250,350))
test_snp_pos<-data.table(SNP=c("SNP1","SNP2","SNP3"),"BP"=c(101,250,350))
test_snp_gene<-data.table(SNP=c("SNP1","SNP2","SNP3"),"GENE"=c("ABC","BRCA1","gamma"))


## check data type

test_that("data types correct works", {
   expect_is(test_data_table,'data.table')
expect_is(test_gene_coord,'data.table')
expect_is(test_snp_pos,'data.table')

expect_is(test_snp_gene,'data.table')
expect_is(test_gene_coord$START, 'numeric')
expect_is(test_gene_coord$END, 'numeric')
expect_is(test_snp_pos$BP, 'numeric')
})

## check column names 

test_that("column names works", {

 expect_named(test_gene_coord, c("GENE","START","END"))
 expect_named(test_snp_pos, c("SNP","BP"))
 expect_named(test_snp_gene, c("SNP","GENE"))

})

当我 运行 devtools::test() 所有测试都通过时,但这是否意味着我不应该在我的功能内进行测试?

请原谅我,如果这看起来很幼稚,但这很令人困惑,因为这对我来说完全陌生。

已编辑:data.table if 检查。

(这是我对这个问题的评论的扩展。我的评论来自 quasi-professional 程序员;我在这里所说的一些内容可能“总体上”不错,但从理论角度来看并不完美.)

测试有很多“类型”,但我将重点区分“unit-tests”和“断言”。对我来说,主要区别在于 unit-tests 通常仅由开发人员 运行,断言 运行 在 run-time.

断言

当您提到向您的函数添加测试时,这对我来说听起来像是 断言 :一个 object 满足特定 属性 假设的编程语句。当数据由用户提供或来自外部来源(数据库)时,这通常是必要的,其中数据的大小或质量以前是未知的。

有“正式”的断言包,包括assertthat, assertr, and assertive;虽然我对它们中的任何一个都没有什么经验,但 base R 中也有足够的支持,严格来说这些并不是 必需的 。最基本的方法是

if (!inherits(mtcars, "data.table")) {
  stop("'obj' is not 'data.table'")
}
# Error: 'obj' is not 'data.table'

以牺牲几行代码为代价,让您拥有绝对的控制权。还有另一个函数可以稍微缩短它:

stopifnot(inherits(mtcars, "data.table"))
# Error: inherits(mtcars, "data.table") is not TRUE

可以提供多个条件,全部必须TRUE才能通过。 (与许多 R 条件语句(例如 if 不同,此语句必须准确解析为 TRUEstopifnot(3) 不会通过。)在 R < 4.0 中,错误消息不受控制,但从 R 开始-4.0 现在可以命名它们了:

stopifnot(
  "mtcars not data.frame" = inherits(mtcars, "data.frame"),
  "mtcars data.table error" = inherits(mtcars, "data.table")
)
# Error: mtcars data.table error

在某些编程语言中,这些断言更多 declarative/deliberate 以便编译可以从生产可执行文件中优化它们。从这个意义上说,它们在开发过程中很有用,但对于生产来说,假设之前有效的一些步骤不再需要验证。我相信在 R 中没有一种自动的方法来做到这一点(特别是因为它通常不会“编译成可执行文件”),但是可以通过模仿这种行为的方式来设计一个函数:

myfunc <- function(x, ..., asserts = getOption("run_my_assertions", FALSE)) {
  # this one only runs when the user explicitly says "asserts=TRUE"
  if (asserts) stopifnot("'x' not a data.frame" = inherits(x, "data.frame"))
  # this assertion runs all the time
  stopifnot("'x' not a data.frame" = inherits(x, "data.table"))
}

我没有在 R 包中经常看到这种逻辑或流程。

无论如何,我的断言假设是那些未优化掉的断言(由于编译或用户参数)每次函数 运行s 都会执行。这往往会确保“更安全”的流程,并且是一个好主意,特别是对于 less-experienced 没有经验(“还没有被烧够”)的开发人员来说某些调用有多少种方式可以出错

单元测试

它们在目的和运行时效方面都有点不同。

首先,unit-tests 并不是每次使用函数时都会 运行。它们通常在完全不同的文件中定义,根本不在函数中[^1]。它们是对您的函数的有意调用集,testing/confirming 给定特定输入的特定行为。

对于 testthat 包,包 ./tests/testthat/ sub-directory 中的 R 脚本(匹配某些文件名模式)将在命令中 运行 作为 unit-tests. (存在其他 unit-test 包。)(Unit-tests 不要求它们对包进行操作;它们可以位于任何位置,并且 运行 在任何文件集或文件目录上。我我以“包”为例。)

旁注:在您的函数中包含一些 testthat 工具以进行 运行 时间验证当然是可行的。例如,可以将 stopifnot(inherits(x, "data.frame")) 替换为 expect_is(x, "data.frame"),它将失败并显示为 non-frames,并通过上面测试的所有三种类型的帧。我不知道这总是最好的方法,而且我还没有在我使用的包中看到它的用途。 (并不意味着它不存在。如果您在包的“导入:”中看到 testthat,那么它是可能的。)

这里的前提不是验证运行时间objects。前提是在给定非常具体的输入 [^2] 的情况下验证函数的性能。例如,可以定义 unit-test 来确认您的函数在 class、"data.frame""tbl_df""data.table" 的帧上同样运行良好。 (这不是 throw-away unit-test,顺便说一句。)

考虑一个温顺的函数,人们认为它可以在任何 data.frame-like object:

上同样有效地工作
func <- function(x, nm) head(x[nm], n = 2)

要测试它是否接受各种类型,可以简单地在控制台上调用它:

func(mtcars, "cyl")
#               cyl
# Mazda RX4       6
# Mazda RX4 Wag   6

当同事抱怨此功能不起作用时,您可能想知道他们使用的是 tidyverse(和 tibble)还是 data.table,因此您可以快速测试控制台:

func(tibble::as_tibble(mtcars), "cyl")
# # A tibble: 2 x 1
#     cyl
#   <dbl>
# 1     6
# 2     6
func(data.table::as.data.table(mtcars), "cyl")
# Error in `[.data.table`(x, nm) : 
#   When i is a data.table (or character vector), the columns to join by must be specified using 'on=' argument (see ?data.table), by keying x (i.e. sorted, and, marked as sorted, see ?setkey), or by sharing column names between x and i (i.e., a natural join). Keyed joins might have further speed benefits on very large data due to x being sorted in RAM.

所以现在您知道问题出在哪里(如果还不知道如何解决)。如果你用 data.table“按原样”测试这个,有人可能会想尝试这样的事情(显然是错误的)修复:

func <- function(x, nm) head(x[,..nm], n = 2)
func(data.table::as.data.table(mtcars), "cyl")
#    cyl
# 1:   6
# 2:   6

虽然这有效,但不幸的是,它现在对其他两个 frame-like objects 失败了。

这个难题的答案是进行测试,以便在您对函数进行更改时,如果 previously-successful属性假设现在改变了,你马上就会知道。如果将所有这三个测试都合并到一个 unit-test 中,人们可能会做一些事情,例如

library(testthat)
test_that("func works with all frame-like objects", {
  expect_silent(func(mtcars, "cyl"))
  expect_silent(func(tibble::as_tibble(mtcars), "cyl"))
  expect_silent(func(data.table::as.data.table(mtcars), "cyl"))
})
# Error: Test failed: 'func works with all frame-like objects'

经过一些研究,您找到了一种您认为可以满足所有三个条件的方法 frame-like objects:

func <- function(x, nm) head(subset(x, select = nm), n = 2)

然后 运行 你的 unit-tests 再次:

test_that("func works with all frame-like objects", {
  expect_silent(func(mtcars, "cyl"))
  expect_silent(func(tibble::as_tibble(mtcars), "cyl"))
  expect_silent(func(data.table::as.data.table(mtcars), "cyl"))
})

(无输出...沉默是金。)

类似于编程中的许多事情,对于如何组织、时尚,甚至何时创建这些unit-tests,有很多意见。其中许多意见对某些人来说是正确的。我倾向于开始的一种策略是:

  • 因为我知道我的函数可以用在所有三个 frame-like object 上,所以我经常先发制人地设置一个给定每种类型的 object 的测试(你会对它们之间的一些潜在差异感到惊讶);
  • 当我发现或收到错误报告时,我在确认错误后做的第一件事就是编写一个触发该错误的测试,前提是这样做需要最少的输入; 然后 我修复了这个错误,运行 我的 unit-tests 确保这个新测试现在通过(现在没有其他测试失败)

经验会决定在错误出现之前先发制人地编写测试类型。

顺便说一下,测试不一定总是“没有错误”。他们可以测试很多东西:

  • 沉默(没有错误)
  • 预期 messages、warnings 或 stop 错误(无论是内部生成的还是从另一个函数传递的)
  • 输出class(matrixnumeric),维度,属性
  • 期望值(返回33.14可能是个问题)

有些人会说 unit-tests 写起来没有乐趣,并且厌恶在它们上面付出的努力。虽然我不同意 unit-tests 不好玩,但当我对一个函数进行简单修复无意中破坏了其他几件事时,我已经无数次地自焚了......并且因为我部署了“简单修复”而不适用 unit-tests,我只是将错误报告从 “这个标题中有“NA” 转移到 “应用程序崩溃,每个人都很生气”(真实故事)。

对于某些包,unit-testing 可以在 分钟内完成 ;对于其他人,可能需要几分钟或几小时。由于函数的复杂性,我的一些 unit-tests 处理“大型”数据结构,因此一次测试需要几分钟才能显示其成功。我的大多数 unit-tests 都是相对瞬时的,输入长度为 1 到 3 的向量,或者 frames/matrices 有 2-4 行 and/or 列。

到目前为止,这还不是一份完整的测试文档。有关于不同技术的书籍、教程和无数博客。一个很好的参考是 Hadley 关于 R Packages 的书,Testing 章节:http://r-pkgs.had.co.nz/tests.html。我喜欢那个,但它远非唯一。

[^1] 切线地说,我相信 roxygen2 包提供的一个强大功能是将函数的文档存储在与函数本身相同的文件中的便利性。它的接近性“提醒”我在处理代码时更新文档。如果我们能确定一种合理的方法来类似地向函数文件本身添加正式的 testthat(或类似的)unit-tests,那就太好了。我已经看到(有时 使用 )非正式 unit-tests 通过在 roxygen2 @examples 部分中包含特定代码:当文件呈现为 .Rd 文件,示例代码中的任何错误都会在控制台上提醒我。我知道这种技术草率而草率,一般来说,我只在更正式的 unit-testing 不会完成时才建议使用它。它确实会使帮助文档比需要的更冗长。

[^2] 我在上面说过“给定非常具体的输入”:另一种方法是所谓的“模糊测试”,一种使用随机或无效输入调用函数的技术。我相信这对于搜索堆栈溢出 memory-access 或导致程序崩溃的类似问题 and/or 执行错误代码非常有用。我没有看到它在 R (ymmv) 中使用过。