Open-source tools, playbooks,
and tutorials for teams that ship
R and Shiny they can trust.
Automated testing is how you build software you can trust.
Software engineer specializing in R and Shiny, currently at Appsilon. I created Cucumber and muttest for R, and I've taught R testing at ShinyConf 2024 and useR! 2025.
I started caring about this after working on a project where every code change required a live connection to production. Terrible experience, fixable problem.
Tests should make development faster and more confident, not slower and more ceremonial. I write about strategies that fit the way developers actually work.
Acceptance Testing
The problem
Business rules may sit in tickets, separate documents, or code comments. To get the full picture you need to read them all, and they may not even agree.
The problem
Tests are written later, by someone else, against a different mental model. Prose and tests drift apart, there is nothing binding them together.
The requirement
"Show the trend for the selected category."
The test
The fix
That is acceptance testing. The rule is written once, in structured language, and executed directly. Cucumber runs it for R.
Anatomy
Name the capability, then one concrete example, in language the whole team reads.
Anatomy
This is the starting point for the scenario. State all the preconditions that will allow the scenario to run.
Anatomy
The action from Users perspective. The thing that triggers the behavior you want to specify and test.
Anatomy
The outcome you expect, observable by the User of the software.
Mutation testing
The problem
A full suite, every test passing, 100% coverage. None of it proves your tests would notice a real bug.
# R/is_adult.R
is_adult <- function(age) {
age >= 18
}# R/is_adult.R
is_adult <- function(age) {
age >= 18
age > 18
}# tests/testthat/test-is_adult.R
test_that("is_adult identifies adults", {
expect_true(is_adult(25))
expect_false(is_adult(10))
})# tests/testthat/test-is_adult.R
test_that("is_adult identifies adults", {
expect_true(is_adult(25))
expect_false(is_adult(10))
expect_true(is_adult(18))
})The idea
Mutation testing changes your source, then reruns the tests with mutated code (mutant). You decide what changes and where. If tests still pass, they never tested your code, they just executed it.
The mutant
Change >= to >. A one-character edit, exactly the kind of bug that slips through review.
# R/is_adult.R
is_adult <- function(age) {
age >= 18
}# R/is_adult.R
is_adult <- function(age) {
age >= 18
age > 18
}# tests/testthat/test-is_adult.R
test_that("is_adult identifies adults", {
expect_true(is_adult(25))
expect_false(is_adult(10))
})# tests/testthat/test-is_adult.R
test_that("is_adult identifies adults", {
expect_true(is_adult(25))
expect_false(is_adult(10))
expect_true(is_adult(18))
})The problem, proven
Both tests still pass. 25 is an adult, 10 is not, either way. The boundary, 18, is never checked. The mutant survives.
# R/is_adult.R
is_adult <- function(age) {
age >= 18
}# R/is_adult.R
is_adult <- function(age) {
age >= 18
age > 18
}# tests/testthat/test-is_adult.R
test_that("is_adult identifies adults", {
expect_true(is_adult(25))
expect_false(is_adult(10))
})# tests/testthat/test-is_adult.R
test_that("is_adult identifies adults", {
expect_true(is_adult(25))
expect_false(is_adult(10))
expect_true(is_adult(18))
})The fix
Add one assertion at exactly 18, the case that can tell >= from >.
# R/is_adult.R
is_adult <- function(age) {
age >= 18
}# R/is_adult.R
is_adult <- function(age) {
age >= 18
age > 18
}# tests/testthat/test-is_adult.R
test_that("is_adult identifies adults", {
expect_true(is_adult(25))
expect_false(is_adult(10))
})# tests/testthat/test-is_adult.R
test_that("is_adult identifies adults", {
expect_true(is_adult(25))
expect_false(is_adult(10))
expect_true(is_adult(18))
})The payoff
Now the mutated code fails. Every mutation triggers a failure, and the gap coverage hid is closed. You can finally trust your tests.
# R/is_adult.R
is_adult <- function(age) {
age >= 18
}# R/is_adult.R
is_adult <- function(age) {
age >= 18
age > 18
}# tests/testthat/test-is_adult.R
test_that("is_adult identifies adults", {
expect_true(is_adult(25))
expect_false(is_adult(10))
})# tests/testthat/test-is_adult.R
test_that("is_adult identifies adults", {
expect_true(is_adult(25))
expect_false(is_adult(10))
expect_true(is_adult(18))
})Three resources, one progression: unit tests to acceptance tests to BDD with a team.
ShinyConf 2024. A structured approach to testing Shiny apps: inside-out unit tests, outside-in acceptance tests, and the loop that connects them.
Build Shiny apps from the outside in. Write acceptance tests first, then let them drive every design decision down to the module level.
Stories written in prose stay prose. They can't be run, so nobody knows when the app actually satisfies them. Requirements drift the moment code ships.
Budget tracking
As a user I want to see my net balance
so that I can understand my financial situation.
Acceptance: shows income, expenses, and net.
// ← lives in a doc, never executed The same scenario becomes a test. Given-When-Then forces you to name preconditions, actions, and outcomes. When it passes, the feature is done.
# tests/acceptance/test-budget.R
test_that("Scenario: I can inspect my net balance", {
# Given
dsl$record_income(2000)
dsl$record_expense(500)
# When
dsl$inspect_finances()
# Then
dsl$verify_total_income(2000)
dsl$verify_total_expenses(500)
dsl$verify_net_balance(1500)
dsl$teardown()
}) useR! 2025. From vague wish to working code: how to work with stakeholders, write Gherkin scenarios, and execute them with Cucumber for R.