Skip to Content
Course content

185: Mutation Testing Concepts for R Functions

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

A few years ago, I was reviewing a critical piece of logic for a clinical trial analysis tool. The developer told me they had "100% code coverage"—every single line of the R script had been executed by the test suite. I trusted them, and we pushed it to production. Two weeks later, we found a bug: a boundary condition where a value exactly equal to the threshold was being handled incorrectly. The code had a > where it should have been a >=. Because the test data only used values like 9.9 and 10.1, but never 10.0, the tests stayed green even though the logic was wrong. That's the moment I realized that code coverage is often a vanity metric. It tells you what code was executed, but it doesn't tell you if your tests actually verify the behavior.

The Gap Between Coverage and Correctness

This is where mutation testing comes in. If standard unit testing is about checking if your code works, mutation testing is about checking if your tests work. I like to think of it as "testing your tests." Instead of looking at which lines were hit, mutation testing intentionally breaks your code in small, predictable ways to see if your test suite notices.

In R, a "mutant" is a version of your function where a single operator or value has been changed. For example, if you have if (x > 0), a mutation engine might change it to if (x >= 0) or if (x < 0). If your tests still pass after this change, the mutant has "survived." A surviving mutant is a red flag; it means you have a hole in your testing strategy that could allow a real bug to slip through unnoticed.

How Mutators Attack R Functions

When you use a mutation package (like mutant), it doesn't just randomly scramble your code. It applies specific "mutation operators" that mimic common human errors. I've found that the most dangerous ones are the ones that target boundary conditions and logical inversions.

# Original Function
calculate_bonus <- function(sales) {
  if (sales > 10000) {
    return(sales * 0.10)
  } else {
    return(0)
  }
}

# Possible Mutants:
# 1. Logical mutation: change > to <
# 2. Boundary mutation: change > to >=
# 3. Constant mutation: change 10000 to 10001
# 4. Return mutation: change return(0) to return(NA)

If your test suite only checks calculate_bonus(15000) and calculate_bonus(5000), it will catch the "Logical mutation" (because 15000 is not < 10000), but it will completely miss the "Boundary mutation." The mutant sales >= 10000 would still return the correct values for your specific test cases, and the mutant survives.

Killing the Mutants

Your goal in mutation testing isn't to reach 100% mutation coverage—that's often an impossible chase that leads to over-testing—but to identify "blind spots." When a mutant survives, you don't change the source code; you add a new test case that specifically targets that mutation.

In the bonus example above, the fix is to add a test case for the exact threshold: calculate_bonus(10000). Now, the original code returns 0, but the mutant (>=) would return 1000. Your test fails, the mutant is "killed," and your confidence in the code actually increases. It's a bit of a cat-and-mouse game, but it's the only way to move beyond the false security of line coverage.




📋 Practical Task

Hardening a Statistical Threshold Filter with Mutation Testing

You have been given a function that filters out "outliers" from a dataset based on a standard deviation threshold. The current test suite has 100% line coverage, but it's missing critical boundary checks.

# The function to be tested
filter_outliers <- function(x, threshold = 3) {
  mu <- mean(x, na.rm = TRUE)
  sigma <- sd(x, na.rm = TRUE)
  # Keep values within the threshold
  return(x[abs(x - mu) < (threshold * sigma)])
}

# The existing test suite
library(testthat)
test_that("filter_outliers removes extreme values", {
  data <- c(10, 12, 11, 10, 100) # 100 is a clear outlier
  result <- filter_outliers(data)
  expect_false(100 %in% result)
  expect_true(10 %in% result)
})

Your Task:

  1. Analyze the filter_outliers function. Identify at least two potential "mutations" (changes to operators or constants) that would not be caught by the existing test suite.
  2. Write a new test_that block containing specific input data and expectations that would "kill" those mutants. Specifically, focus on the boundary where a value is exactly at the threshold.
  3. Explain why your new test case is more robust than the original one.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.