Open-source tools, playbooks, and tutorials for teams that ship R and Shiny they can trust.

0
CRAN downloads
cucumber + muttest
0
GitHub stars
across cucumber + muttest
0
Articles
on R & Shiny testing
0
R Weekly picks
featured by R Weekly

Jakub Sobolewski

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.

R Testing Packages

Acceptance Testing

Cucumber

The problem

Requirements live as prose

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.

Ticket · SALES-142Show the sales trend for a selected category.
Spec v3"…the plot must reflect the chosen category…"
# TODO: confirm this works for Electronics

The problem

Tests and requirements are separate

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

test_that("plot works", { expect_true(TRUE)})

The fix

Make the requirement the test

That is acceptance testing. The rule is written once, in structured language, and executed directly. Cucumber runs it for R.

#' tests/testthat/sales_trends.featureFeature: Sales trends As an analyst I want to see category trends So that I can spot what is moving  Scenario: Viewing a category Given the sales data is loaded When the user views "Electronics" Then the trend plot for "Electronics" is shown
#' tests/testthat/setup-steps.Rgiven("sales data is loaded", \(ctx) load_data(ctx))when("user views {string}", \(c, ctx) trend(ctx, c))then("plot for {string} shown", \(c, ctx) expect_plot(ctx))

Anatomy

Feature & Scenario

Name the capability, then one concrete example, in language the whole team reads.

Anatomy

Given

This is the starting point for the scenario. State all the preconditions that will allow the scenario to run.

Anatomy

When

The action from Users perspective. The thing that triggers the behavior you want to specify and test.

Anatomy

Then

The outcome you expect, observable by the User of the software.

The payoff

Run it

cucumber::test executes the file. The spec is the test, so the two can never disagree again.

31 GitHub stars9,578 CRAN downloads
> cucumber::test()✔ | F W S OK | Context✔ | 1 | Feature: Sales trends ══ Results ═══════════════════════[ FAIL 0 | WARN 0 | SKIP 0 | PASS 1 ]

Mutation testing

Muttest

The problem

Green doesn't mean tested

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)) 
})
100%coverage

The idea

Break it on purpose

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

Flip one operator

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)) 
})
100%coverage

The problem, proven

Your suite shrugs

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)) 
})
50%Mutation score
100%coverage

The fix

Test the boundary

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)) 
})
50%Mutation score
100%coverage

The payoff

Kill the mutant

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)) 
})
100%Mutation score
100%coverage

Mutation testing for R

Patch the gaps

muttest mutates your code across the whole suite and reports what survived. Coverage tells you what ran; muttest tells you what is actually tested.

26 GitHub stars7,107 CRAN downloads

The Learning Path

Three resources, one progression: unit tests to acceptance tests to BDD with a team.

01 TDD

Shiny Test-Driven Development

ShinyConf 2024. A structured approach to testing Shiny apps: inside-out unit tests, outside-in acceptance tests, and the loop that connects them.

  • Inside-out vs. outside-in strategies
  • Automate acceptance criteria
  • Isolate and test Shiny modules
  • Inject fake dependencies
02 ATTD

Shiny Acceptance TDD

Build Shiny apps from the outside in. Write acceptance tests first, then let them drive every design decision down to the module level.

  • Transform user stories into runnable tests
  • Build a DSL that hides UI details from specs
  • Keep tests green as the UI evolves
  • Structure Shiny modules for testability
Vague requirements

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
Executable specification

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()
})
03 BDD

Behavior-Driven Development

useR! 2025. From vague wish to working code: how to work with stakeholders, write Gherkin scenarios, and execute them with Cucumber for R.

  • BDD fundamentals and why they work
  • Given-When-Then scenario structure
  • Run specs with Cucumber for R
  • Align code with business language
muttest 0.3.0: Turn surviving mutants into a to-do list Snapshot Testing in R: Beyond Screenshots Test Doubles Taxonomy for R: Dummy, Stub, Spy, Mock, Fake 11 Test Smells That Make Your Tests Lie to You Behavior-Driven Development in R Shiny: Asserting Outcomes with Then Steps Behavior-Driven Development in R Shiny: Modeling User Behavior with When Steps muttest 0.2.0: More Mutators, Better Reporting, and Parallel Execution Behavior-Driven Development in R Shiny: Setting Up Test Preconditions with Given Steps Simplifying Interactions with Complex Widgets in shinytest2 Using JavaScript APIs Behavior-Driven Development in R Shiny: A Step-By-Step Example Deploy Multiple Shiny Apps from One R Package The Cadence of Behavior-Driven Development Clean R Tests with `local_mocked_bindings` and Dependency Wrapping R6 Interfaces For Backend: Define What, Not How How to Write Robust shinytest2 Tests for R Shiny Apps How to Test R Code That Uses LLMs, APIs, or Databases How to Write Cucumber Specifications the Right Way: From App Description to Scenarios Refactoring Cucumber and Playwright Acceptance Tests with GitHub Copilot 3 Reasons Blocking You From Doing Automated Testing Testing your Plumber APIs from R Testing Legacy Shiny Apps: Start with Behavior, Not Code The 4 Layers of Testing Every R Package Needs An Introduction to Behavior-Driven Development in R Keys To Scalable Code: Owning The Interfaces You Use Acceptance Test-Driven Development with Shiny Maximizing Efficiency with AI-Assisted Testing: Lessons Learned Efficient Snapshot Testing in R with CI and GitHub API How to Get Code Coverage Reports Without Sharing Code with Codecov.io Essential BDD Playlist: Master Behavior-Driven Development with Dave Farley Choosing the Best Library for Acceptance Testing Shiny Apps: {shinytest2} vs Cypress Understanding Software Quality: Unit Tests vs Acceptance Tests The Benefits of Writing Good Automated Tests The Consequences of Poor Test Writing in Software Development Optimize Your Unit Test Structure for Faster Feedback How Tests Improve Code Quality: 3 Key Insights Top 3 Testing Lessons from 3 Years as an R Developer Improve Your Unit Test Titles for Better Code Understanding How to Easily Capture and Test Code Output in R Optimize Shinytest2: Speed Up Your Shiny App Tests Effective Testing of Shiny Components with shinytest2 Effective State Management in Shiny Modules: A React-Inspired Approach Creating Robust E2E Test Selectors for Shiny Apps How to Snapshot Test Excel Workbooks in Shiny Apps Developing Shiny Modules with Test-Driven Development How to Set Up Cucumber for End-to-End Testing in Rhino Projects BDD Style Testing for Shiny Module Servers with R6 and testServer Real-Time Input Validation Using Bootstrap Form Validation API How to Use Acceptance Test-Driven Development for Shiny Modules Understanding Agile Testing Quadrants for Effective Software Testing Comprehensive Guide to Testing Shiny Modules with shiny::testServer 3-Step Guide to Building Plots Faster with Test Driven Development How Test-Driven Development (TDD) Speeds Up Prototyping Extending Shiny Modules with TDD in Legacy Code How to Develop Quickly Using a New Library Without Reading Extensive Documentation How to Test Code with External Dependencies Using Stubs in R Mastering Test-Driven Development: 3 Essential Steps for Better Code Mastering Test Driven Development: Build the Right Thing First Effective Strategies to Avoid Frustration with Legacy Code Improve Your Unit Tests with Arrange, Act, Assert Method How We Achieved 96% Code Coverage in a 2-Week App Prototype 3 Essential Types of Unit Tests Every R Developer Should Know