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:

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. (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.
  • 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.

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).