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] == 16get_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.defaultsuffix — the hook appends it on the wire, but Python’s handler is a no-op, soset_inputs(bins=9)is equivalent. - A typed one does:
set_inputs(**{"when:shiny.datetime": 1756382400})is what runs the handler and makesinput.when()adatetime. - An unset input means
status == "silent", not aNonevalue, becauseinput.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 against30L, not30).
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.