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 article
assert that JSON — the first with no browser at all, the second inside a
real one.
Testing the server with testServer()
A ui.tsx server contains only reactive computation, so
“this input produces that output value” is the server.
reactive_output() is an ordinary render function, so
shiny::testServer() drives it with no browser and no
client, 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
(output$`counter-label`), or reachable through
session$makeScope("counter"); a module server can also be
driven on its own:
shiny::testServer(card_server, args = list(id = "left"), {
session$setInputs(n = 7)
expect_equal(output$label, "n=7")
})testServer() raises rather than
reporting a status. Reading an output whose render function failed
req() throws a silent error, which is how you assert that
an output produced nothing:
expect_error(output$answer, class = "shiny.silent.error")examples/01-hello and examples/07-plotly
both carry a tests/testthat/test-outputs.R written this
way, runnable from the app directory with
shiny::runTests().
The Python counterpart is
shiny.testserver.test_server(), which reports
.status ("ok", "error",
"silent") instead of raising.
Testing wire payloads with wire_tap()
wire_tap() records the payloads crossing the websocket
in a shinytest2 test
so you can assert on them directly, without inspecting the rendered DOM.
Reserve it for what testServer() structurally cannot see:
the values the client chooses to send, real bindings, and real
rendering.
Setup
wire_tap() needs the shinytest2 package. Start the
AppDriver with shiny.trace = TRUE so every
websocket frame is recorded in the app’s logs:
Matchers
Each expect_* function 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 with
identical(). JSON numbers parse to integer when they have no fraction, so compare against30L, not30.
There is one expect_* per channel:
| Function | Channel |
|---|---|
expect_output_value(id, matcher) |
values delivered for output$id
|
expect_message(id, matcher) |
send_message(session, id, ...) payloads |
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
To inspect everything that crossed a channel, use the
all_* functions:
tap$all_output_values("dist_data")
tap$all_messages("notify")
tap$all_input_values("bins")Input ids match the bare id or any id:type wire id, so
use the id you wrote in useShinyInput().
Python counterpart
shinyreact.playwright.WireTap in the Python package has
the same methods and semantics for Playwright tests. One divergence:
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.