Testing Shiny applications
Lesson 9 — R Shiny
Learning objectives
- Separate business logic from reactive plumbing so it can be tested plainly
- Test server logic and modules with
testServer() - Write end-to-end browser tests with
shinytest2 - Use snapshot testing for UI and output regression
- Run Shiny tests in continuous integration
Three layers
┌─────────────────────────────────────────────┐
│ End-to-end (shinytest2) │ slow, few
│ Real browser, real clicks, screenshots │
├─────────────────────────────────────────────┤
│ Server logic (testServer) │ fast, many
│ Reactives, modules, no browser │
├─────────────────────────────────────────────┤
│ Pure functions (testthat) │ fastest, most
│ Derivations, formatting, validation │
└─────────────────────────────────────────────┘
The most valuable thing you can do for Shiny testability happens before you write any test: move logic out of the server.
# HARD TO TEST — logic tangled with reactivity
server <- function(input, output, session) {
output$table <- renderTable({
d <- adsl
if (input$arm != "All") d <- d[d$TRT01P == input$arm, ]
d <- d[d$AGE >= input$age[1] & d$AGE <= input$age[2], ]
aggregate(AGE ~ TRT01P, d, function(x) c(n = length(x), mean = mean(x)))
})
}
# EASY TO TEST — a plain function
filter_subjects <- function(data, arm = "All", age_range = c(0, 120)) {
if (arm != "All") data <- dplyr::filter(data, TRT01P == arm)
dplyr::filter(data, AGE >= age_range[1], AGE <= age_range[2])
}
summarise_by_arm <- function(data) {
dplyr::summarise(data, n = dplyr::n(), mean_age = mean(AGE, na.rm = TRUE),
.by = TRT01P)
}
server <- function(input, output, session) {
filtered <- reactive(filter_subjects(adsl, input$arm, input$age))
output$table <- renderTable(summarise_by_arm(filtered()))
}filter_subjects() and summarise_by_arm() are now testable with ordinary testthat — no Shiny involved. See Testing with testthat.
testServer()
Tests the server function without a browser. Fast enough to run on every save.
library(testthat)
library(shiny)
test_that("filtering by arm reduces the row count", {
testServer(server, {
session$setInputs(arm = "Placebo", age = c(18, 90), saffl = TRUE)
expect_true(all(filtered()$TRT01P == "Placebo"))
expect_lt(nrow(filtered()), nrow(adsl))
})
})
test_that("the age filter is inclusive at both ends", {
testServer(server, {
session$setInputs(arm = "All", age = c(65, 65), saffl = FALSE)
expect_true(all(filtered()$AGE == 65))
})
})
test_that("outputs update when inputs change", {
testServer(server, {
session$setInputs(arm = "All", age = c(18, 90), saffl = TRUE)
n_all <- output$n_subj
session$setInputs(arm = "Placebo")
expect_lt(as.numeric(output$n_subj), as.numeric(n_all))
})
})Inside testServer() you have access to:
session$setInputs(x = 1, y = 2) # set inputs (triggers reactivity)
input$x # read an input
output$plot # read a rendered output
some_reactive() # call any reactive in the server
session$flushReact() # force pending reactions to run
session$elapse(1000) # advance time for invalidateLater/debounce
session$getReturned() # a module's return value
session$returned # sameTesting a module
test_that("mod_filter_server returns correctly filtered data", {
test_data <- reactive(tibble::tribble(
~USUBJID, ~TRT01P, ~AGE, ~SAFFL,
"001", "Placebo", 45, "Y",
"002", "Drug A", 72, "Y",
"003", "Drug A", 38, "N",
"004", "Placebo", 67, "Y"
))
testServer(mod_filter_server, args = list(data = test_data), {
session$setInputs(arm = "All", age = c(18, 90), saffl = FALSE)
expect_equal(nrow(session$getReturned()()), 4)
session$setInputs(arm = "Drug A")
expect_equal(nrow(session$getReturned()()), 2)
session$setInputs(arm = "All", saffl = TRUE)
expect_equal(nrow(session$getReturned()()), 3)
expect_false("003" %in% session$getReturned()()$USUBJID)
session$setInputs(age = c(60, 90))
expect_equal(session$getReturned()()$USUBJID, c("002", "004"))
})
})session$getReturned() gives the module’s return value. Because it is a reactive, you call it: session$getReturned()().
Testing state and events
test_that("the counter increments and resets", {
testServer(server, {
expect_equal(counter(), 0)
session$setInputs(increment = 1) # action buttons: set the click count
expect_equal(counter(), 1)
session$setInputs(increment = 2)
expect_equal(counter(), 2)
session$setInputs(reset = 1)
expect_equal(counter(), 0)
})
})
test_that("debounced input settles after the delay", {
testServer(server, {
session$setInputs(search = "abc")
expect_null(debounced_search()) # not yet
session$elapse(600) # advance past the 500ms debounce
expect_equal(debounced_search(), "abc")
})
})Testing an error path
test_that("an empty filter result produces a validation message", {
testServer(server, {
session$setInputs(arm = "Nonexistent", age = c(18, 90))
expect_equal(nrow(filtered()), 0)
expect_error(output$plot, "No subjects match")
})
})testServer() cannot do
- No browser, so no JavaScript —
shinyjs,conditionalPanelandDTclient-side behaviour are all invisible renderUI()output is a tag object, not rendered HTML- Inputs created by
renderUI()do not exist until yousetInputs()them manually - No layout, no CSS, no screenshots
For those, you need shinytest2.
shinytest2
Drives a real headless browser (Chromote). Slower, but tests what the user actually experiences.
usethis::use_package("shinytest2", "Suggests")
shinytest2::use_shinytest2()library(shinytest2)
test_that("the app loads and filtering updates the outputs", {
app <- AppDriver$new(
app_dir = test_path("../.."),
name = "adsl-explorer",
height = 900, width = 1400,
seed = 42 # make any randomness reproducible
)
# Initial state
app$wait_for_idle()
app$expect_values(output = c("n_subj", "mean_age"))
# Interact
app$set_inputs(arm = "Placebo")
app$wait_for_idle()
expect_lt(as.numeric(app$get_value(output = "n_subj")), 300)
app$set_inputs(age = c(65, 90))
app$wait_for_idle()
app$expect_values(output = "n_subj")
# Visual regression
app$expect_screenshot(name = "placebo-elderly")
# Click a button
app$click("reset")
app$wait_for_idle()
expect_equal(app$get_value(input = "arm"), "All")
app$stop()
})Key methods:
app$set_inputs(x = 1, y = "a", wait_ = TRUE)
app$click("button_id")
app$upload_file(file = "test-data.csv")
app$get_value(input = "x"); app$get_value(output = "plot")
app$get_values() # everything
app$wait_for_idle(duration = 200, timeout = 30000)
app$wait_for_value(output = "table", ignore = list(NULL))
app$expect_values() # snapshot of all inputs/outputs
app$expect_screenshot()
app$expect_download("download_csv")
app$get_logs() # browser console + R messages
app$view() # open the live browser — for debugging
app$stop()Recording a test
shinytest2::record_test()Opens the app in a browser with a recording panel. Click through the workflow, add expectations, and it generates the test file. This is by far the fastest way to write the first version of an end-to-end test; edit it afterwards to remove noise.
Snapshots
expect_values() writes a JSON snapshot of inputs and outputs; expect_screenshot() writes a PNG.
testthat::snapshot_review() # visual diff of what changed
testthat::snapshot_accept() # accept the new stateFont rendering differs between macOS, Windows and Linux, so a screenshot taken on your laptop will not match the one taken in CI. Options:
- Run screenshot tests only on one platform (
skip_on_os()) - Use
expect_values()(JSON) for CI and screenshots locally - Run CI in the same container you develop in
expect_values() is much more robust and catches most regressions. Reserve screenshots for layout-critical apps.
Testing downloads
test_that("the CSV export contains the filtered rows", {
app <- AppDriver$new(app_dir = test_path("../.."))
app$set_inputs(arm = "Placebo")
app$wait_for_idle()
path <- app$get_download("download_csv")
d <- readr::read_csv(path, show_col_types = FALSE)
expect_true(all(d$TRT01P == "Placebo"))
expect_gt(nrow(d), 0)
app$stop()
})Testing uploads
test_that("uploading a valid CSV populates the table", {
app <- AppDriver$new(app_dir = test_path("../.."))
app$upload_file(file = test_path("fixtures", "valid_adsl.csv"))
app$wait_for_idle()
expect_gt(as.numeric(app$get_value(output = "n_rows")), 0)
app$stop()
})
test_that("uploading a file without USUBJID shows an error", {
app <- AppDriver$new(app_dir = test_path("../.."))
app$upload_file(file = test_path("fixtures", "missing_usubjid.csv"))
app$wait_for_idle()
expect_match(app$get_value(output = "table"), "USUBJID")
app$stop()
})Continuous integration
# .github/workflows/shiny-tests.yaml
name: Shiny tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: r-lib/actions/setup-r@v2
with:
use-public-rspm: true
- uses: r-lib/actions/setup-r-dependencies@v2
with:
extra-packages: |
any::shinytest2
any::testthat
any::rcmdcheck
- name: Run tests
run: |
shiny::runTests(".", assert = TRUE)
shell: Rscript {0}
- name: Upload failure artefacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-snapshots
path: tests/testthat/_snaps/Uploading _snaps/ on failure is the detail that makes CI failures diagnosable — you get the actual screenshot or JSON that differed, rather than just a message saying it did.
A testing strategy
| Layer | What | How many | Speed |
|---|---|---|---|
| Pure functions | Derivations, formatting, validation | Dozens | Milliseconds |
testServer |
Reactive wiring, modules, state | Tens | Seconds |
shinytest2 |
Critical user journeys | A handful | Minutes |
The critical journeys worth an end-to-end test:
- App loads without error
- The primary workflow produces a result
- Upload → process → download round trip
- Authorisation: a restricted user cannot reach restricted data
- The error path: bad input produces a message, not a crash
Everything else should be pushed down to the faster layers.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| All logic inside render functions | Untestable | Extract pure functions |
| Only end-to-end tests | Slow, flaky suite | Push tests down the pyramid |
No wait_for_idle() |
Flaky, race-dependent | Wait after every interaction |
| Screenshot tests in CI | Fail on font differences | expect_values(), or pin the platform |
| Testing implementation | Breaks on every refactor | Test observable behaviour |
| Unseeded randomness | Non-reproducible snapshots | seed = in AppDriver$new() |
| No test for the empty case | Crashes on real data | Test zero-row results |
Exercise 9.1 — Make an app testable
Refactor this server so the logic can be tested without Shiny, then write both the pure-function tests and a testServer() test.
server <- function(input, output, session) {
output$summary <- renderTable({
d <- adsl[adsl$TRT01P == input$arm, ]
data.frame(
n = nrow(d),
mean_age = round(mean(d$AGE, na.rm = TRUE), 1),
pct_female = round(100 * mean(d$SEX == "F", na.rm = TRUE), 1)
)
})
}Show solution
Step 1 — extract the logic.
# R/summarise.R
#' Summarise a subject-level dataset
#' @param data A data frame with AGE and SEX columns.
#' @return A one-row data frame with n, mean_age and pct_female.
#' @export
summarise_subjects <- function(data) {
data.frame(
n = nrow(data),
mean_age = if (nrow(data) == 0) NA_real_
else round(mean(data$AGE, na.rm = TRUE), 1),
pct_female = if (nrow(data) == 0) NA_real_
else round(100 * mean(data$SEX == "F", na.rm = TRUE), 1)
)
}
#' @export
filter_arm <- function(data, arm) {
if (identical(arm, "All")) return(data)
data[data$TRT01P == arm & !is.na(data$TRT01P), , drop = FALSE]
}# app.R
server <- function(input, output, session) {
filtered <- reactive(filter_arm(adsl, input$arm))
output$summary <- renderTable(summarise_subjects(filtered()))
}Step 2 — pure function tests (fast, exhaustive).
test_data <- tibble::tribble(
~USUBJID, ~TRT01P, ~AGE, ~SEX,
"001", "Placebo", 45, "F",
"002", "Placebo", 55, "M",
"003", "Drug A", 65, "F",
"004", "Drug A", NA, "F"
)
test_that("filter_arm keeps only the requested arm", {
expect_equal(nrow(filter_arm(test_data, "Placebo")), 2)
expect_equal(nrow(filter_arm(test_data, "Drug A")), 2)
expect_equal(nrow(filter_arm(test_data, "All")), 4)
expect_equal(nrow(filter_arm(test_data, "Nonexistent")), 0)
})
test_that("summarise_subjects computes n, mean age and percent female", {
out <- summarise_subjects(filter_arm(test_data, "Placebo"))
expect_equal(out$n, 2)
expect_equal(out$mean_age, 50)
expect_equal(out$pct_female, 50)
})
test_that("missing ages are excluded from the mean", {
out <- summarise_subjects(filter_arm(test_data, "Drug A"))
expect_equal(out$n, 2) # both subjects counted
expect_equal(out$mean_age, 65) # only one contributes to the mean
expect_equal(out$pct_female, 100)
})
test_that("an empty dataset returns NA rather than NaN", {
out <- summarise_subjects(test_data[0, ])
expect_equal(out$n, 0)
expect_true(is.na(out$mean_age))
expect_true(is.na(out$pct_female))
})That last test is the one worth having. Without the if (nrow(data) == 0) guard, mean(numeric(0)) returns NaN and the table shows NaN to the user.
Step 3 — testServer for the wiring.
test_that("the summary output responds to the arm input", {
testServer(server, {
session$setInputs(arm = "Placebo")
expect_equal(nrow(filtered()), 2)
session$setInputs(arm = "All")
expect_equal(nrow(filtered()), 4)
})
})testServer test is. All the interesting behaviour was tested in step 2 at a fraction of the cost; step 3 only confirms the wiring. That ratio is the goal.
Exercise 9.2 — End-to-end journey
Write a shinytest2 test for the journey: upload a CSV → apply a filter → verify the row count → download the result → verify the downloaded file matches the filter.
Show solution
library(shinytest2)
test_that("upload, filter and download round trip", {
skip_on_cran()
# --- Fixture ------------------------------------------------------------
fixture <- withr::local_tempfile(fileext = ".csv")
test_data <- data.frame(
USUBJID = sprintf("%03d", 1:20),
TRT01P = rep(c("Placebo", "Drug A"), each = 10),
AGE = c(seq(40, 76, by = 4), seq(45, 81, by = 4)),
SEX = rep(c("M", "F"), 10),
SAFFL = "Y"
)
readr::write_csv(test_data, fixture)
app <- AppDriver$new(
app_dir = test_path("../.."),
name = "upload-filter-download",
seed = 42,
height = 900, width = 1400
)
on.exit(app$stop(), add = TRUE)
# --- 1. Upload ----------------------------------------------------------
app$upload_file(file = fixture)
app$wait_for_idle(timeout = 15000)
expect_equal(as.numeric(app$get_value(output = "n_rows")), 20)
# --- 2. Filter ----------------------------------------------------------
app$set_inputs(arm = "Placebo")
app$wait_for_idle()
expect_equal(as.numeric(app$get_value(output = "n_rows")), 10)
app$set_inputs(age = c(50, 70))
app$wait_for_idle()
n_shown <- as.numeric(app$get_value(output = "n_rows"))
expected <- sum(test_data$TRT01P == "Placebo" &
test_data$AGE >= 50 & test_data$AGE <= 70)
expect_equal(n_shown, expected)
# --- 3. Download and verify --------------------------------------------
downloaded <- app$get_download("download_csv")
result <- readr::read_csv(downloaded, show_col_types = FALSE)
expect_equal(nrow(result), expected)
expect_true(all(result$TRT01P == "Placebo"))
expect_true(all(result$AGE >= 50 & result$AGE <= 70))
expect_setequal(names(result), names(test_data))
# --- 4. No errors in the console ---------------------------------------
logs <- app$get_logs()
errors <- logs[logs$level %in% c("error", "SEVERE"), ]
expect_equal(nrow(errors), 0,
info = paste(errors$message, collapse = "\n"))
})Points that make this a useful test rather than a fragile one:
- The expected count is computed from the fixture, not hard-coded. If the fixture changes, the test still asserts the right thing.
wait_for_idle()after every interaction. Without it, the assertion may read a stale output and the test fails intermittently — the worst kind of failure.- The download is verified against the filter, not just checked for existence. “The download button produces a file” is a much weaker statement than “the file contains exactly the filtered rows”, and the second is the one that catches the bug where the handler forgets the filter (see Authentication).
- Console errors are checked. A JavaScript error that does not break the visible behaviour will otherwise go unnoticed until it does.
- No screenshot, so this runs reliably in CI on any platform.
Recap
- Extract logic into pure functions first; that is the biggest testability win
testServer()for reactives and modules — fast, no browsersession$getReturned()()to test a module’s return valuesession$elapse()to test debounce andinvalidateLatershinytest2for a handful of critical end-to-end journeyswait_for_idle()after every interaction, or the suite will be flaky- Prefer
expect_values()over screenshots in CI - Upload
_snaps/as a CI artefact so failures are diagnosable
Next: Deployment.