Skip to main content

Testing

In a shinyreact app the contract between server and client is the JSON that crosses the Shiny websocket: the values reactive_output delivers, the payloads send_message() pushes, and the values useShinyInput() sends back. Both halves of this page assert that JSON — the first without a browser at all, the second inside a real one.

Testing the server without a browser

A ui.tsx server contains only reactive computation, so “this input produces that output value” is the server. Shiny can run one against a mock connection — no browser, no subprocess — and hand you the value the client would have received.

shiny.testserver.test_server() loads the app file (Express or Core, shiny.App or shinyreact.ReactApp) and runs its server:

from pathlib import Path

from shiny.testserver import test_server

APP = Path(__file__).resolve().parents[1] / "app.py"


def test_dist_outputs():
    with test_server(APP) as ts:
        ts.set_inputs(bins=9)
        assert ts.get_output("dist_caption") == "272 eruptions in 9 bins"
        assert ts.get_output("dist_data").value["counts"][0] == 16

get_output() compares equal to the underlying value, so assert on it directly; reach for .value to index into it, and .status ("ok", "error", "silent") or .error for the non-value outcomes. Traditional renderers embedded with ShinyOutput are readable the same way, so a @render.data_frame payload can be checked here too.

Three details are specific to shinyreact:

  • An untyped input id needs no :shinyreact.default suffix — the hook appends it on the wire, but Python’s handler is a no-op, so set_inputs(bins=9) is equivalent.
  • A typed one does: set_inputs(**{"when:shiny.datetime": 1756382400}) is what runs the handler and makes input.when() a datetime.
  • An unset input means status == "silent", not a None value, because input.x() raises a silent exception while unset.

An input read through @reactive.event(..., ignore_init=True) needs two set_inputs calls, mirroring the client: useShinyInput registers its default at mount and sends the event after.

test_server() is newer than shiny 1.7.0.

reactive_output() is an ordinary render function, so shiny::testServer() drives it directly and output$id is the JSON value itself — no spec wrapper, no coercion:

test_that("dist_data bins the waiting column", {
  shiny::testServer(app_dir, {
    session$setInputs(bins = 9)
    expect_named(output$dist_data, c("breaks", "counts"))
    expect_equal(
      unclass(output$dist_data$counts),
      c(16L, 37L, 30L, 16L, 14L, 57L, 67L, 29L, 6L)
    )
    expect_equal(output$dist_caption, "272 eruptions in 9 bins")
  })
})

unclass() is there because the app wraps its vectors in I() so a one-bin result serializes as [272] rather than 272; the AsIs class rides along on the value the test sees.

Pass a directory containing the app, or the server function itself. Module ids are namespaced as the session sees them, so a scoped output is read as output$`counter-label`, or reachable through session$makeScope("counter"); a module server can also be driven on its own with testServer(card_server, args = list(id = "left"), { ... }).

testServer() raises rather than reporting a status: reading an output whose render function failed req() throws a shiny.silent.error, so expect_error(output$answer, class = "shiny.silent.error") is how you assert an output produced nothing. Python’s test_server() reports that through .status instead.

Testing wire payloads

A wire tap records the payloads crossing the websocket in a browser test, so you can assert on them directly instead of inspecting the rendered DOM. Reserve it for what the section above structurally cannot see: the values the client chooses to send, real bindings, and real rendering.

shinyreact.playwright.WireTap needs the playwright package. Construct it before page.goto() so it sees every frame:

from shinyreact.playwright import WireTap


def test_dist_data(page, app):
    tap = WireTap(page)
    page.goto(app.url)
    tap.expect_input_value("bins", 30)
    tap.expect_output_value("dist_data", lambda d: d["breaks"][0] == 43.0)

wire_tap() needs the shinytest2 package. Start the AppDriver with shiny.trace = TRUE so every websocket frame is recorded in the app’s logs:

test_that("dist_data bins the waiting column", {
  app <- shinytest2::AppDriver$new(
    app_dir,
    options = list(shiny.trace = TRUE)
  )
  withr::defer(app$stop())

  tap <- shinyreact::wire_tap(app)
  tap$expect_input_value("bins", 30L)
  tap$expect_output_value("dist_data", function(d) d$breaks[[1]] == 43)
})

Matchers

Each expect_* method takes a matcher and retries until it matches or a timeout (10 seconds by default) elapses:

  • A function is satisfied by a truthy return value. A function that errors on a payload’s shape counts as a non-match, not a test failure.
  • Any other object is compared for equality (identical() in R, so compare against 30L, not 30).

There is one expect_* per channel:

Method Channel
expect_output_value(id, matcher) values delivered for output id
expect_message(id, matcher) send_message() payloads of type id
expect_input_value(id, matcher) values the client sent for input id

Successive expectations on one channel assert an ordered subsequence: each scans from just past the previous match, so a value that arrives between two checks is never missed.

Full histories

all_output_values(id), all_messages(id), and all_input_values(id) return everything that crossed a channel, in order. Input ids match the bare id or any id:type wire id, so use the id you wrote in useShinyInput().

One divergence between the two languages: jsonlite::fromJSON() maps a JSON null output value to NULL, indistinguishable from an absent key, so early null frames are dropped in R where Python records None.