Test Mode
In the previous article, you learned how to test a Shiny app end-to-end with Playwright controllers. Controllers are great for checking what your users see, but sometimes you want to check values your app doesn’t render at all — like the result of a reactive.calc — or to read an output’s value without parsing it out of the page’s HTML. That’s what test mode is for.
When test mode is enabled, a running Shiny session serves a JSON snapshot of its current state with three blocks:
input— the current value of every inputoutput— the current value of every outputexport— internal values you explicitly export from your server code
Enabling test mode
There are two ways to enable test mode:
- Pass
test_mode=Truewhen constructing your app:App(app_ui, server, test_mode=True) - Set the
SHINY_TESTMODE=1environment variable before starting the app
Here’s the good news: the pytest app-launch fixtures (local_app, create_app_fixture) enable test mode by default, so if you followed the steps in the previous article, you already have it — no changes needed.
Exporting internal reactive values
By default, the snapshot only contains inputs and outputs. To surface an internal value — say, a reactive.calc that never gets rendered directly — call export_test_values() in your server code. Each keyword argument names a value to export, and each value is a function that takes no arguments — a reactive.calc is a natural fit:
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 200
from shiny import reactive
from shiny.express import input, render, ui
from shiny.testmode import export_test_values
ui.panel_title("Hello Shiny!")
ui.input_slider("n", "N", 0, 100, 20)
@reactive.calc
def doubled():
return input.n() * 2
@render.text
def txt():
return f"n*2 is {doubled()}"
export_test_values(doubled=doubled)
Each exported value shows up under the snapshot’s export block. And here’s the best part: export_test_values() is a no-op when test mode is off, so you can leave these calls in your production code with zero impact on your users.
Reading the snapshot in a test
The AppTestValues controller reads the snapshot from a running app. Its expect_* methods automatically retry until the expectation holds (or a timeout elapses), just like other controller assertions:
from playwright.sync_api import Page
from shiny.playwright import controller
from shiny.run import ShinyAppProc
def test_doubled(page: Page, local_app: ShinyAppProc) -> None:
page.goto(local_app.url)
app_values = controller.AppTestValues(page)
# Per-key expectations (auto-retrying)
app_values.expect_input("n", 20)
app_values.expect_output("txt", "n*2 is 40")
app_values.expect_export("doubled", 40)
# Interact with the app, then check the internal value updated
controller.InputSlider(page, "n").set("30")
app_values.expect_export("doubled", 60)Notice that the slider’s value is the integer 20 here, while the controller examples in the previous article asserted strings like "55". That’s because the two read from different places:
- Controllers read the browser DOM — what the user sees. Values are strings formatted for display: a slider reads
"50"(or"1,000"with a thousands separator), an output reads its rendered text. AppTestValuesreads the server’s snapshot — the Python values your server function currently holds:input.n()as the int50, an output as its render function’s return value, and exports that have no DOM presence at all.
They can also differ in time: the DOM updates instantly when the user interacts, but the server only sees the change after the websocket round-trip (and any debounce/throttle). The auto-retrying expect_* methods absorb that gap for you.
As a rule of thumb: use controllers to test user-visible behavior, and reach for AppTestValues when you want to assert on server state.
Beyond the per-key methods, you can assert on a whole block at once:
expect_inputs(),expect_outputs(),expect_exports()take a dict of expected values. By default (match="subset"), only the keys you pass are checked; withmatch="exact", the block must contain exactly those keys (and values).get()returns the raw snapshot as a dict with"input","output", and"export"keys, in case you want to make free-form assertions.
# Check several exports at once; other blocks' keys are ignored
app_values.expect_exports({"doubled": 60})
# "exact" also fails if the block has keys you didn't list
app_values.expect_exports({"doubled": 60}, match="exact")
# Raw snapshot for custom assertions
snapshot = app_values.get()
assert snapshot["export"]["doubled"] == 60Expecting with a function
The expected value doesn’t have to be a literal. Pass a function instead, and the expectation calls it with the snapshot value, retrying until it returns something truthy. This works in the per-key methods and inside the dicts of the whole-block methods alike:
def is_integer(value):
return isinstance(value, int)
app_values.expect_export("doubled", is_integer)
app_values.expect_inputs({"n": is_integer, "name": "xyz"})Two tips:
- Prefer a named function over a lambda. The failure message reports the function’s name, e.g.
input['name'] == 'abc', which does not satisfy is_integer— a lambda leaves you guessing which check failed. - Raise for richer messages. If returning
True/Falseisn’t descriptive enough, the function can raiseAssertionErrorwith its own message; the expectation retries that just like a falsy return. (Any other exception is treated as a bug in the function and propagates immediately.)
Scrubbing values from the snapshot
Some values make snapshots noisy or leaky: timestamps change on every run, and secrets shouldn’t show up in test artifacts. Snapshot preprocessing lets you rewrite a value on its way into the snapshot — the live app value is untouched.
- Inputs: call
shiny.testmode.snapshot_preprocess_input(id, fn)(or the equivalent methodinput.set_snapshot_preprocess(id, fn)) in your server code. - Outputs: call
.snapshot_preprocess(fn)on the@render.*object itself.
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [editor, viewer]
#| layout: vertical
#| viewerHeight: 200
from datetime import datetime
from shiny import render
from shiny.express import ui
from shiny.testmode import snapshot_preprocess_input
ui.input_text("secret", "Secret", value="hunter2")
# The snapshot will show "<redacted>"; the app still sees the real value
snapshot_preprocess_input("secret", lambda value: "<redacted>")
@render.text
def stamp():
return f"time = {datetime.now().isoformat()}"
# Scrub the nondeterministic timestamp from the snapshot
stamp.snapshot_preprocess(lambda value: "time = <scrubbed>")
File inputs get one preprocessor automatically: the uploaded file’s datapath (a temporary directory that differs on every run) is replaced with just the file’s basename.
Under the hood, the snapshot is served at /session/{session_id}/dataobj/shinytest. You normally don’t need this URL — AppTestValues discovers it for you — but server code can obtain it with session.get_test_snapshot_url().
The endpoint accepts ?input=, ?output=, and ?export= query parameters to select which blocks to include (e.g. ?export=1 returns only the export block).