16.5 测试

为了避免写出糟糕的充满bug的代码,测试非常重要。单元测试的概念就是一小块一小块地测试你的代码。在R中,这意味着在函数级别测试。(系统集成测试是对整个软件的大规模的测试,但是比起数据分析,它更适用于应用程序的开发。)

每次更改一个函数时,你可能会破坏其他依赖于它的函数。这就是说每次改变一个函数时,你需要测试它可能带来的一切影响。手动尝试是不可能的,或者至少是非常耗时和乏味的,所以通常你不愿意这样做。因此,你需要把任务自动化。在R中,你有两种选择:

  • RUnit拥有“xUnit”的语法,这意味着它非常类似于Java的JUnit、.NET的NUnit、Python的PyUnit,以及其他一整套单元测试套件。如果你已使用过其他语言的单元测试工具,它就非常容易上手。

  • testthat有自己的语法,以及一些其他的特性。特别是它的测试缓存功能使它对于大型项目来说速度飞快。

来测试一下在6.3节中初次了解函数时编写的hypotenuse函数。它使用了一个你能用纸和笔就能计算的简单算法5。该函数包含在learning包:

5如果你对那句“用纸和笔来计算”感到畏惧。恭喜!你正在积极地成为一个R用户。

  1. hypotenuse <- function(x, y)
  2. {
  3. sqrt(x ^ 2 + y ^ 2)
  4. }

16.5.1 RUnit

RUnit中,每个测试都是一个没有输入的函数。每个测试都会使用包中的某个check*函数来将某些代码(在本例中即调用hypotenuse)运行的实际结果与其预期值相比较。下例使用checkEqualsNumeric,因为是在比较两个数字:

  1. library(RUnit)
  2. test.hypotenuse.3_4.returns_5 <- function()
  3. {
  4. expected <- 5
  5. actual <- hypotenuse(3, 4)
  6. checkEqualsNumeric(expected, actual)
  7. }

提示

目前的测试没有统一的命名约定,但RUnit会默认查找以test为开头的函数。在这里使用命名约定旨在最大限度地把问题说清楚。测试需要类似test.name_of_function.description_of_inputs.returns_a_value的名称形式。

有时候,我们要确保一个函数能以正确的方式失败。例如,我们能测试:当没有提供输入时,hypotenuse会失败:

  1. test.hypotenuse.no_inputs.fails <- function()
  2. {
  3. checkException(hypotenuse())
  4. }

许多算法在输入很小或很大时会损失精度,所以最好要测试这些边界条件。R中可以表示的最小和最大的正数值由内置的.Machine常数double.xmindouble.xmax给出:

  1. .Machine$double.xmin
  2. ## [1] 2.225e-308
  3. .Machine$double.xmax
  4. ## [1] 1.798e+308

对于小型和大型测试,我们会选择往这些限制值靠近。在小数值的情况下,我们需要手动地缩小试验的容差tolerance。默认情况下,checkEqualsNumeric对于实际测量结果在1e-8的范围内会认为测试通过(它使用绝对而不是相对误差)。我们把该值设为一个比输入小几个数量级的值,以确保测试按计划地失败:

  1. test.hypotenuse.very_small_inputs.returns_small_positive <- function()
  2. {
  3. expected <- sqrt(2) * 1e-300
  4. actual <- hypotenuse(1e-300, 1e-300)
  5. checkEqualsNumeric(expected, actual, tolerance = 1e-305)
  6. }
  7. test.hypotenuse.very_large_inputs.returns_large_finite <- function()
  8. {
  9. expected <- sqrt(2) * 1e300
  10. actual <- hypotenuse(1e300, 1e300)
  11. checkEqualsNumeric(expected, actual)
  12. }

可能的测试有无数个。例如,如果我们传入的是缺失值会发生什么?是NULL值、无限值、字符值、向量、矩阵或数据帧呢?又或者是我们期望在非欧几里得空间里得到答案?要进行彻底的测试需要你展开充分的想像力。释放你内在的童心,构想突破性测试吧。现在,在此打住。把所有的测试保存到一个文件中;RUnit会默认查找所有以“runit”开头并以.R为扩展名的文件。这些测试可以在learningr包的tests目录中找到。

既然已有一些测试用例,让我们来运行它们。过程有两步。

首先,使用defineTestSuite定义一个测试套件。这个函数接受一个字符串输入作为命名(将在其输出中使用),以及一个包含了你所有的测试的路径参数。如果没有按标准的方式命名你的测试函数或文件,可以提供一个模式来识别它们:

  1. test_dir <- system.file("tests", package = "learningr")
  2. suite <- defineTestSuite("hypotenuse suite", test_dir)

第二步是使用runTestSuite来运行它们(已根据需要添加了额外的换行符,以适应本书的书写格式):

  1. runTestSuite(suite)
  2. ##
  3. ##
  4. ## Executing test function test.hypotenuse.3_4.returns_5 ...
  5. ## done successfully.
  6. ##
  7. ##
  8. ##
  9. ## Executing test function test.hypotenuse.no_inputs.fails ...
  10. ## done successfully.
  11. ##
  12. ##
  13. ##
  14. ## Executing test function
  15. ## test.hypotenuse.very_large_inputs.returns_large_finite ...
  16. ## Timing stopped at: 0 0 0 done successfully.
  17. ##
  18. ##
  19. ##
  20. ## Executing test function
  21. ## test.hypotenuse.very_small_inputs.returns_small_positive ...
  22. ## Timing stopped at: 0 0 0 done successfully.
  23. ## Number of test functions: 4
  24. ## Number of errors: 0
  25. ## Number of failures: 2

这将运行每个它能找到的测试并显示是否通过、失败或抛出了错误。在这种情况下,你可以看到小和大的输入测试失败。是什么出错了呢?

算法的问题在于我们对每个输入求了平方值。对大的数字求平方使得它比R能表示的最大的数(双精度)还要大,所以其结果为无穷大。对非常小的数字求平方会使它们更小,以致于R认为其值为零。(有更好的算法可以避免这个问题,请参阅?hypotenus帮助页面中的链接,讨论在实际使用中更好的算法。)

RUnit没有内置的checkWarning函数来测试警告。要测试一个警告是否已被抛出,我们需要一个技巧:把warn选项设置为2,使警告变成错误,然后当测试函数退出时使用on.exit恢复其原始值。记得on.exit内的代码在函数退出时总会运行,不管它是否成功完成或抛出一个错误:

  1. test.log.minus1.throws_warning <- function()
  2. {
  3. old_ops <- options(warn = 2) #警告变成错误
  4. on.exit(old_ops) #回复原有行为
  5. checkException(log(-1))
  6. }

16.5.2 testthat

虽然testthat具有不同的语法,但其原理几乎相同。主要的区别在于:不是每个测试都是一个函数,它是通过调用包中的某个expect_*函数。例如,expect_equal相当于RunitcheckEqualsNumeric函数。翻译过来的测试(也在learningr包的tests目录中)看起来是这样:

  1. library(testthat)
  2. expect_equal(hypotenuse(3, 4), 5)
  3. expect_error(hypotenuse())
  4. expect_equal(hypotenuse(1e-300, 1e-300), sqrt(2) * 1e-300, tol = 1e-305)
  5. expect_equal(hypotenuse(1e300, 1e300), sqrt(2) * 1e300)

为了运行它,我们需要调用test_file函数,其参数为包含测试用例的文件名;或调用test_dir,其参数为测试用例所在的目录的路径。因为只有一个文件,所以使用test_file

  1. filename <- system.file(
  2. "tests",
  3. "testthat_hypotenuse_tests.R",
  4. package = "learningr"
  5. )
  6. test_file(filename)
  7. ## ..12
  8. ##
  9. ## 1. Failure: (unknown) -----------------------------------------------------
  10. ## learningr::hypotenuse(1e-300, 1e-300) not equal to sqrt(2) * 1e-300
  11. ## Mean relative difference: 1
  12. ##
  13. ## 2. Failure: (unknown) -----------------------------------------------------
  14. ## learningr::hypotenuse(1e+300, 1e+300) not equal to sqrt(2) * 1e+300
  15. ## Mean relative difference: Inf

运行此测试有两种方法:一是使用test_that在命令行(或者说看似更像是复制和粘贴)测试代码,另一种是test_package,在一个包中运行所有测试,这会使测试没有输出的函数更容易。

RUnit不同,警告可以直接通过expect_warning测试:

  1. expect_warning(log(-1))