Testing components with shinytest2

Tips for Shiny component libraries developers

4 min read

Components in Shiny not only need to be rendered with correct markup, but also need to successfully communicate with the server. This means that testing the markup only may be not enough to ensure they function correctly.

In the newest addition to shiny.blueprint, server update functions are added to some of its components. To test that they work correctly, we must:

How to run a component with Shiny server?

One approach is to bunch up a few (or all) components in one Shiny app. This could allow sharing one app between tests, saving on the time it takes to start up the app, but it would require additional coordination between tests.

Such choice was made for component tests in shiny.fluent, the test app is placed in inst/ directory and a series of Cypress tests are run on it. This approach works well, because Cypress quickly refreshes the app between each test case, ensuring clean state in each test case. shinytest2 doesn’t have this feature.

This approach is a bit tricky to maintain, as there are a lot of things rendered on the page.

The other approach is to run a small Shiny app with only the tested component.

Running components in isolation

We can create an app factory that accepts components and their corresponding update functions as parameters.

#' tests/testtthat/setup.R
#' @param component Function, returns the component to test
#' @param update Function, updates the component
serverUpdateApp <- function(
  component = \() { },
  update = \(session) { }
) {
  shinytest2::AppDriver$new(
    shiny::shinyApp(
      ui = shiny::fluidPage(
        shiny::actionButton("trigger", "Trigger"),
        component()
      ),
      server = function(input, output, session) {
        shiny::observeEvent(input$trigger, update(session))
      }
    )
  )
}

#' @param inputId The ID of the tested component
#' @param driver shinytest2::AppDriver of the test app
serverUpdateActions <- function(inputId, driver) {
  list(
    update = function() {
      driver$click("trigger")
    },
    getValue = function() {
      driver$get_value(input = inputId)
    }
  )
}

This simple app showcases the behavior we want to test. It will render the component and allow triggering the update. It’s simple and it’s enough to test this behavior.

We can create a different app, with different parametrization, for testing other behaviors.

If testing consists of the same steps there is an opportunity to abstract test code.

We can provide a list of actions tests can take on this app – they hide implementation details of those interactions. Tests don’t need to know that a button is clicked to trigger the update, they only know that the update happens. They also don’t need to know how the value is retrieved, only that it is and expect a value. This way we can change the implementation of those actions without changing the tests in the future.

This is how a test for a Checkbox component looks like:

#' tests/testthat/test-Checkbox.R
describe("Checkbox", {
  it("should allow updating values from the server", {
    # Arrange
    driver <- serverUpdateApp(
      \() Checkbox.shinyInput("checkbox", value = FALSE),
      \(session) {
        updateCheckbox.shinyInput(
          session,
          inputId = "checkbox",
          value = TRUE
        )
      }
    )
    on.exit(driver$stop())
    actions <- serverUpdateActions("checkbox", driver)

    # Act
    actions$update()

    # Assert
    expect_true(actions$getValue())
  })
})

and for NumericInput:

describe("NumericInput", {
  it("should allow updating values from the server", {
    # Arrange
    driver <- serverUpdateApp(
      \() {
        NumericInput.shinyInput(
          inputId = "numeric",
          value = 1,
          label = "Numeric"
        )
      },
      \(session) {
        updateNumericInput.shinyInput(
          session,
          inputId = "numeric",
          value = 2
        )
      }
    )
    on.exit(driver$stop())
    actions <- serverUpdateActions("numeric", driver)

    # Act
    actions$update()

    # Assert
    expect_equal(actions$getValue(), 2)
  })
})

Test code for both cases is similar, and it’s good, as they test the same behavior.

This approach provides:

Check out the full scope of the changes here.