16.5 测试
为了避免写出糟糕的充满bug的代码,测试非常重要。单元测试的概念就是一小块一小块地测试你的代码。在R中,这意味着在函数级别测试。(系统或集成测试是对整个软件的大规模的测试,但是比起数据分析,它更适用于应用程序的开发。)
每次更改一个函数时,你可能会破坏其他依赖于它的函数。这就是说每次改变一个函数时,你需要测试它可能带来的一切影响。手动尝试是不可能的,或者至少是非常耗时和乏味的,所以通常你不愿意这样做。因此,你需要把任务自动化。在R中,你有两种选择:
RUnit
拥有“xUnit”的语法,这意味着它非常类似于Java的JUnit
、.NET的NUnit
、Python的PyUnit
,以及其他一整套单元测试套件。如果你已使用过其他语言的单元测试工具,它就非常容易上手。testthat
有自己的语法,以及一些其他的特性。特别是它的测试缓存功能使它对于大型项目来说速度飞快。
来测试一下在6.3节中初次了解函数时编写的hypotenuse
函数。它使用了一个你能用纸和笔就能计算的简单算法5。该函数包含在learning
包:
5如果你对那句“用纸和笔来计算”感到畏惧。恭喜!你正在积极地成为一个R用户。
hypotenuse <- function(x, y)
{
sqrt(x ^ 2 + y ^ 2)
}
16.5.1 RUnit
在RUnit
中,每个测试都是一个没有输入的函数。每个测试都会使用包中的某个check*
函数来将某些代码(在本例中即调用hypotenuse
)运行的实际结果与其预期值相比较。下例使用checkEqualsNumeric
,因为是在比较两个数字:
library(RUnit)
test.hypotenuse.3_4.returns_5 <- function()
{
expected <- 5
actual <- hypotenuse(3, 4)
checkEqualsNumeric(expected, actual)
}
提示
目前的测试没有统一的命名约定,但
RUnit
会默认查找以test
为开头的函数。在这里使用命名约定旨在最大限度地把问题说清楚。测试需要类似test.name_of_function.description_of_inputs.returns_a_value
的名称形式。
有时候,我们要确保一个函数能以正确的方式失败。例如,我们能测试:当没有提供输入时,hypotenuse
会失败:
test.hypotenuse.no_inputs.fails <- function()
{
checkException(hypotenuse())
}
许多算法在输入很小或很大时会损失精度,所以最好要测试这些边界条件。R中可以表示的最小和最大的正数值由内置的.Machine
常数double.xmin
和double.xmax
给出:
.Machine$double.xmin
## [1] 2.225e-308
.Machine$double.xmax
## [1] 1.798e+308
对于小型和大型测试,我们会选择往这些限制值靠近。在小数值的情况下,我们需要手动地缩小试验的容差tolerance
。默认情况下,checkEqualsNumeric
对于实际测量结果在1e-8
的范围内会认为测试通过(它使用绝对而不是相对误差)。我们把该值设为一个比输入小几个数量级的值,以确保测试按计划地失败:
test.hypotenuse.very_small_inputs.returns_small_positive <- function()
{
expected <- sqrt(2) * 1e-300
actual <- hypotenuse(1e-300, 1e-300)
checkEqualsNumeric(expected, actual, tolerance = 1e-305)
}
test.hypotenuse.very_large_inputs.returns_large_finite <- function()
{
expected <- sqrt(2) * 1e300
actual <- hypotenuse(1e300, 1e300)
checkEqualsNumeric(expected, actual)
}
可能的测试有无数个。例如,如果我们传入的是缺失值会发生什么?是NULL
值、无限值、字符值、向量、矩阵或数据帧呢?又或者是我们期望在非欧几里得空间里得到答案?要进行彻底的测试需要你展开充分的想像力。释放你内在的童心,构想突破性测试吧。现在,在此打住。把所有的测试保存到一个文件中;RUnit
会默认查找所有以“runit”开头并以.R
为扩展名的文件。这些测试可以在learningr
包的tests目录中找到。
既然已有一些测试用例,让我们来运行它们。过程有两步。
首先,使用defineTestSuite
定义一个测试套件。这个函数接受一个字符串输入作为命名(将在其输出中使用),以及一个包含了你所有的测试的路径参数。如果没有按标准的方式命名你的测试函数或文件,可以提供一个模式来识别它们:
test_dir <- system.file("tests", package = "learningr")
suite <- defineTestSuite("hypotenuse suite", test_dir)
第二步是使用runTestSuite
来运行它们(已根据需要添加了额外的换行符,以适应本书的书写格式):
runTestSuite(suite)
##
##
## Executing test function test.hypotenuse.3_4.returns_5 ...
## done successfully.
##
##
##
## Executing test function test.hypotenuse.no_inputs.fails ...
## done successfully.
##
##
##
## Executing test function
## test.hypotenuse.very_large_inputs.returns_large_finite ...
## Timing stopped at: 0 0 0 done successfully.
##
##
##
## Executing test function
## test.hypotenuse.very_small_inputs.returns_small_positive ...
## Timing stopped at: 0 0 0 done successfully.
## Number of test functions: 4
## Number of errors: 0
## Number of failures: 2
这将运行每个它能找到的测试并显示是否通过、失败或抛出了错误。在这种情况下,你可以看到小和大的输入测试失败。是什么出错了呢?
算法的问题在于我们对每个输入求了平方值。对大的数字求平方使得它比R能表示的最大的数(双精度)还要大,所以其结果为无穷大。对非常小的数字求平方会使它们更小,以致于R认为其值为零。(有更好的算法可以避免这个问题,请参阅?hypotenus
帮助页面中的链接,讨论在实际使用中更好的算法。)
RUnit
没有内置的checkWarning
函数来测试警告。要测试一个警告是否已被抛出,我们需要一个技巧:把warn
选项设置为2
,使警告变成错误,然后当测试函数退出时使用on.exit
恢复其原始值。记得on.exit
内的代码在函数退出时总会运行,不管它是否成功完成或抛出一个错误:
test.log.minus1.throws_warning <- function()
{
old_ops <- options(warn = 2) #警告变成错误
on.exit(old_ops) #回复原有行为
checkException(log(-1))
}
16.5.2 testthat
虽然testthat
具有不同的语法,但其原理几乎相同。主要的区别在于:不是每个测试都是一个函数,它是通过调用包中的某个expect_*
函数。例如,expect_equal
相当于Runit
的checkEqualsNumeric
函数。翻译过来的测试(也在learningr
包的tests目录中)看起来是这样:
library(testthat)
expect_equal(hypotenuse(3, 4), 5)
expect_error(hypotenuse())
expect_equal(hypotenuse(1e-300, 1e-300), sqrt(2) * 1e-300, tol = 1e-305)
expect_equal(hypotenuse(1e300, 1e300), sqrt(2) * 1e300)
为了运行它,我们需要调用test_file
函数,其参数为包含测试用例的文件名;或调用test_dir
,其参数为测试用例所在的目录的路径。因为只有一个文件,所以使用test_file
:
filename <- system.file(
"tests",
"testthat_hypotenuse_tests.R",
package = "learningr"
)
test_file(filename)
## ..12
##
## 1. Failure: (unknown) -----------------------------------------------------
## learningr::hypotenuse(1e-300, 1e-300) not equal to sqrt(2) * 1e-300
## Mean relative difference: 1
##
## 2. Failure: (unknown) -----------------------------------------------------
## learningr::hypotenuse(1e+300, 1e+300) not equal to sqrt(2) * 1e+300
## Mean relative difference: Inf
运行此测试有两种方法:一是使用test_that
在命令行(或者说看似更像是复制和粘贴)测试代码,另一种是test_package
,在一个包中运行所有测试,这会使测试没有输出的函数更容易。
与RUnit
不同,警告可以直接通过expect_warning
测试:
expect_warning(log(-1))