πŸ’» 🏦
Code You Can Bank On

Testdat

Amelia McNamara

{testdat}

Data unit testing

There are several packages that do data testing, and even a paper about them:

R Packages for Data Quality Assessments and Data Monitoring: A Software Scoping Review with Recommendations for Future Developments

The authors put their list on GitHub if you want to review it.

But, the one I am most familiar with is {testdat}.

{testdat}

Another extension to testthat is testdat, a way to implement data unit testing.

To use it, you can

install.packages("testdat")

The standard form of a data expectation is expect_*(var(s), ..., flt = TRUE, data = get_testdata())

Variables

Most operations act on one or more variables. There are two variants of the variable argument:

test_that("multi-variable identifier is unique", {
  expect_unique(c(name, year, month, day, hour), data = storms)
})
#> ── Failure: multi-variable identifier is unique ────────────────────────────────
#> `storms` has 48 duplicate records on variable `name, year, month, day, hour`.
#> Filter: None
#> Error:
#> ! Test failed

Variables

Most operations act on one or more variables. There are two variants of the variable argument:

  • var requires an unquoted variable name. This only applies to a small number of expectations.
test_that("hour values are valid", {
  expect_base(ts_diameter, year >= 2004)
})
#> ── Error: hour values are valid ────────────────────────────────────────────────
#> Error: A test data frame has not been specified. Use `set_testdata()` to set the data frame.
#> Backtrace:
#>     β–†
#>  1. └─testdat::expect_base(ts_diameter, year >= 2004)
#>  2.   β”œβ”€testthat::quasi_label(enquo(data))
#>  3.   β”‚ └─rlang::eval_bare(expr, quo_get_env(quo))
#>  4.   └─testdat::get_testdata()
#> Error:
#> ! Test failed

Filter

You can filter your test data using the flt argument.

test_that("iris range checks", {
  expect_range(Petal.Width, 0, 1, data = iris)
})
#> ── Failure: iris range checks ──────────────────────────────────────────────────
#> `iris` has 93 records failing range check on variable `Petal.Width`.
#> Variable set: `Petal.Width`
#> Filter: None
#> Arguments: `min = 0, max = 1`
#> Error:
#> ! Test failed

test_that("iris range checks filtered", {
  # Test passes for setosa rows
  expect_range(Petal.Width, 0, 1, flt = Species == "setosa", data = iris)
  # Failures will provide the filter
  expect_range(Petal.Width, 0, 0.5, flt = Species == "setosa", data = iris)
})
#> ── Failure: iris range checks filtered ─────────────────────────────────────────
#> `iris` has 1 records failing range check on variable `Petal.Width`.
#> Variable set: `Petal.Width`
#> Filter: `Species == "setosa"`
#> Arguments: `min = 0, max = 0.5`
#> Error:
#> ! Test failed

Testing in a script

You can use testdat in a script, interactively.

library(testdat)
library(dplyr)

x <- tribble(
  ~id, ~pcode, ~state, ~nsw_only,
  1,   2000,   "NSW",  1,
  2,   3123,   "VIC",  NA,
  3,   2123,   "NSW",  3,
  4,   12345,  "VIC",  3
)

with_testdata(x, {
  test_that("id is unique", {
    expect_unique(id)
  })
  
  test_that("variable values are correct", {
    expect_values(pcode, 2000:2999, 3000:3999)
    expect_values(state, c("NSW", "VIC"))
    expect_values(nsw_only, 1:3) # by default expect_values allows NAs
  })
  
  test_that("filters applied correctly", {
    expect_base(nsw_only, state == "NSW")
  })
})

x <- x %>% mutate(market = case_when(pcode %in% 2000:2999 ~ 1,
                                     pcode %in% 3000:3999 ~ 2))

with_testdata(x, {
  test_that("market derived correctly", {
    expect_values(market, 1:2, miss = NULL) # miss = NULL excludes NAs from valid values
  })
})

Try it out

Let’s try testdat out interactively with our data. Some expectations you might find useful:

  • expect_values()
  • expect_range()
  • expect_date_yyymmdd()

Try it out

library(testthat)
library(testdat)
with_testdata(bacon, {
  test_that("series ID is correct", {
    expect_values(series_id, "APU0000704111")
  })
  test_that("dates are correct format", {
    expect_date_yyymmdd(date)
  })
})
── Failure: dates are correct format ───────────────────────────────────────────────────────────────────
`get_testdata()` has 119 records failing YYYYMMDD date format check on variable `date`.

expect_date_yyymmdd() doesn’t do quite what we’d expect

On GitHub

It’s checking for a string of numbers, without any separation. Our dates have dashes in them.

Create an expectation from a check function

Luckily, we can make our own expectations! We write a function, and then use expect_make() to turn it into an expectation.

Can you modify

chk_date_yyyymm <- function(x) {
  check_lubridate_installed()
  chk_blank(x) | (str_detect(x, "[0-9]{6}") & !is.na(lubridate::ymd(paste0(x, "01"), quiet = TRUE)))
}

to test for our situation?

Create an expectation from a check function

Create an expectation from a check function

chk_date_BLT <- function(x) {
  stringr::str_detect(x, "^[1-2][0-9]{3}-[0-9]{2}-[0-9]{2}$") & !is.na(lubridate::ymd(x, quiet = TRUE))
}

expect_date_formatted <- expect_make(chk_date_BLT)

Try it out

library(testthat)
library(testdat)
with_testdata(bacon, {
  test_that("series ID is correct", {
    expect_values(series_id, "APU0000704111")
  })
  test_that("dates are correct format", {
    expect_date_formatted(date)
  })
})
Test passed with 1 success πŸ₯³.
Test passed with 1 success 🌈.

Using a test suite

Now that we have a couple of tests that work interactively, let’s add them to our test suite. We will need to use a few special files, specifically helper.R and maybe setup.R

There is a usethis helper!

usethis::use_test_helper()

The stuff outside of the test_that() calls should go somewhere other than a standard test-xxx.R file. The tests themselves can live in test-data.R.

test-data.R

test_that("series ID is correct", {
  expect_values(series_id, "APU0000704111")
})

# test_that("dates are correct format", {
#   expect_date_formatted(date)
# })

Edit: custom expectations aren’t working in automated testing.

helper.R

# library(rlang)
library(testdat)

# chk_date_BLT <- function(x) {
#   stringr::str_detect(x, "^[1-2][0-9]{3}-[0-9]{2}-[0-9]{2}$") & !is.na(lubridate::ymd(x, quiet = TRUE))
# }
# 
# expect_date_formatted <- expect_make(chk_date_BLT)

set_testdata(bacon) #? unsure

Edit: custom expectations aren’t working in automated testing

(The library stuff is kind of bad practice, but it took me ages of chasing down an error to figure out. I might put an issue on testdat.)

setup.R

set_testdata(bacon) #? unsure

test()

Document, load, check, commit

Now would be a good time to commit your changes

Git icon

Jason Long, CC BY 3.0 <https://creativecommons.org/licenses/by/3.0>, via Wikimedia Commons

Dependencies

Oops, did we forget to

use_package("testdat")

References