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:

Enabling test mode

There are two ways to enable test mode:

  • Pass test_mode=True when constructing your app: App(app_ui, server, test_mode=True)
  • Set the SHINY_TESTMODE=1 environment 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.
  • AppTestValues reads the server’s snapshot — the Python values your server function currently holds: input.n() as the int 50, 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; with match="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"] == 60

Expecting 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/False isn’t descriptive enough, the function can raise AssertionError with 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 method input.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).