Skip to Content
Course content

70: Unit Testing with testthat

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent way too many hours of my career staring at a console, manually typing a function call, looking at the result, and saying, "Yeah, that looks right." The problem is that "looks right" is a dangerous metric. The moment I change one line of code to fix a bug in one area, I usually break three other things I forgot existed. That's why we're using testthat.

Let's look at a function I'm working on. I want to calculate a weighted average, but I want it to be robust enough to handle NA values by ignoring them—provided the corresponding weight is also handled.

calc_weighted_avg <- function(x, w) {
  sum(x * w) / sum(w)
}

The illusion of correctness

At first glance, this is fine. I'll run a quick manual check:

calc_weighted_avg(c(10, 20), c(0.2, 0.8))
# [1] 18

Perfect. 18 is exactly what I expected. But here is where the "manual check" workflow fails. I'm only testing the "happy path." What happens if my data is messy? Let's try passing in some NA values.

calc_weighted_avg(c(10, NA), c(0.5, 0.5))
# [1] NA

That's not what I want. I want the function to ignore the NA and calculate the average based on the remaining valid data. Now, I could just add an na.rm = TRUE argument to the sums, but I don't want to just "guess and check" my way to a solution. I want a safety net.

Building the safety net

Instead of running lines of code in the console, I'm going to move this into a test_that block. This is the heart of the testthat package: we describe what we expect to happen, and the package tells us if we're lying to ourselves.

library(testthat)

test_that("calc_weighted_avg handles basic input", {
  expect_equal(calc_weighted_avg(c(10, 20), c(0.2, 0.8)), 18)
})

test_that("calc_weighted_avg ignores NAs", {
  expect_equal(calc_weighted_avg(c(10, NA), c(0.5, 0.5)), 10)
})

When I run this, the first test passes, but the second one fails spectacularly. It tells me Expected 10, but ACTUAL NA. Now I have a concrete failure to fix, and more importantly, a way to verify the fix without re-typing my test data every single time.

Adjusting the logic

I'll rewrite the function to handle those NAs. I'll use complete.cases to ensure we only use indices where both the value and the weight exist.

calc_weighted_avg <- function(x, w) {
  valid <- complete.cases(x) & complete.cases(w)
  sum(x[valid] * w[valid]) / sum(w[valid])
}

Running the tests again... both pass. But as a software engineer, I've learned that if you can think of a way to break it, the user will find it. What if the weights sum to zero? Or what if the input vectors are different lengths?

test_that("calc_weighted_avg handles edge cases", {
  # Different lengths should probably trigger an error
  expect_error(calc_weighted_avg(c(1, 2), c(1)))
  
  # Zero weights should probably return NaN or an error, not crash
  expect_true(is.nan(calc_weighted_avg(c(10, 20), c(0, 0))))
})

The "different lengths" test fails because R's recycling rules actually let c(1, 2) * c(1) happen without an error. That's a bug in my logic. I need to explicitly check that the lengths match.

Closing the loop

I'll add a check at the start of the function. This is the "Red-Green-Refactor" cycle in action: I wrote a test that failed (Red), I changed the code to make it pass (Green), and now I can clean up the implementation (Refactor).

calc_weighted_avg <- function(x, w) {
  if (length(x) != length(w)) {
    stop("x and w must be the same length")
  }
  
  valid <- complete.cases(x) & complete.cases(w)
  sum(x[valid] * w[valid]) / sum(w[valid])
}

Now, when I run my test suite, everything is green. I can now change the internals of this function—maybe optimize it for huge datasets using matrixStats—and I can do so with total confidence. If I break the core logic, testthat will scream at me immediately.




📋 Practical Task

Fixing the `calculate_z_score` Utility

You have been handed a function designed to calculate the Z-score of a vector. However, it is currently fragile. Your task is to write a testthat suite to identify the bugs and then fix the function.

calculate_z_score <- function(x) {
  (x - mean(x)) / sd(x)
}

Requirements:

  • Create a test block that ensures a simple vector (e.g., c(1, 2, 3)) returns the expected Z-scores.
  • Create a test that checks how the function handles a vector of length 1. (Hint: What is the standard deviation of a single number?).
  • Create a test that checks how the function handles a vector where all numbers are identical (e.g., c(5, 5, 5)).
  • Modify the calculate_z_score function so that it returns a vector of NAs instead of NaNs when the standard deviation is zero, and returns a helpful error message if the input is not numeric.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.