rguides

Testing Shiny Apps with shinytest2

Testing Shiny apps manually is tedious. You click through the same paths, verify the same outputs, and hope nothing broke when you added that new feature. This process does not scale. As your app grows, you need a way to catch regressions automatically. The shinytest2 package provides a programmatic testing toolkit that integrates with testthat.

The shinytest2 package solves this problem. It provides a streamlined toolkit for testing Shiny applications and integrates with the testthat framework. Instead of clicking through your app manually, you write tests that run automatically.

Why test your Shiny apps?

Every time you modify a Shiny app, you risk breaking existing functionality. A simple change to a reactive expression might cause outputs to fail silently. Without tests, you only discover these bugs when users report them.

Automated tests give you confidence to refactor, add features, and upgrade dependencies. When a test fails, you know exactly what broke. When all tests pass, you can deploy with confidence.

The shinytest2 package uses chromote to render your app in a headless Chrome browser. This lets you interact with your app programmatically: click buttons, set inputs, and verify outputs.

Installing shinytest2

Install the package from CRAN:

install.packages("shinytest2")

You also need Chrome or Chromium installed on your system. The chromote package uses it to run the browser. On most systems, installing Chrome is sufficient. For headless testing on CI servers, you may need to install Chromium via your package manager (apt-get install chromium on Ubuntu) and configure the path with options(chromote.browser = "/usr/bin/chromium").

If you want the development version with the latest features and bug fixes, install from GitHub:

devtools::install_github("rstudio/shinytest2")

Creating your first test

The easiest way to start is by recording your interactions. But first, you need an app to test. The following is a minimal Shiny app with a text input, a button, and a reactive greeting output. It is deliberately simple so you can focus on the testing mechanics rather than the application logic. Create a new file called app.R and paste this code:

library(shiny)

ui <- fluidPage(
  textInput("name", "Enter your name"),
  actionButton("greet", "Greet"),
  textOutput("greeting")
)

server <- function(input, output, session) {
  observeEvent(input$greet, {
    output$greeting <- renderText({
      paste0("Hello, ", input$name, "!")
    })
  })
}

shinyApp(ui, server)

Save this file as app.R in your project directory. The app takes a name from the user, waits for a button click, and displays a greeting — a classic interactive pattern that exercises both input handling and reactive output rendering. Now create a test file that programmatically drives the same interactions:

library(shinytest2)

# Create the test file by recording your actions
test_app <- function() {
  app <- AppDriver$new()
  
  # Record interactions
  app$set_inputs(name = "World")
  app$click("greet")
  
  # Get the output and verify
  output <- app$get_value(output = "greeting")
  testthat::expect_equal(output, "Hello, World!")
  
  app$stop()
}

This works but it is not integrated with the testthat workflow where multiple tests run in a single session and produce a standard pass/fail report. Let’s structure it properly.

Writing testthat tests

The real power of shinytest2 comes from integrating with testthat. Create a test file in the tests/testthat/ directory and use test_that() blocks — each block creates a fresh AppDriver instance, interacts with the app, and asserts on the output. The AppDriver$new() method launches your app automatically, looking for app.R in the current directory.

# tests/testthat/test-app.R
library(shinytest2)
library(testthat)

test_that("greeting works correctly", {
  app <- AppDriver$new()
  app$set_inputs(name = "Alice")
  app$click("greet")
  expect_equal(app$get_value(output = "greeting"), "Hello, Alice!")
  app$stop()
})

test_that("empty name shows generic greeting", {
  app <- AppDriver$new()
  app$set_inputs(name = "")
  app$click("greet")
  expect_equal(app$get_value(output = "greeting"), "Hello, !")
  app$stop()
})

Each test_that() block creates a fresh AppDriver instance, so the two tests above run independently — the second test does not inherit any state from the first. This isolation is critical for reliable testing because it prevents one test’s side effects from contaminating another. You can group related scenarios in the same file without worrying about ordering dependencies or state leaks between test cases.

Run all tests with a single command — devtools::test() discovers and executes every file in tests/testthat/. It looks for app.R in the current directory or accepts a path to a specific app.

Recording tests interactively

For complex apps, recording interactions is easier than writing them by hand. Instead of guessing the correct input IDs and output names, you interact with the app naturally and let shinytest2 capture every action as test code. This approach also helps you discover the exact CSS selectors and input names the app uses internally. Use the record_test() function:

library(shinytest2)

# This opens your app in a browser
record_test()

Interact with your app as a user would. The package records every click and input. When you’re done, stop recording. The package generates test code that you can copy into your test files.

This is useful for capturing the current behavior of your app. You can then modify the generated code to add assertions.

Testing Shiny UI elements

Beyond simple inputs and outputs, you often need to verify UI state. The AppDriver provides methods for this:

test_that("button is disabled when name is empty", {
  app <- AppDriver$new()
  
  # Check button exists
  expect_true(app$exists(id = "greet"))
  
  # Check button is enabled (no disabled attribute)
  button_html <- app$get_html(id = "greet")
  expect_false(grepl("disabled", button_html))
  
  app$stop()
})

This test verifies that a button exists in the DOM and is not disabled. That is a common check for conditional UI logic where certain controls should only be available after the user completes a prerequisite step. The $get_html() method retrieves the raw HTML of an element, letting you inspect attributes like disabled, class, or style directly rather than relying on Shiny-specific output assertions.

You can also check for specific UI elements or output content:

test_that("output container exists", {
  app <- AppDriver$new()
  
  # Verify output div exists
  expect_true(app$exists(name = "greeting"))
  
  # Get full HTML of an output
  html <- app$get_html(name = "greeting")
  expect_type(html, "character")
  
  app$stop()
})

Handling dynamic UI

Shiny apps often create UI elements dynamically — inserting new inputs with insertUI(), toggling visibility with shinyjs::show(), or rendering conditional panels based on reactive state. These elements do not exist when the page first loads, so any test that tries to interact with them immediately will fail. You need to wait for elements to appear before interacting:

test_that("dynamic input appears after button click", {
  app <- AppDriver$new()
  
  # Click a button that creates dynamic UI
  app$click("show-options")
  
  # Wait for the dynamic element to appear
  app$wait_for_idle(timeout = 5000)
  
  # Now interact with it
  app$set_inputs(`dynamic-select` = "Option A")
  
  app$stop()
})

The wait_for_idle() method waits until Shiny is idle (all reactive calculations finished). This is essential for testing apps with delayed UI updates.

Best practices for stable tests

Tests that break easily are worse than no tests. Here are patterns that make your tests maintainable.

Use data-testid instead of raw iDs

Input IDs change when you refactor or redesign your app. A button you called submit-btn today might become submit-button-v2 after a UI redesign, and every test that references it by ID will break. Tests that use raw IDs break when selectors change. The solution is to add test-specific attributes to your UI elements, separating the test contract from the implementation detail of element naming:

library(htmltools)

my_text_input <- function(inputId, label, testid = NULL) {
  tagList(
    textInput(inputId, label),
    # Add data-testid for testing
    tags$script(HTML(paste0(
      "$(document).ready(function() {",
      "$('#", inputId, "').attr('data-testid', '", testid, "');",
      "});"
    )))
  )
}

The helper wraps the standard textInput() with a jQuery snippet that stamps a data-testid attribute onto the generated HTML input element. This attribute is invisible to users but remains stable across refactors — even if you rename inputId from "name" to "user-name", the testid stays the same. Then in your tests, you query by testid instead of by Shiny input ID:

test_that("testid-based selection works", {
  app <- AppDriver$new()
  
  # Get element by testid
  testid <- app$get_js("$('[data-testid=name-input]').attr('id')")
  app$set_inputs(!!testid := "Test")
  
  app$stop()
})

This keeps tests aligned with business logic, not code structure.

Wrap common actions in functions

Instead of repeating interaction sequences across multiple tests, extract shared workflows into helper functions. This approach reduces duplication and makes test intent clearer: a test that calls login_as(app, "admin") communicates its purpose more directly than one that repeats the full sequence of clicks and input assignments. Create helper functions:

greet_user <- function(app, name) {
  app$set_inputs(name = name)
  app$click("greet")
}

test_that("greeting displays correctly", {
  app <- AppDriver$new()
  
  greet_user(app, "Bob")
  expect_equal(app$get_value(output = "greeting"), "Hello, Bob!")
  
  app$stop()
})

This makes tests readable and easier to maintain.

Running tests in CI

Automate your tests in continuous integration so that every push and pull request triggers a full test run. The GitHub Actions configuration below installs R, restores package dependencies via renv, and executes the testthat suite. A failing test blocks the build, preventing regressions from reaching your main branch:

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: r-lib/actions/setup-r@v2
      - uses: r-lib/actions/setup-renv@v2
      - run: devtools::test()
        shell: Rscript {0}

The CI pipeline runs your tests on every push. You catch regressions before they reach production.

Common issues and solutions

Chrome not found

If you get an error about Chrome not being found, shinytest2 cannot locate the browser binary it needs for headless rendering. This is common on CI runners where Chrome is not pre-installed or is installed at a non-standard path. Set the path explicitly with the chromote option:

options(chromote.browser = "/path/to/chromium")

On Linux, you might need to install Chromium. The chromote package searches common paths, but if your distribution places the binary elsewhere, the explicit chromote.browser option overrides the default search. For headless operation, Chromium does not need a display or window manager, so it works inside Docker containers and CI runners without X11. Install it via your package manager:

sudo apt-get install chromium

Tests timeout

If tests timeout waiting for UI updates, your app’s reactive computations may be taking longer than the default 10-second timeout, especially on CI runners with limited CPU. The AppDriver$new() constructor accepts a timeout parameter in milliseconds that controls how long it waits for the app to become responsive. Increase the timeout when your app performs heavy initialization work like loading datasets or precomputing model outputs:

app <- AppDriver$new(timeout = 30000)  # 30 seconds

The AppDriver$new(timeout = 30000) constructor stretches the startup window to 30 seconds, which is useful for apps that load external data or compile CSS on launch. For mid-test delays, such as waiting for a database query to populate a reactive output, use explicit waits that poll for a specific condition rather than a blanket timeout:

app$wait_for_idle(timeout = 10000)

Flaky tests

Intermittent test failures are nearly always caused by timing races between the test script and the Shiny reactive graph. Tests that pass on one run and fail on the next point to a missing or insufficient wait. The $wait_for_value() method blocks until a specific output matches a predicate, which is more precise than a flat Sys.sleep() call. Add explicit waits after setting inputs:

app$set_inputs(name = "Test")
app$wait_for_value(output = "greeting")

Automated testing transforms Shiny development. You stop manually clicking through your app to verify everything works. Instead, you run a command and trust that your tests catch any regressions. The initial investment pays off quickly as your app grows and evolves.

Start small: write tests for your most critical features. Expand coverage as you add functionality. Your future self will thank you when a test catches a bug before deployment.

Integration testing with shinytest2

shinytest2 drives a headless Chromium browser to simulate user interactions. It can click buttons, enter text, select from dropdowns, and verify that the expected outputs appear. Tests are deterministic because they wait for the app’s reactive graph to settle before making assertions, a $expect_values() call implicitly waits for all outputs to update.

Screenshot-based testing ($expect_screenshot()) catches visual regressions. The first run saves the baseline image; subsequent runs compare against it. This is useful for catching CSS regressions and layout changes that do not affect the app’s behavior but affect its appearance.

Unit testing reactives with testServer

shiny::testServer() tests server logic without rendering the UI, which is faster than full app testing. It allows direct manipulation of reactive inputs and assertions on reactive values: testServer(server, { session$setInputs(x = 5); expect_equal(result(), 25) }). This is the right level of testing for complex reactive logic, data transformations, and custom modules, it isolates the logic from the browser infrastructure.

Combine both levels: testServer() for unit tests of complex logic (fast, many tests), shinytest2 for integration tests of critical user flows (slower, focused on key paths). The same coverage philosophy applies as for non-Shiny code: test the public API, not the implementation details.

For end-to-end testing of deployed Shiny apps, shinytest2 can connect to a running server rather than launching a new app. This allows testing staging environments before production deployments. AppDriver$new(url = 'https://staging.example.com/app') connects to a remote app.

Summary

Testing Shiny applications with shinytest2 catches regressions in reactive logic that unit tests miss. The key is testing behaviors rather than implementation details — what the user sees and interacts with, not the internal state of reactive values. Combine shinytest2 for end-to-end tests with testthat for unit tests of pure functions used in the server. Run both in CI on every commit. The investment in Shiny tests pays off when refactoring complex reactive graphs, where manual testing is time-consuming and error-prone.

See also