Robert's Data Science Blog

Mocking testthat

When making a function that gets data from a REST API I use the following pattern. If X is a descriptive name for the data source I make a function get_X, which is intended for users to get the data. The get_X is split into two parts: One for getting the HTTP response (download_X) and one for parsing this response into an R object (parse_X):

get_X <- function(...) {
    response <- download_X(...)
    parse_X(response)
}

The reason for splitting get_X is to separate the responsibilities as download_X and parse_X are very different – download_X relies on external resources and parse_X is local data wrangling. The two function should therefore also be tested independently.

The httr vignette Best practices for API packages is a good guide on how to make a download_X.

Testing download

Relevant tests for download_X could be that the the response succeeded and the content type is as expected:

test_that("Download X", {
    response <- download_X(...)

    expect_equal(httr::status_code(response), 200)

    expect_equal(httr::content_type(response), "application/json")
})

In some cases it could also be relevant to test that the URL of the response is as expected.

When testing parse_X I use a reference response. I try to find a suitable response, that is, one that is representative and small. This response is saved in the subfolder testdata of the inst folder and I include a small function in the package to retrieve it:

testdata_file <- function(filename) {
    system.file(file.path("testdata", filename), package = "mypackage", mustWork = TRUE)
}

It may also be relevant to check that the content of response has not changed:

reference_response <- readRDS(testdata_file("X_response.rds"))

expect_equal(
    httr::content(response, "text")
    httr::content(reference_response, "text")
)

Testing parse

The function parse_X use the saved response as a starting point.

test_that("Parse X", {
    response <- readRDS(testdata_file("X_response.rds"))

    result <- parse_X(result)
})

The nature of result depends on the situation and the tests of course depends on what parse_X returns.

If result is a tibble (which I highly prefer over a plain data frame) I test that it is indeed a tibble, that it has the right column names, that the columns have the right types and that it contains data.

expect_s3_class(result, "tbl")

expect_gt(nrow(result), 0)

expect_named(result, c("col1", "col2"))

column_types <- vapply(result, function(x) class(x)[1], character(1))
expect_equal(
    column_types,
    c("col1" = <col1's type>,
      "col2" = <col2's type>)
)

The vector column_types contains the first element in the class vector for each column. My main usecase is that I only want to check that date-time columns are POSIXct

Testing get

Finally it is get_X's turn. With a small response parse_X should execute fast, but download_X is most likely much slower and more unpredictable. Therefore, I want to mock download_X when testing get_X such that it just returns the saved response.

The testthat package offers a mocking functionality:

test_that("Get X", {
    local_mock(
        download_X = readRDS(testdata_file("X_response.rds"))
    )

    result <- get_X(...)
})

The local_mock (or with_mock) from testthat command work as intended with devtools::test(), but not with the covr package:

> covr::package_coverage()
Error: Failure in `path/to/testthat.Rout.fail`
{
           stop("Can't mock functions in base packages (", pkg_name, ")", call. = FALSE)
       }
       name <- gsub(pkg_and_name_rx, "\\2", qual_name)
       if (pkg_name == "") {
           pkg_name <- .env
       }
       env <- asNamespace(pkg_name)
       if (!exists(name, envir = env, mode = "function")) {
           stop("Function ", name, " not found in environment ", environmentName(env), ".", call. = FALSE)
       }
       mock(name = name, env = env, new = funs[[qual_name]])
   })
4: FUN(X[[i]], ...)
5: stop("Function ", name, " not found in environment ", environmentName(env), ".", call. = FALSE)

Error: testthat unit tests failed

Fortunately, the mockery package works with both testthat and covr. It also allows more fine-grained mocking.

test_that("Get X", {
    mockery::stub(
        get_X,
        "download_X",
        readRDS(testdata_file("X_response.rds"))
    )

    result <- get_X(...)
})

Executing tests

Say the “Download X” test is located in the file tests/testthat/test-API.R. To run only the tests in test-API.R use the test command with a filter:

devtools::test(filter = "API")

The filter argument relies on grepl to choose test files, so to run all tests, except those in test-API.R we can supply an extra argument:

devtools::test(filter = "API", invert = TRUE)