Setting Up Module Tests

{box} works seamlessly with any frameworks, even {testthat}, R’s most popular unit testing framework. While you can use other testing frameworks, this guide focuses on the use of {testthat} because of its widespread adoption and excellent integration with popular IDEs like RStudio.

Getting Started

Let us revisit from what we started on ./module from Chapter 3, then for this example, let us test the robustness of ./module/matrix_ops.r, where it overloads * and ^ from base R for matrix operations. For our example, update ./module by creating another subfolder named __tests__/ that contains the unit test scripts, next to your module file you want to test. You can choose ANY name, the double underscores here is used to make it clear this is special infrastructure code, not just a regular module.

Here’s the visualization of the structure:

module/
├── __init__.r
├── convert.r
├── hello_world.r
├── matrix_ops.r               # ◄─── Test this
├── __tests__/                 # ◄─── Add this
│   ├── __init__.r
│   ├── helper-module.r
│   └── test-matrix_ops.r
├── not_func.r
├── tables.r
└── statistics/
    ├── __init__.r
    ├── cor.r
    ├── corrr.r
    ├── time_series.r
    └── models/
        ├── __init__.r
        ├── linear.r
        ├── logistic.r
        └── baseline_logit.r

The ./module/__tests__/__init__.r file serves as the test runner. It’s minimal but essential:

box::use(testthat[...])

.on_load = function (ns) {
    test_dir(box::file(), reporter = 'progress')
}

box::export()

The .on_load hook here will make all unit tests automatically run when the __tests__/ module loads. Using box::file() gives us the current directory path.

Note

In R, any names that start with _ are considered non-syntactic names. To declare an import that attaches this module, you must place backticks side-by-side. Quotation marks are not allowed as discussed on the use of NSE back in Chapter 2.

The ./module/__tests__/helper-module.r file loads/attaches the namespace of the module you want to test into the test environment (this file). In our case, we choose ./module/matrix_ops.r, and since the module is located at 1 level upper from __tests__/, locate the module 1 ../ upper, then load/attach its namespace:

helper-module.r
box::use(../matrix_ops[...])

The [...] syntax attaches all exported functions, and we are applying this to make the exports of the module namespace available in tests without prefixes.

Note

A file only gets treated as a helper by {testthat} if its name starts with helper and ends in .r or .R, e.g. helper-module.r. Anything else, like any names such as imports.r, is just a regular file that box::use() or source() has to load explicitly. See the Special files article in the official {testthat} documentation.

Making Modules Testable

Suppose you want to verify that ./module/matrix_ops.r works correctly. This script contains R source code that overloads the * operator from base R to enable intelligent matrix multiplication, and ^ to take the exponentiation of the matrices, except when ^-1, which takes the (square) matrix its inverse.

Take a look of the source code:

./modules/matrix_ops.r
#' Matrix Multiplication
#' 
#' @export
`*` = function (e1, e2) UseMethod("*")

`*.default` = function (e1, e2) base::`*`(e1, e2)

`*.matrix` = function (e1, e2) {
    if (is.matrix(e1) && is.matrix(e2)) {
        # Check dimensions for matrix multiplication
        if (ncol(e1) == nrow(e2)) {
            return(base::`%*%`(e1, e2))
        } else if (ncol(e2) == nrow(e1)) {
            # Try the other way around if dimensions match
            return(base::`%*%`(e2, e1))
        } else {
            # If neither multiplication works, do element-wise
            return(base::`*`(e1, e2))
        }
    } else {
        return(base::`*`(e1, e2))
    }
}

# ... Truncated ...

box::register_S3_method("^", "default")
box::register_S3_method("^", "matrix")
box::register_S3_method("^", "array")
box::register_S3_method("^", "data.frame")

To make this module testable if executed, append the following code at the bottom of ./module/matrix_ops.r:

if (is.null(box::name())) {
    box::use(./`__tests__`)
}

and it becomes:

./modules/matrix_ops.r
#' Matrix Multiplication
#' 
#' @export
`*` = function (e1, e2) UseMethod("*")

`*.default` = function (e1, e2) base::`*`(e1, e2)

`*.matrix` = function (e1, e2) {
    if (is.matrix(e1) && is.matrix(e2)) {
        # Check dimensions for matrix multiplication
        if (ncol(e1) == nrow(e2)) {
            return(base::`%*%`(e1, e2))
        } else if (ncol(e2) == nrow(e1)) {
            # Try the other way around if dimensions match
            return(base::`%*%`(e2, e1))
        } else {
            # If neither multiplication works, do element-wise
            return(base::`*`(e1, e2))
        }
    } else {
        return(base::`*`(e1, e2))
    }
}

# ... The rest are truncated

box::register_S3_method("^", "default")
box::register_S3_method("^", "matrix")
box::register_S3_method("^", "array")
box::register_S3_method("^", "data.frame")

if (is.null(box::name())) {
    box::use(./`__tests__`)
}

This conditional check distinguishes between two scenarios: when you execute Rscript module/matrix_ops.r directly from the command line, box::name() returns NULL and the test suite runs. However, when another module imports it using box::use(module/matrix_ops), the condition evaluates to false and tests are skipped.

Note

The backticks around __tests__ is another case of non-syntactic names (discussed at Advanced R and this book under Chapter 2) — R doesn’t normally allow identifiers starting with underscores, so backticks tell R to treat it as a literal name.

Or if you just want to test only that script containing the test, use test_file() instead, then specify the path of the file containing the test, i.e. ./module/__tests__/test-matrix-ops.r. Do the following:

if (is.null(box::name())) {
    testthat::test_file(path = box::file("__tests__", "test-matrix_ops.r"))
}

Considering that you have two or more scripts that contain the tests from matrix_ops.r module especially, use either a map-family function (^walk() in {purrr} package) or just a for-loop to execute the unit tests at once.

Now you know how you can mark the module into testable by executing it. After knowing that, update the ./module/__tests__/test-matrix_ops.r file that contains the actual tests:

Listing 13.1: ./module/tests/test-matrix_ops.r
Code
# ---Matrix Multiplication---

test_that('matrix multiplication works correctly', {
    m1 = matrix(c(1, 2, 3, 4), nrow = 2)
    m2 = matrix(c(5, 6, 7, 8), nrow = 2)

    result = m1 * m2
    expected = m1 %*% m2

    expect_equal(result, expected)
})

test_that('matrix multiplication with dimension mismatch tries reverse order', {
    m1 = matrix(c(1, 2, 3, 4, 5, 6), nrow = 2, ncol = 3)
    m2 = matrix(c(7, 8), nrow = 2, ncol = 1)

    expect_error(m1 * m2)
    expect_error(m2 %*% m1)
})

test_that('matrix multiplication works with scalar', {
    m1 = matrix(c(1, 2, 3, 4), nrow = 2)
    scalar = 2

    result = m1 * scalar
    expected = base::`*`(m1, scalar)

    expect_equal(result, expected)
})

# ---Exponentiation---
test_that('matrix inverse (^-1) works correctly', {
    m = rbind(c(2, 7), c(5, -4))
    
    result = m ^ -1
    expected = solve(m)
    
    expect_equal(result, expected)
})

# ---Solving system of linear equation---
# ---An example from https://www.youtube.com/watch?v=cblHUeq3bkE---
test_that('combined operations work (inverse then multiply)', {
    A = rbind(c(2, 7), c(5, -4))
    b = cbind(c(34, -1))
    
    result = A ^ -1 * b
    expected = matrix(c(3, 4), ncol = 1)
    
    expect_equal(result, expected)
})

Tests Execution

You have several options for running tests:

  1. If you are using the terminal:

    Rscript module/matrix_ops.r

    and when all five executable tests passed the unit test, you’ll get . being tallied:

    $ Rscript module/matrix_ops.r
     | F W  S  OK | Context
     |          0 | matrix_ops
     |          6 | matrix_ops
    
    ══ Results ════════════════════════════════════════════════════════════════
    [ FAIL 0 | WARN 0 | SKIP 0 | PASS 6 ]
    
    You rock!

    This depends on what reporter argument you are placing. In this case, "progress" is chosen, because in my opinion, this is easier to read.

    See Chapter 4.2 to talk about more about diagnostics.

    Important

    Make sure you are in the current directory where {./module} is found.

  2. In RStudio, locate ./module/__tests__/test-matrix_ops.r. Click the file, and click “Run Tests”.

    Important

    Do not run RStudio’s “Source” button (if there’s any)! It reuses the current R session, and box caches loaded modules. You might end up testing old code without realizing it. Always run tests in a fresh session to ensure you’re testing the current version of your code.

Testing Nested Modules

How about testing nested modules? In this example, we want to measure the robustness the functions for linear and logistic regression models under ./module/statistics/models/ module. Then, choose either updating ./module/__tests__/ then prepare the scripts containing the unit tests, or create another __tests__/ under ./module/statistics/models/ module folder. If you choose the former, the ./module/__tests__/helper-module.r should contain the following import declaration:

# helper-module.r
box::use(
    ../statistics/models/linear[linear_reg], 
    ../statistics/models/logistic[logistic_reg], 
)

Then copy the following unit tests, both test-linear-reg.r and test-logistic-reg.r, below. If you want to make these unit test script files testable assuming these scripts live under ./module/__tests__/, then the following code that makes these scripts testable becomes:

if (is.null(box::name())) {
    box::use(../../`__tests__`)
}

Then execute either module/statistics/models/linear.r and/or module/statistics/models/logistic.r scripts on the terminal, assuming the Shell working directory is the root directory where module/ live, through the following command:

Rscript module/statistics/models/linear.r

Else, do the following steps below. Let us test both module/statistics/models/linear.r and module/statistics/models/logistic.r.

First, here’s the structure:

module/statistics/models/
├── __init__.r
├── linear.r                 # ◄─── Test this
├── logistic.r               # ◄─── Test this
├── __tests__/               # ◄─── Add
│   ├── __init__.r
│   ├── helper-module.r
│   ├── test-linear-reg.r
│   └── test-logistic-reg.r
└── baseline_logit.r

The helper-module.r file from ./module/statistics/models/__tests__/ adjusts the relative path:

box::use(
    ../linear[linear_reg], 
    ../logistic[logistic_reg]
)

And /__tests__/test-linear-reg.r contains tests linear_reg() function:

Code
test_that('Linear Regression from formula S3 method works', {
    model = linear_reg(mpg ~ wt + hp, mtcars, vif = TRUE)
    
    expect_s3_class(model, "linear_reg")
    expect_true("out" %in% names(model))
    expect_true("fitted" %in% names(model))
    expect_true(all(c("terms", "coefficients", "std_err", "t_statistic", "pval", "vif", "tolerance") %in% names(model$out)))
    expect_equal(nrow(model$out), 3)
    expect_equal(model$out$terms, c("beta0", "wt", "hp"))
})

test_that('Linear Regression from data frame S3 method works', {
    model = 
        mtcars |> 
        linear_reg(mpg ~ wt + hp, vif = TRUE)
    
    expect_s3_class(model, "linear_reg")
    expect_equal(nrow(model$out), 3)
})

test_that('Linear Regression calculates correct coefficients', {
    # Compare with base R lm()
    model = linear_reg(mpg ~ wt + hp, mtcars)
    base_model = lm(mpg ~ wt + hp, mtcars)
    
    expect_equal(model$out$coefficients, as.vector(coef(base_model)), tolerance = 1e-6)
})

test_that('Linear Regression with column selection works', {
    model =
        mtcars |> 
        linear_reg(c(wt, hp), mpg)
    
    expect_s3_class(model, "linear_reg")
    expect_equal(nrow(model$out), 3)
    
    # Without vif flag, should not have vif columns
    expect_false("vif" %in% names(model$out))
})

As well as /__tests__/test-logistic-reg.r to tests logistic_reg() function:

Code
test_that('Logistic Regression from formula S3 method works', {
    model = logistic_reg(am ~ wt + hp, mtcars, vif = TRUE)
    
    expect_s3_class(model, "logistic_reg")
    expect_true("out" %in% names(model))
    expect_true("fitted" %in% names(model))
    expect_true("levels" %in% names(model))
    expect_true("reference" %in% names(model))
    expect_true(all(c("terms", "coefficients", "odds_ratio", "std_err", "z_statistic", "pval", "vif", "tolerance") %in% names(model$out)))
    expect_equal(nrow(model$out), 3)
    expect_equal(model$out$terms, c("beta0", "wt", "hp"))
})

test_that('Logistic Regression from data frame S3 method works', {
    model = 
        mtcars |> 
        logistic_reg(am ~ wt + hp, vif = TRUE)
    
    expect_s3_class(model, "logistic_reg")
    expect_equal(nrow(model$out), 3)
})

test_that('Logistic Regression calculates correct coefficients', {
    # Compare with base R glm()
    model = logistic_reg(am ~ wt + hp, mtcars)
    base_model = glm(am ~ wt + hp, data = mtcars, family = binomial(link = "logit"))
    
    expect_equal(model$out$coefficients, as.vector(coef(base_model)), tolerance = 1e-4)
})


test_that('Logistic Regression with column selection works', {
    model = 
        mtcars |> 
        logistic_reg(c(wt, hp), am)
    
    expect_s3_class(model, "logistic_reg")
    expect_equal(nrow(model$out), 3)
    
    # Without vif flag, should not have vif columns
    expect_false("vif" %in% names(model$out))
})