Site icon R-bloggers

How to Write Robust shinytest2 Tests for R Shiny Apps

[This article was first published on jakub::sobolewski, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Most shinytest2 tests break too easily.

They expose fragile details of your app that break your tests as your implementation changes. It’s either by doing snapshots or by using raw input IDs. But you can take back control: make tests describe behavior, not wiring.

The problem with raw shinytest2

shinytest2 is great.

It really democratizes testing for Shiny apps. It’s easy to install. It’s easy to get your Shiny tests up and running.

But unfortunately it couples your tests tightly with internals of Shiny and entices you to use fragile snapshots. A default, very minimal shinytest2 test will looks like this (and this probably as simple as you can get):

test_that("...", {
  driver <- shinytest2::AppDriver$new()
  driver$click("setup-data-next")
  driver$click("setup-data-next")
  driver$click("setup-data-next")
  driver$click("setup-data-next")
  driver$set_inputs("plots-colorby" = "AGE")
  # Verify outcome
})

It works, but it leaks app internals. Input IDs change when you refactor, redesign, or just evolve your app. Setting inputs through raw IDs ties your tests to the “how,” not the “what.”

Your test can fail not because behavior changed, but because your selectors did.

Step 1. Use data-testid instead of input IDs

htmltools::tagApendAttributes allows you to decorate widgets with attributes. One of the best gifts you can give your tests: add data-testid (or use a different attribute name, but use it for testing only).

This keeps tests aligned with logic, not code structure.

Refactor your components to attach data-testid

picker_input <- function(inputId, ..., testid) {
  shinyWidgets::pickerInput(
    inputId,
    ...
  ) |>
    htmltools::tagAppendAttributes(
      `data-testid` = testid,
      .cssSelector = "select"
    )
}

Ensure you attach data-testid to the HTML tag with input ID. In case of shinyWidgets::pickerInput it’s the select tag. Then in the app code, replace pickerInput(...) with for example picker_input(..., testid = "plot_color_variable").

It’s a very safe refactoring, don’t worry.

Then instead of using input IDs in test code we can use data-testid to get the input ID:

driver <- shinytest2::AppDriver$new()
id <- driver$get_js(
  sprintf("$('[data-testid=%s]').attr('id')", testable_id)
)
driver$set_inputs(!!!rlang::list2(!!id := "AGE"))

Now it looks quite complex, but it’ll get better.

Use data-testtype for smarter dispatch

Another simple refactoring that will make our testing lives easier is adding data-testtype.

Why do this?

Let’s create a unique data-testtype for each component in our library:

picker_input <- function(inputId, ..., testid) {
  shinyWidgets::pickerInput(
    inputId,
    ...
  ) |>
    htmltools::tagAppendAttributes(
      `data-testid` = testid,
      `data-testtype` = "picker_input",
      .cssSelector = "select"
    )
}

The easiest choice is to just use the name of component we’re using. It will be easy to correlate with from the test code.

Step 3. Refactor test code

Now instead of using AppDriver directly, we extend it with our own driver class that uses data-testid and data-testtype to localize components.

action <- function(type, id, ..., driver) {
  switch(type,
    action_button = driver$click(id),
    picker_input = driver$set_inputs(
      !!!rlang::list2(!!id := rlang::list2(...)[[1]])
    )
  )
}

ShinyDriver <- R6::R6Class(
  inherit = shinytest2::AppDriver,
  public = list(
    dispatch = function(testable_id = missing_arg(), ...) {
      id <- self$get_js(
        sprintf("$('[data-testid=%s]').attr('id')", testable_id)
      )
      type <- self$get_js(
        sprintf("$('[data-testid=%s]').attr('data-testtype')", testable_id)
      )
      action(type, id, ..., driver = self)
    }
  )
)

Then our test becomes:

test_that("...", {
  # Given
  driver <- ShinyDriver$new()
  driver$dispatch("next")
  driver$dispatch("next")
  driver$dispatch("next")
  driver$dispatch("next")

  # When
  driver$dispatch("plot_color_variable", c("AGE"))

  # Then
  # Verify the outcome

  # Teardown
  driver$stop()
})

Step 4. Wrap reusable actions in step-functions

Tests are for humans to read first, machines second. Wrapping common interactions in functions gives your test language:

i_use_default_mapping <- function(driver) {
  driver$dispatch("next")
  driver$dispatch("next")
  driver$dispatch("next")
  driver$dispatch("next")
}

i_set <- function(what, to, driver) {
  driver$dispatch(what, to)
}

Now your test looks like this:

test_that("...", {
  # Given
  driver <- ShinyDriver$new()
  i_use_default_mapping(driver)

  # When
  i_set("plot_color_variable", to = "AGE", driver)

  # Then
  # Verify the outcome

  # Teardown
  driver$stop()
})

You read it as: Given I use the default mapping, when I set the plot color variable to AGE, then I should see the plot.

That’s executable specification.

Step 5. Use Cucumber (Optional)

From there you can see that we almost obtained Cucumber syntax with code.

To facilitate collaboration between developers and non-developers, you can use Cucumber to write your tests in an even more natural language format. This allows stakeholders to understand and contribute to the testing process more easily.

Then this test case would become:

Given I use default mapping
When I set "plot_color_variable" to "AGE"
# Then verify the outcome

With this approach we organize our test code towards:

This approach preserves business meaning of tests, enables tests to evolve alongside your codebase, allowing you to keep your development speed high.


Don’t let your tests chase implementation details.

Equip them with stable hooks, let the driver dispatch actions smartly, and express behavior with reusable steps. With these four steps, your shinytest2 suite speaks the same language as your users: actions, not wiring, and makes tests maintenance easier.

To leave a comment for the author, please follow the link and comment on their blog: jakub::sobolewski.

R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
Exit mobile version