Introduction

This is not a pipe... nor an introduction to automated tests in R. If this is what you are looking for, please refer to other sources like this one.

People tend to learn (if at all) by imitation. I encourage you to do this with a grain (or more) of salt. Here two real life situations:

  • I was showing a co-worker who is a beginner in R how to read a csv file using data.table's fread function, from a file which was labeled "risc_data.csv" (see the typo?). Then, when asked how to write the data back to a csv file, I recommended to use data.table's fwrite. So the co-worker does this, to a file called something like "risc_merged_data.csv". To my comment that there is a spelling mistake in the file name, he replied: I see, but the input file had the spelling problem in the name, so I thought I would leave it like this. And me: hmm, no, why would you propagate an error after you recognize it as an error?
  • While doing a code review for a co-worker who is not familiar with our system, I commented about a piece of code which was redundant and I explained why this is so. He immediately see my point, and explained to me that he did like this because he saw this method in several places in our code. Which was unfortunately true. So take care of your code. Even tiny things, like a few lines of redundant code, can have implications: why should I bother to remove it, it does not do any harm... yes, and soon after somebody comes around and copies it...

Lesson learned: recognize when you are wrong and then learn to be right. In the spirit of getting rid of hello world let's be wrong first:

assert_true <- function()
{
  testthat::expect_true(FALSE)
}
assert_true()
# Error: FALSE is not TRUE
# 
# `actual`:   FALSE
# `expected`: TRUE 

testthat is a powerful package for testing R code. With testthat 3rd edition, we gained the possibility to run tests in parallel, which is very useful. Unfortunately, in the same time, mocking was deprecated.

On the bright side, mockthat appeared, so just in case the authors ever come to read this, thanks a lot for this package. Your package saved me from having to spend countless hours in rewriting tests, and it has a few very nice features, for example I can simultaneously mock functions from different packages:

port_alert <- function()
  {
    port <- httpuv::randomPort()
    is_am <- lubridate::am(Sys.time())
    
    paste(
      "Opening port", port, "| is_am:", is_am
    )
  }

port_alert()
# "Opening port 33594 | is_am: TRUE"

mocked_port <- mockery::mock(42)
mocked_am <- mockery::mock("not a boolean")

mockthat::with_mock(
  `httpuv::randomPort` = mocked_port,
  `lubridate::am` = mocked_am,
  port_alert()
)
# "Opening port 42 | is_am: not a boolean"

 

testthat best practices 

data <- data.table::data.table(c1 = 1:3)
# do not do this
testthat::expect_true(inherits(x = data, what = "data.tableTYPO"))
# Error: inherits(x = data, what = "data.tableTYPO") is not TRUE
# 
# `actual`:   FALSE
# `expected`: TRUE 

# but do this - more verbose error message
testthat::expect_s3_class(object = data, class = "data.tableTYPO")
#  Error: `data` inherits from 'data.table'/'data.frame' not 'data.tableTYPO'. 

#----------------------------------------------------
# do not do this
testthat::expect_true(
  all(data$c1 == c(1, 3, 4)) 
)
# Error: all(data$c1 == c(1, 3, 4)) is not TRUE
# 
# `actual`:   FALSE
# `expected`: TRUE 

# do this instead (more verbose error message)
testthat::expect_equal(data$c1, c(1, 3, 4))
# Error: data$c1 not equal to c(1, 3, 4).
# 2/3 mismatches (average diff: 1)
# [2] 2 - 3 == -1
# [3] 3 - 4 == -1

# or even better, use bang bang operator to actually print the values of
# data$c1 - see ?testthat::quasi_label
testthat::expect_equal(!!data$c1, c(1, 3, 4))
# Error: 1:3 not equal to c(1, 3, 4).
# 2/3 mismatches (average diff: 1)
# [2] 2 - 3 == -1
# [3] 3 - 4 == -1 


#----------------------------------------------------
# do not do this
testthat::expect_true(length(data$c1) == 4)
# Error: length(data$c1) == 4 is not TRUE
# 
# `actual`:   FALSE
# `expected`: TRUE 

# but do this (more verbose error message)
testthat::expect_length(data$c1, 4)
# Error: data$c1 has length 3, not length 4. 

 

How to write unit tests for an R function

Here a few contrived examples for the rlist::list.flatten function.

testthat::test_that(
  desc = "list.flatten with use.names = TRUE",
  code = {
    # arrange
    x <- list(running_times_hours = list(half_marathon = 2, marathon = 5))
    
    # act
    x_flat <- rlist::list.flatten(x = x, use.names = TRUE)
    
    # assert
    testthat::expect_is(x_flat, class = "list")
    
    testthat::expect_named(
      object = x_flat,
      expected = c("running_times_hours.half_marathon", "running_times_hours.marathon")
    )
    
    testthat::expect_equal(
      object = unname(x_flat),
      expected = list(2, 5)
    )
  }
)

testthat::test_that(
  desc = "list.flatten with use.names = FALSE",
  code = {
    # arrange
    x <- list(running_times_hours = list(half_marathon = 2, marathon = 5))
    
    # act
    x_flat <- rlist::list.flatten(x = x, use.names = FALSE)
    
    # assert
    testthat::expect_is(x_flat, class = "list")
    
    testthat::expect_named(
      object = x_flat,
      expected = NULL
    )
    
    testthat::expect_equal(
      object = x_flat,
      expected = list(2, 5)
    )
  }
)

Note: please make sure that the tests within testthat::test_that(...) do not depend on states set in other testthat::test_that(...), even if they are in the same file, and usually executed in order. 

I recently spent (much too many) hours trying to understand why a test fails in a framework I wasn't familiar with. There were some minimal changes in the code, so I adjusted the test, without realizing that that specific test was actually based on some internal data that was set in previous tests, which had nothing to do with the change in the code. Do yourself a favor and make sure that the test within testthat::test_that(...) are self contained. You can thank me later...

Testing R6 classes

1. Testing active bindings

In R6, active bindings can be redefined and even removed from the class definition. Therefore they can be easily mocked for test purposes.

ClassToTest <- R6::R6Class(
  active = list(
    activeFunction1 = function() {42},
    activeFunction2 = function() {-Inf}
  )
)

ClassToTest$active
# $activeFunction1
# function() {42}
# 
# $activeFunction2
# function() {-Inf}

Tester <- ClassToTest$new()
Tester$activeFunction1
# 42
Tester$activeFunction2
# -Inf

# redefine an active binding - note: use the class generator, not the instance of the class
ClassToTest$active$activeFunction2 <- function() {1}
Tester2 <- ClassToTest$new()
Tester2$activeFunction2
# 1

# remove an active binding - note: use the class generator, not the instance of the class
ClassToTest$active$activeFunction2 <- NULL
ClassToTest$active
# $activeFunction1
# function() {42}

Tester3 <- ClassToTest$new()
Tester3$activeFunction2
# NULL

 2. Testing private and public data members

Data members can be overwritten using R6 class generator setter:

ClassToTest <- R6::R6Class(
  private = list(
    max_n_calls = 1e5,
    n_calls = 0
  ),
    
  public = list(
    publicFunction = function()
    {
      if (private$n_calls >= private$max_n_calls)
      {
        stop("maximum number of calls reached")
      }
      private$n_calls <- private$n_calls + 1
    }
  )
)

ClassToTest$set("private", "max_n_calls", 1)
# Error in ClassToTest$set("private", "max_n_calls", 1) : 
#   Can't add max_n_calls because it already present in  generator.

ClassToTest$set("private", "max_n_calls", 1, overwrite = TRUE)

Tester <- ClassToTest$new()
Tester$publicFunction()

testthat::expect_error(
  object = Tester$publicFunction(),
  regexp = "maximum number of calls reached"
)

 

3. Testing public methods

3. a) Testing public methods using inheritance

In R6, derived classes have access to both public and private methods of their base classes. You can therefore create a Tester class which inherits from the class to test, and in which you do the necessary modifications for your tests.

ClassToTest <- R6::R6Class(
  private = list(
    privateMember = 7,
    privateFunction = function(x) 
    {
      return(x * private$privateMember)
    }
  )
)

TesterClass <- R6::R6Class(
  inherit = ClassToTest,
  public = list(
    run = function(x) 
    {
      private$privateMember <- 40
      private$privateFunction(x)
    }
  )
)

Tester <- TesterClass$new()
Tester$run(1)
# 40

3.b) Testing public methods by redefining them using unlockBinding


  In R6, public methods can be redefined using unlockBinding:

ClassToTest <- R6::R6Class(
  private = list(
    privateFunction = function() {message("privateFunction")}
  ),
  public = list(
    publicFunction1 = function(x) {message("publicFunction1: x = ", x)},
    publicFunction2 = function(x)
    {
      checkmate::assertNumber(x, lower = 0, upper = 3)
      
      for (i in seq_len(x))
      {
        self$publicFunction1(i)
      }
    }
  )
)

Tester <- ClassToTest$new()
Tester$publicFunction1(0)
# publicFunction1: x = 0

Tester$publicFunction2(3)
# publicFunction1: x = 1
# publicFunction1: x = 2
# publicFunction1: x = 3

# Public methods can be modified on the fly using unlockBinding

Tester$publicFunction1 <- function(){"foo"}
# Error in Tester$publicFunction1 <- function() { : 
#     cannot change value of locked binding for 'publicFunction1'

# redefine a public data member using unlockBinding - note: use the instance of the class, not the class generator
unlockBinding(sym = "publicFunction1", env =  Tester)
# user mockery::mock to test that a function was called a specific number of times
mock1 <- mockery::mock("this is the mock", cycle = TRUE)
Tester$publicFunction1 <- mock1
lockBinding(sym = "publicFunction1", env =  Tester)
Tester$publicFunction2(3)

# test that publicFunction1 was called 3 times
mockery::expect_called(mock1, 3)

# test that publicFunction1 was called with arguments 1, 2 and 3
local_mock_args <- mockery::mock_args(mock1)
testthat::expect_equal(
  unlist(local_mock_args), 1:3
)

4. The recommended way to test R6 private methods

Let's modify the above example.

ClassToTest <- R6::R6Class(
  private = list(
    privateFunction = function(x) {message("privateFunction: x = ", x)}
  ),
  public = list(
    publicFunction = function(x)
    {
      checkmate::assertNumber(x, lower = 0, upper = 3)
      
      for (i in seq_len(x))
      {
        private$privateFunction(i)
      }
    }
  )
)

Private methods cannot be redefined using unlockBinding.  

Tester <- ClassToTest$new()
Tester$privateFunction <- function(){"foo"}
# Error in Tester$privateFunction <- function() { : 
#     cannot add bindings to a locked environment

unlockBinding(sym = "privateFunction", env = Tester)
# Error in unlockBinding(sym = "privateFunction", env = Tester) : 
#   no binding for "privateFunction"

But we can actually access the private environment by inspecting a public function, as explained in R6 issue 41.

priv <- environment(Tester$publicFunction)$private
priv$privateFunction(1)
# privateFunction: x = 1

unlockBinding(sym = "privateFunction", env = priv)
priv$privateFunction <- mockery::mock("mocked privateFunction", cycle = TRUE)
lockBinding(sym = "privateFunction", env = priv)

Tester$publicFunction(3)
mockery::expect_called(priv$privateFunction, n = 3)
mockery::expect_args(
  mock_object = priv$privateFunction,
  n = 3,
  3
)

I hope you find some of these useful.

Versions:

  • R version 4.0.5 (2021-03-31)
  • testthat: 3.0.2.9000
  • mockthat: 0.2.5
  • R6: 2.5.0
  • rlist: 0.4.6.1

Make a promise. Show up. Do the work. Repeat.