5  Unit Tests

Recording

Write unit tests for your custom functions using testthat.

Packages required for this chapter
library(usethis)
library(testthat)

5.1 Set up testhat

Use the following code to set up testing for your package. this makes a directory called tests that contains a file called testthat.R and a directory called testthat where you will keep your testing scripts. It will also add some lines to the DESCRIPTION file, so make sure that file doesnโ€™t have unsaved changes before you run this.

run in the console
usethis::use_testthat()

5.2 Set up test

Next, weโ€™ll follow the directions from the output text and call use_test() to initialise a basic test file and open it for editing.

run in the console
usethis::use_test("apa_t_pair")

A new file (tests/testthat/apa_t_pair.R) will open with the following text. It you run it, you should get a message saying it passed.

tests/testthat/apa_t_pair.R
test_that("multiplication works", {
  expect_equal(2 * 2, 4)
})
Test passed ๐Ÿ˜ธ

Change the default label for this test. You can label these whatever you want (it really doesnโ€™t matter); I usually start with โ€œdefaultsโ€ and test the most basic example to make sure the function defaults work as expected. Delete the demo code between the curly brackets.

tests/testthat/apa_t_pair.R
test_that("defaults", {

})

Set x and y as two small static vectors (donโ€™t use rnorm() or anything that makes random numbers). Then assign the value of apa_t_pair(x, y) to result and the expected result to expected. Finally, use the function expect_equal() to compare the obtained and expected result.

tests/testthat/apa_t_pair.R
test_that("defaults", {
  x <- c(1,2,3,4,5)
  y <- c(2,3,2,5,6)
  
  result <- apa_t_pair(x, y)
  expected <- "A paired-samples t-test was conducted to compare the DV between level 1 (M = 3.0, SD = 1.6) and level 2 (M = 3.6, SD = 1.8). There was a non-significant difference; t(4) = -1.50, p = 0.208."
  expect_equal(result, expected)
})
Test passed ๐ŸŽŠ

That particular example was significance. Letโ€™s add a version that does show a significant difference and make sure the text changes appropriately.

tests/testthat/apa_t_pair.R
test_that("defaults-sig", {
  x <- c(1,2,1,3,1)
  y <- c(5,3,2,5,6)
  
  result <- apa_t_pair(x, y)
  expected <- "A paired-samples t-test was conducted to compare the DV between level 1 (M = 1.6, SD = 0.9) and level 2 (M = 4.2, SD = 1.6). There was a significant difference; t(4) = -3.20, p = 0.033."
  expect_equal(result, expected)
})
Test passed ๐Ÿฅ‡

No letโ€™s change the default values for the DV and labels.

tests/testthat/apa_t_pair.R
test_that("non-defaults", {
  x <- c(1,2,1,3,1)
  y <- c(5,3,2,5,6)
  
  result <- apa_t_pair(x, y, dv = "the score", "Group A", "Group B")
  expected <- "A paired-samples t-test was conducted to compare the score between Group A (M = 1.6, SD = 0.9) and Group B (M = 4.2, SD = 1.6). There was a significant difference; t(4) = -3.20, p = 0.033."
  expect_equal(result, expected)
})
Test passed ๐Ÿ˜ธ

5.3 Test-driven development

You can use tests to help you develop your package. First, think of something you want to add or change. Then write a test that checks if your function is doing that new thing. It will, of course, fail, but then your task is to alter the function code until the test passes.

For example, it would be nice to give a custom error message if x and y are identical, since this is almost always a mistake and you canโ€™t calculate any test statistics.

x <- c(1,2,1,4,1)
apa_t_pair(x, x)
A paired-samples t-test was conducted to compare the DV between level 1 (M = 1.8, SD = 1.3) and level 2 (M = 1.8, SD = 1.3). There was a NAsignificant difference; t(4) = NaN, p = NaN.
Warning

You may get an error above instead of โ€œThere was a NAsignificant difference; t(4) = NaN, p = NaN.โ€, this means that youโ€™ve changed a default setting, but Iโ€™m having trouble figuring out what (one of my computers returns an error and two return the text above). Iโ€™ll update this when I figure it out. Regardless, we want to replace that opaque error with an intelligible error.

The first step is to write a test that fails. Wrap the code apa_t_pair(x, x) inside the function expect_error(), which will pass if the code produces the specified error message and fail if it doesnโ€™t.

tests/testthat/apa_t_pair.R
test_that("same x and y", {
  x <- c(1,2,1,4,1)
  
  expect_error( apa_t_pair(x, x), 
                regexp = "x and y cannot be identical",
                fixed = TRUE )
})
Error in `reporter$stop_if_needed()`:
! Test failed
โ”€โ”€ Failure ('<text>:5'): same x and y โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
`apa_t_pair(x, x)` did not throw an error.
Note

The function expect_error() can use regular expressions to test for patterns. For example, "is not allowed$" will match any string that ends with โ€œis not allowedโ€. Regex is powerful, but can be tricky to use. If you want to test if the returned error text is exactly the same as the regexp string, set fixed = TRUE.

Now we can edit the function apa_t_pair() until the test passes. Add a few lines of code at the start of the function to check if all of the values in x are the same as all of the values in y, and display an error message if they are.

R/apa_t_pair.R
apa_t_pair <- function(x, y, 
                       dv = "the DV", 
                       level1 = "level 1", 
                       level2 = "level 2") {
  # warn about identical values
  if (all(x == y)) {
    stop("x and y cannot be identical")
  }
  
  t_results <- t.test(x, y, paired = TRUE)
  
  template <- "A paired-samples t-test was conducted to compare {dv} between {level1} (M = {mean1}, SD = {sd1}) and {level2} (M = {mean2}, SD = {sd2}). There was a {non}significant difference; t({df}) = {t_value}, p = {p_value}."
  
  glue::glue(
    template,
    mean1   = round0(mean(x), 1), 
    sd1     = round0(sd(x), 1),
    mean2   = round0(mean(y), 1),
    sd2     = round0(sd(y), 1),
    non     = ifelse(t_results$p.value < .05, "", "non-"),
    df      = round0(t_results$parameter, 0),
    t_value = round0(t_results$statistic,2),
    p_value = round0(t_results$p.value, 3)
  )
}

Now the test should pass.

tests/testthat/apa_t_pair.R
test_that("same x and y", {
  x <- c(1,2,1,4,1)
  
  expect_error( apa_t_pair(x, x), 
                regexp = "x and y cannot be identical",
                fixed = TRUE )
})
Test passed ๐Ÿ˜ธ

Now that we have 4 tests for this function, we can run them all by clicking on Run Tests in the upper right corner of the source pane. If all goes well, youโ€™ll see the following in the Build tab:

==> Testing R file using 'testthat'

โ„น Loading demopkg

โ•โ• Testing test-apa_t_pair.R โ•โ•โ•โ•โ•
[ FAIL 0 | WARN 0 | SKIP 0 | PASS 4 ] Done!

Test complete

5.4 Testing data

You can also set up tests for a data object.

run in the console
usethis::use_test("self_res_att")

Letโ€™s check that the data() function can load this data set and that it has the expected dimensions. We can also check if the column sex is a factor.

tests/testthat/self_res_att.R
test_that("data available", {
  data("self_res_att")

  expect_true( exists("self_res_att") )
  expect_equal(ncol(self_res_att), 16)
  expect_equal(nrow(self_res_att), 108)
  expect_true(is.factor(self_res_att$sex))
})

5.5 Multiple tests

You can run all of the tests for all of the functions in your package by clicking on Test in the Build Tab or typing devtools::test() in the console. Here, youโ€™ll get one row for each test file (usually one test file per function) and a summary of how many tests Failed (F), gave unexpected warnings (W), were skipped (S), or passed (OK).

==> devtools::test()

โ„น Testing demopkg
โœ” | F W S  OK | Context
โœ” |         4 | apa_t_pair        
โœ” |         3 | self_res_att      

โ•โ• Results โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Duration: 0.1 s

[ FAIL 0 | WARN 0 | SKIP 0 | PASS 7 ]

Sometimes you want to set tests to skip because they take too long or cause problems when you submit a package to CRAN. Testthat has a number of functions that start with skip that allow you to skip any following tests under different circumstances.

You can also set up tests for a data object.

run in the console
usethis::use_test("self_res_att")

Letโ€™s check that the data() function can load this data set and that it has the expected dimensions.

tests/testthat/self_res_att.R
test_that("data available", {
  data("self_res_att")

  expect_true( exists("self_res_att") )
  expect_equal(ncol(self_res_att), 16)
  expect_equal(nrow(self_res_att), 108)
})

5.6 Glossary

term definition
default-values
non-significant
panes RStudio is arranged with four window "panes".
vector A type of data structure that collects values with the same data type, like T/F values, numbers, or strings.

5.7 Further Resources

5.8 Further Practice

  1. Set up tests for round0. Make sure that this function gives the expected result for different values of digits and rounds values appropriately. Remember that the results youโ€™re trying to match is a character data type, not a numeric value.

  2. Use test-driven development to add a custom error message to apa_t_pair() if the length of x and y are not the same.

  3. Add further sense checks that the column types are as expected for self_res_att.

  4. Add unit tests for any other functions youโ€™ve created.