Test Mode
In Unit testing, you read a session’s inputs, outputs, and exported values in-process, through local_server. Those readers work because the session keeps a snapshot of the server’s state. Test mode is what makes that same snapshot available from a running app: with it enabled, a Shiny session serves the snapshot as JSON over HTTP, so an end-to-end test can check server-side values from a real browser session.
That matters because Playwright controllers only see what the user sees. Sometimes you want to check a value your app doesn’t render at all — the result of a reactive.calc — or read an output’s value without parsing it out of the page’s HTML.
The snapshot has the same three blocks you met in-process:
input— the current value of every inputoutput— the current value of every outputexport— internal values you explicitly export from your server code withexport_test_values()
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. (The in-memory local_server fixture and test_server() enable it for their own session the same way, which is why get_export() works there without any setup.)
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, exactly as in Exported values. Nothing changes on the app side between the two kinds of test; the same export serves both. The app below is the one the rest of this article tests:
#| '!! 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.
Preprocessors are part of building the snapshot, not of serving it, so they apply equally to the values local_server reads in-process: a scrubbed output is scrubbed in get_output(), and a scrubbed input in get_input().
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).