Skip to content

feat(testserver): add test_server() for in-memory server testing - #2470

Merged
schloerke merged 43 commits into
mainfrom
feat-shiny-simulate
Sep 11, 2026
Merged

schloerke merged 43 commits into
mainfrom
feat-shiny-simulate

Conversation

@karangattu

@karangattu karangattu commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Overview

Adds test_server() — the Python counterpart to R Shiny's testServer(). It runs an app's server function against a mock connection, so inputs can be set and outputs asserted in process: no browser, no network server, no subprocess.

from shiny.testserver import test_server


def test_app():
    with test_server("app.py") as ts:
        ts.set_inputs(a=1, b=2)
        assert ts.get_output("name") == "foo"

        ts.set_inputs(a=3, b=4)
        assert ts.get_output("name") == "bar"

Exported from shiny.testserver: test_server, test_server_async, TestServerSession, AsyncTestServerSession, TestServerScope, AsyncTestServerScope, TestServerValue, TestServerValues, DEFAULT_CLIENT_DATA.

It lives beside shiny.run and shiny.playwright as a capability package rather than under shiny.pytest, which is reserved for pytest integration (create_app_fixture, ScopeName) and raises ImportError without pytest installed. test_server needs no pytest — it imports only stdlib and shiny internals — so it stays usable from unittest, a script, or a REPL.

API

Naming what to test

app is the single positional, defaulting to app.py beside the test file — the same convention as the local_app fixture:

test_server()                 # app.py beside the test file
test_server("myapp.py")       # another file beside the test file
test_server(path_to_app)      # absolute Path, used as-is
test_server(my_mod_server)    # server function, or a shiny.App

A str, or a Path that is not already a file, resolves against the caller's directory rather than the working directory (pytest runs from the rootdir, so a bare relative path would otherwise be unusable). This mirrors create_app_fixture, including its rule that passing a str guarantees a path stays relative.

Express apps are module-level code, so they are loaded from a file like any other app.

Context manager only

with is the only way to run a session — start()/close() are private. Teardown is therefore not something a caller can skip, so SHINY_TESTMODE, sys.path, sys.modules, and the app object's patched server attribute are always restored, including when an assertion fails. Every member that needs a running session raises a RuntimeError naming the fix.

test_server_async() is the same thing for async tests; the sync version drives its own event loop and raises a pointed error if one is already running.

set_inputs takes only **kwargs. An id that is not a valid Python identifier — a module's namespaced "counter-n" — goes through an unpacked dictionary, which CPython allows: set_inputs(**{"counter-n": 7}). Both it and flush() return what they were called on, so a sequence of interactions can be one chain:

assert ts.set_inputs(name="Ada").set_inputs(n=10).get_output("doubled") == "20"

Values

get_input / get_output / get_export return a TestServerValue, which compares equal to the underlying value so the common assertion needs no unwrapping:

assert ts.get_output("name") == "foo"        # compares .value
assert ts.get_output("name").status == "ok"  # or go rich

status is ok, error, or silent — the last meaning the item never rendered because a dependency was unavailable.

A TestServerValue never pretends to hold a value it does not have:

  • value is MISSING, not None, unless status is "ok". A renderer can legitimately return None, so the two must be distinguishable. __post_init__ rejects any instance whose status, value, error, and traceback disagree. This is shiny.types.MISSING, not a second sentinel; MISSING_TYPE gained the __copy__/__deepcopy__ identity guard that requires (a deep-copied sentinel stops comparing equal) and a __repr__ naming itself.
  • Comparing a valueless item raises ValueError. Comparing presumes a value exists, so returning "not equal" would answer a different question than the test asked — and would let assert ts.get_output("txt") != "hi" pass for an output that never rendered. Comparing two TestServerValues still compares structurally and never raises.
  • An unknown name raises KeyError naming what is available, rather than returning a placeholder. There is no "missing" status: a TestServerValue always describes something real.
  • error and traceback travel together, both None when nothing raised. "" was a value pretending to be a traceback.

to_values() captures a TestServerValues snapshot of copies that outlives the block. dict(values) and dict(session) are the plain-data form, keyed on status — an item that produced nothing has no value key at all — and is JSON-serializable. TestServerValues.is_ok folds every output and export plus any fatal error into one answer; there is no per-item is_ok, since that would restate status == "ok".

Modules

A module's ids are namespaced, so they can be read as the session sees them:

ts.set_inputs(**{"counter-n": 7})
assert ts.get_output("counter-label") == "n=7"

…or through a scope, which is the test-side counterpart of the SessionProxy a module's server function receives — bare ids in, bare ids out:

with ts.make_scope("counter") as counter:
    counter.set_inputs(n=7)
    assert counter.get_output("label") == "n=7"

assert ts.get_output("counter-label") == "n=7"   # still visible on the session

make_scope, root_scope, and the public ns: ResolvedId mirror shiny.Session, and id resolution reuses ResolvedId, so the - separator and the rules for a legal id are shared with the rest of shiny rather than restated. Scopes nest (outer-inner). to_values() / dict() / is_ok / error are all scoped, so a scope reports exactly what a test of that module server on its own would: a sibling module's failure leaves is_ok true, while a fatal session error counts against every scope since it belongs to no namespace. A scope's __enter__/__exit__ are deliberate no-ops — the session owns the app's lifetime — and exist only so a module's assertions can be indented under the module they belong to.

Setting a session-wide .clientdata_* id through a scope raises rather than quietly building an id nothing reads; client data is read from the root in real Shiny too.

Client-side stand-ins

A browser reports things the server reads back: each output's size, the pixel ratio, the URL parts. Without them, render.plot raises a silent exception and produces nothing, and session.clientdata.url_pathname() never resolves — invisibly, with a passing is_ok.

The session sends defaults at startup, overridable per test:

with test_server(server, client_data={"output_width": 300}) as ts:
    ...

Keys are named after the readers on shiny.session.ClientData: an output_* key applies to every output, everything else is session-wide. None means the same as {}.

Changes outside shiny/testserver/

shiny/session/_session.py gains per-item error tracebacks. The output error handler called traceback.print_exc() and discarded the result, so a failed output reported a message with no origin. It now passes the formatted traceback to set_error(), which stores it in a new test_tracebacks dict only when _record_test_values is on.

Deliberately kept out of both the outbound error message and _build_test_snapshot(): the former is sent to the browser and the latter is served over HTTP at /session/{id}/dataobj/shinytest, and neither should carry a stack trace — that is what sanitize_errors exists to prevent. test_server reads it in process.

_build_test_snapshot() was also split out of the HTTP handler so the session can build a snapshot without going over the wire.

shiny/types.py: MISSING_TYPE gains __copy__/__deepcopy__ returning self, and a __repr__ of "MISSING" / "DEPRECATED". A sentinel is identified by identity, so copy.deepcopy minting a second one is a bug wherever it happens — dataclasses.asdict/astuple deep-copy their fields.

Testing

  • tests/pytest/test_test_server.py — target resolution, Express apps, the context-manager contract, repeated and chained set_inputs, value statuses, equality and repr, construction-time validation, dict conversion, per-item tracebacks, client data, Core and Express modules, and scopes.
  • test_test_server_flavors_present_the_same_api guards against drift across TestServerSession / AsyncTestServerSession / TestServerScope / AsyncTestServerScope: the public name sets must match, only set_inputs/flush may differ (in being awaitable), the read half must take the same arguments, dict() must yield the same keys, and each flavor must have the context-manager protocol it advertises.
  • tests/pytest/test_types.py — the MISSING/DEPRECATED sentinels survive copying and repr by name.
  • tests/pytest/test_playwright_simulation_equivalents.py — the same assertions as existing Playwright tests, run in memory.
  • tests/pytest/test_controller_documentation.py — new parametrized documentation-coverage test. test_quartodoc_configs only rejected duplicates and the existing completeness test was scoped to shiny.playwright.controller, so an export could ship with no published docs. test_all_testing_exports_are_documented closes that for both shiny.pytest and shiny.testserver.

Full unit suite passes. make format check-lint check-types, make check-pyright, and make docs are all clean.

Note for review

Every new name is registered in docs/_quartodoc-testing.yml under a new "Test server" section, with @no_example() on the two functions to match create_app_fixture.

@karangattu karangattu changed the title feat(cli): Add shiny simulate command and Python simulation testing API feat(testing): add headless simulate function and shiny simulate CLI Aug 25, 2026
@karangattu
karangattu force-pushed the feat-shiny-simulate branch from c8d80c3 to 2a9b50e Compare August 25, 2026 06:32
@karangattu karangattu changed the title feat(testing): add headless simulate function and shiny simulate CLI feat(testing): add headless test_server for fast reactive unit testing Aug 28, 2026
@karangattu karangattu changed the title feat(testing): add headless test_server for fast reactive unit testing feat(testserver): add interactive test_server() test utility matching R testServer Aug 28, 2026
karangattu and others added 4 commits August 28, 2026 14:30
TestServerSession wrapped AsyncTestServerSession in a background daemon
thread with its own event loop, marshalling every call across it with
run_coroutine_threadsafe. That ran user server functions, render
functions and reactive effects on a non-main thread -- unlike a real
app -- and pushed exceptions across a thread boundary, which forced the
`type(e).__name__ == "TimeoutError"` string sniffing in set_inputs()
and flush().

Nothing needs to make progress between calls, so drive the same loop on
the calling thread with run_until_complete() instead. The session task
stays alive across calls; it just resumes whenever the loop runs.
Timeouts now come from the asyncio.wait_for() calls already present in
the async layer.

Calling the sync test_server() from inside a running event loop now
raises a RuntimeError pointing at test_server_async() rather than
asyncio's opaque "loop is already running".

Also collapses the duplicated read-only properties onto a single
_result() helper.
@schloerke schloerke self-assigned this Sep 11, 2026
Add numpydoc docstrings to test_server(), test_server_async(),
TestServerSession, AsyncTestServerSession, TestServerResult, and their
public methods and properties.

Covers the three calling conventions (context manager, callback,
single-shot), which of app/code/file_path to use, what errors and
exports keys mean, when each method raises TimeoutError, and why an
async test needs test_server_async() rather than test_server().
… self

Inputs are now only set through set_inputs(), which returns the session
so calls can be chained:

    assert test_server(app).set_inputs(x=10).outputs["doubled"] == "20"

Chaining outside a `with` block would otherwise leave a session running
and leak the global state start() mutates (SHINY_TESTMODE, sys.path,
sys.modules, the app's patched server attribute), so reading a result
attribute outside a `with` block now snapshots and closes the session --
extending the single-shot behaviour that previously only applied to a
never-started session. __exit__ likewise caches the final result before
closing, so reads after a `with` block no longer silently re-run the app.
Three changes:

1. `with` is now the only way to run a test session. `start()`/`close()`
   are private, the lazy single-shot path and the `Mapping` interface on
   the session are gone, and every member that needs a started session
   raises a RuntimeError naming the fix. Cleanup is no longer something
   a caller can skip, so SHINY_TESTMODE, sys.path, sys.modules and the
   app's patched server attribute are always restored.

2. Dropped the `fn=` callback parameter and its four overloads.
   `with` covers it, and it was a second lifecycle to keep correct.

3. Registered all five `shiny.pytest` test-server exports in
   docs/_quartodoc-testing.yml under a new "Test server" section, with
   @no_example() on the two functions to match pytest.create_app_fixture.

Nothing caught the missing docs because the existing coverage has a
gap: test_quartodoc_configs only rejects duplicates, and
test_all_controllers_are_documented is scoped to
shiny.playwright.controller. Added test_all_pytest_exports_are_documented
to close it for shiny.pytest.__all__.

Also removes the README's `shiny simulate` bullet, which documented a
CLI command that does not exist.
Existing tests called set_inputs() twice, but only asserted that a
changed input produced a changed output. Cover the semantics that a
second call actually depends on:

- inputs merge across calls; a later call updates only the ids it names
- re-sending an unchanged value, setting an id no output reads, calling
  set_inputs() with no arguments, and calling flush() repeatedly all
  still flush. None of these invalidates anything, so if the flush were
  skipped the call would block until timeout_secs rather than fail fast
- falsy values (0) are values, not "unset"
- errors clear once a later flush succeeds, rather than accumulating
- the async session repeats the same way

Verified each assertion discriminates: dropping the merge makes
input.b() unset, and accumulating errors leaves the recovered flush
reporting a stale one.
`app` now covers every way of naming a target, and defaults to "app.py"
beside the test file, matching the `local_app` fixture:

    test_server()                 # app.py beside the test file
    test_server("myapp.py")       # another file beside the test file
    test_server(path_to_app)      # an absolute Path, used as-is
    test_server(my_mod_server)    # a server function, or a shiny.App

A str, or a Path that is not already a file, resolves against the
directory of the calling file rather than the working directory -- pytest
runs from the rootdir, so a bare relative path was previously unusable.
This mirrors create_app_fixture, including "pass a str to be sure a path
stays relative".

Drops `file_path=`, which was documented as equivalent to passing the
path as `app` and whose branch in _start_impl was a byte-for-byte
duplicate of the str/Path branch. Both now route through one
_load_app_path() helper, and an unusable `app` raises TypeError instead
of falling through to a confusing "No Shiny 'App' instance found".
…r_async

Move the usage code blocks out of the description prose into a numpydoc
`Examples` section, listing all four ways to name a target plus a full
test function. Matches `create_app_fixture`, which likewise pairs
`@no_example()` with a hand-written `Examples` section.
Inline Express source as a string was the only thing code= uniquely
offered, and a copied Express snippet is not what anyone wants to test --
Express apps are module-level code that belongs in a file. Pointing `app`
at that file covers it.

Removing code= also removes: the temp-directory lifecycle (_temp_dir plus
its cleanup), a branch in _start_impl, and a silent precedence bug where
test_server(path, code=...) loaded the path and discarded code entirely
(while also skipping caller-relative resolution of the path).

_start_impl now validates up front -- a missing app raises ValueError
instead of falling through the elif chain.

Replaces the code= Express test with two file-based ones, covering an
Express app by explicit path and via the default app.py resolution. Both
assert the behaviour I had wrong first time: with no browser to report a
slider's value, input.n() raises a silent exception, so the dependent
output renders nothing and the session still reports success.
Session drops the bulk dict properties (outputs/exports/errors), which
returned copies and so silently discarded writes like ts.outputs[k] = v.
Per-name reads are now uniformly verbs -- get_output/get_export and a new
get_error -- so nothing on the session looks assignable and set_inputs no
longer sits oddly beside a property.

Also dropped: elapsed_ms (benchmark trivia, never worth asserting) and
to_dict(). dict(session) replaces to_dict(), via keys()/__getitem__ --
the session is not a Mapping, so no values()/items() appear to collide
with a value accessor.

TestServerResult -> TestServerValues, now a frozen dataclass with no
Mapping base: dataclasses.asdict() covers what __getitem__/__iter__/
__len__/get did. to_result() -> to_values().

traceback survives, but only on TestServerValues. A fatal error is
recorded rather than re-raised, so its traceback is the only record of
where it came from -- but it is a debugging aid, not something to assert
on, so it does not belong on the session's hot surface.

Session surface: 13 public members -> 9.
get_input/get_output/get_export now return a TestServerValue carrying
name, kind, status, value, error, and traceback, instead of a bare value.

TestServerValue.__eq__ compares against the underlying value, so the
common assertion is unchanged:

    assert ts.get_output("name") == "foo"

Comparing two TestServerValues compares every field. Equality against
arbitrary values makes instances unhashable, which is documented.

status distinguishes a state the old API flattened into a bare None:

  ok      - produced a value
  error   - raised; see error/traceback
  silent  - never rendered, because a dependency was unavailable
  missing - no such input, output, or export

"silent" is what a render.plot reports without client width/height, and
what any output reading an unset input reports. Previously both looked
like a successfully rendered None next to a passing success.

Inputs are now exposed too -- _build_test_snapshot() already returned an
input block that _refresh_snapshots discarded.

Per-item tracebacks required a core change: the output error handler in
_session.py called traceback.print_exc() and discarded the result. It now
passes the formatted traceback to set_error(), which stores it in
test_tracebacks only when _record_test_values is on. Deliberately kept
out of the outbound error message and out of _build_test_snapshot(): the
former goes to the browser and the latter is served over HTTP, and
neither should carry a stack trace.
…izes

Two related gaps, both of which let a broken test pass.

1. A TestServerValue with status silent/missing/error carries value=None,
   so it compared equal to None. An output that never rendered, and an id
   with a typo in it, both quietly satisfied `== None` -- and `in [None]`.
   Equality against a non-TestServerValue now returns False unless status
   is "ok". An output that genuinely rendered None still matches, because
   its status is "ok".

   Chose this over raising from __eq__: __eq__ raising breaks the Python
   contract and would explode inside `in`, dict lookups and list
   comparisons, far from the assertion that caused it. The repr already
   names the status, so a failed comparison says "status='silent'" rather
   than just showing a mismatch.

2. render.plot reads the size a browser reports, so with no browser it
   raised a silent exception and produced nothing -- every plot in every
   app, invisibly. The startup pass that already unhides each output now
   also sends stand-in width/height plus .clientdata_pixelratio, so sized
   renderers work out of the box. A test that cares about the size can
   still set it via set_inputs, which re-renders.

Together these turn "silent" back into a signal: it now means a genuine
unmet dependency, such as an output reading an input that is not set,
rather than the everyday case of having no browser attached.
The client-side values the session fabricates at startup were hardcoded.
They are now a client_data= parameter on test_server, test_server_async,
and both session constructors, merged over a DEFAULT_CLIENT_DATA map.

Keys are named after the readers on shiny.session.ClientData, so the
vocabulary is one people already have: an output_* key applies to every
output (output_width -> each .clientdata_output_<id>_width) and every
other key is session-wide. That rule mirrors ClientData's own split
between output_width(id) and pixelratio().

Widened the defaults while making them configurable: url_protocol,
url_hostname, url_port, url_pathname, url_search, url_hash and
url_hash_initial join the sizing keys. session.clientdata.url_pathname()
and friends previously read an unset input, so any output touching them
went silent for the same reason plots did.
Already the behaviour -- `dict(client_data or {})` collapses None -- but
it was implicit. A parametrized test now covers both, and the docstrings
say so, so neither can regress into "None suppresses the defaults".
@schloerke schloerke changed the title feat(testserver): add interactive test_server() test utility matching R testServer feat(testserver): add test_server() for in-memory server testing Sep 11, 2026
A TestServerValue is frozen, so an instance built in a bad state stays in
one -- there is no later update to fix it. "missing" was exactly that: a
value object describing something that does not exist.

get_input/get_output/get_export now raise KeyError naming what is
actually available, instead of fabricating an absent item. That also
closes a hole the equality fix left open: a valueless item compares
unequal to everything, so `assert ts.get_output("typo") != "hi"` quietly
passed. A name that is not there is a mistake in the test, and now fails
at the lookup rather than surviving into an assertion.

__post_init__ rejects the remaining incoherent combinations: an unknown
status, status 'error' without an error, and an error on any other
status. status is now required, since there is no sensible default.
Three changes to the same idea: a TestServerValue never pretends to hold
a value it does not have.

1. __eq__ against a raw value raises ValueError unless status is "ok".
   Comparing presumes a value exists, so answering "not equal" answers a
   different question than the test asked -- and let
   `assert ts.get_output("txt") != "hi"` pass for an output that never
   rendered, or that raised. Comparing two TestServerValues still
   compares structurally and never raises.

2. value is MISSING, not None, unless status is "ok". A renderer can
   legitimately return None, so the two had to be distinguishable --
   that is what lets __post_init__ reject a status and value that
   disagree (ok without a value, or a value on any other status).
   MissingType defines __copy__/__deepcopy__ so dataclasses.astuple does
   not mint a second sentinel and break structural comparison.

3. dict(value) keys on status rather than inspecting value: an item that
   produced nothing has no "value" key at all, and only an error carries
   "error"/"traceback". dict(session) now converts the three value blocks
   all the way down, so it is plain data -- and JSON-serializable, which
   the raw dataclasses.asdict form is not.
Examples now show the assertion patterns rather than just the call:
setting several inputs at once, reading outputs and exports, inputs
persisting across interactions, and inspecting .status/.error/.traceback
when the assertion is not about equality.

Adds examples for three things that are not guessable from the
signature: a Shiny module, reached through its namespaced ids
("counter-n"); wrapping the session in a pytest fixture, with a note to
keep it function-scoped because a session holds the inputs set so far;
and capturing to_values()/dict() to assert after the block closes.

A new Notes callout explains client data -- what a browser would report,
why its absence is silent rather than an error, and that
DEFAULT_CLIENT_DATA is sent at startup -- with examples of overriding it
per session and per output.

DEFAULT_CLIENT_DATA is now exported and documented so the defaults can
be inspected rather than only described.

Each new example is backed by a test, so the docs cannot drift from
behaviour: the module id form, the fixture pattern (including that it
isolates tests), and values outliving the block.
A captured snapshot converts the same way the live session does, so
`dict(values)` and `dict(session)` produce identical plain data, keyed on
each item's status and JSON-serializable.

The conversion now has one definition: TestServerValues owns keys() and
__getitem__, and both sessions' __getitem__ delegate to
`self.to_values()[key]` rather than repeating the walk over the three
value blocks.
shiny.pytest re-exported the testserver names but not shiny.run's, so
`from shiny.pytest import test_server` worked while
`from shiny.pytest import run_shiny_app` did not. That is not a design,
it is an aggregate namespace built halfway.

Resolved by not aggregating, which is what the codebase already does:
shiny.run and shiny.playwright are capability packages reached directly,
create_app_fixture's own docstring tells users to import ShinyAppProc
`from shiny.run`, and the 0.10.0 changelog describes shiny.pytest as
holding "pytest test fixtures". testserver needs no pytest at all -- it
imports only stdlib and shiny internals -- so routing it through a module
that raises ImportError without pytest was backwards.

The alternative, aggregating shiny.run and testserver into shiny.pytest,
has no stopping rule (why not the playwright controllers?) and would
document run_shiny_app twice, once per import path.

test_all_pytest_exports_are_documented becomes
test_all_testing_exports_are_documented, parametrized over shiny.pytest
and shiny.testserver, so a new export in either is caught.

Public path is now `from shiny.testserver import test_server`.
CI failed on test_test_server_set_inputs_timeout with DID NOT RAISE. The
test was racy, and the race is inherent to running the session on the
calling thread: a reactive effect that blocks without awaiting holds the
event loop, so asyncio.wait_for cannot fire while it runs. Whether the
timeout or the completed flush won was down to scheduling -- it won
locally and lost on CI's Python 3.10.

set_inputs() and startup now check the clock alongside wait_for, so an
overrun is reported whether the wait timed out or the flush merely
finished late. Blocking code still cannot be interrupted, which
timeout_secs now documents, but it can no longer silently exceed its
budget.

Splits the test in two -- a blocking effect and an awaiting one -- so
both paths into the timeout are covered, and asserts the message rather
than just the type.
Comment thread docs/_quartodoc-testing.yml Outdated
Comment thread docs/_quartodoc-testing.yml Outdated
Comment thread shiny/testserver/_test_server.py Outdated
Comment thread shiny/testserver/_test_server.py Outdated
Comment thread shiny/testserver/_test_server.py Outdated
Comment thread shiny/testserver/_test_server.py Outdated
An app with an input named `inputs` could not set it by keyword:
`set_inputs(inputs=5)` bound the dictionary parameter instead, and
failed with `'int' object has no attribute 'items'`. Marking that
parameter positional-only frees the name for **kwargs, so any input id
that is a valid identifier now works as one.

The dictionary form itself has to stay: input ids are not always valid
identifiers. A module namespaces its ids ("counter-n") and client data
keys start with a dot (".clientdata_output_x_width"), neither of which
can be passed as a keyword argument.
Corrects the previous commit, which claimed a module's namespaced ids and
client data keys "cannot be passed as a keyword argument". They can:
CPython does not require identifier keys when unpacking into **kwargs, so
`set_inputs(**{"counter-n": 7})` has always worked. That was the only
justification for the separate dictionary parameter, so it is gone --
along with the merge loop and the rule that kwargs won over same-named
dictionary keys, which nobody needs to learn now there is one channel.

`self` stays positional-only, so every id is reachable, including "self"
and "kwargs".

Two things the tests caught while converting:

- Shiny rejects "first-name" as an id ("only letters, numbers, and
  underscore are permitted"), so the docstring examples using it were
  invalid. Hyphens only appear via module namespacing, which resolves
  them internally. Examples now use a module for that case.
- Client data is filtered out of the input snapshot, so its effect is
  asserted through session.clientdata rather than get_input().
* Reuse `shiny.types.MISSING` rather than minting a second sentinel, so
  `shiny.testserver` no longer re-exports one. `MISSING_TYPE` gains the
  `__copy__`/`__deepcopy__` identity guard it needed for that (a deep-copied
  sentinel stops comparing equal to `MISSING`) and a `__repr__` naming itself,
  which also tidies up how `DEPRECATED` prints.
* Rename `success` to `is_ok` on `TestServerValue`, `TestServerValues`,
  `TestServerSession`, and `AsyncTestServerSession`. `assert tsv.is_ok` says
  what it checks; `assert ts.success` did not. `is_ok` stays exactly
  `status == "ok"` -- an output that rendered `None` is still ok, since `None`
  is a value a renderer can legitimately produce and `MISSING` is how "no
  value" is spelled.
* `traceback` is now `Optional[str]`, `None` when there is no `error`, on both
  `TestServerValue` and `TestServerValues`. `""` was a value pretending to be
  a traceback; `error` and `traceback` now travel together, and
  `__post_init__` rejects a traceback without an error.
* Move `DEFAULT_CLIENT_DATA` to the end of the quartodoc section.
* Fix `set_inputs`' docstring, which kept the removed `inputs` parameter and
  had lost its indentation.

Also adds an Express module test and the matching docstring example, so both
module flavors are covered.
It was a one-line restatement of `status == "ok"`, so it exposed the same
implementation detail twice. `TestServerValues.is_ok` stays -- that one folds
every output and export plus any fatal error into a single answer, which is
not something the caller can read off one field.
Mirrors `shiny.Session.make_scope`, and `TestServerScope` /
`AsyncTestServerScope` are the test-side counterpart of `SessionProxy`: the
scope carries a `ResolvedId` and nothing else, ids go in and come out bare,
and the session underneath keeps the namespaced ones.

    with test_server(app_server) as ts:
        counter = ts.make_scope("counter")
        counter.set_inputs(n=7)
        assert counter.get_output("label") == "n=7"
        assert ts.get_output("counter-label") == "n=7"   # still visible

`to_values()` / `dict()` / `is_ok` / `error` are all scoped, so a scope reports
exactly what a test of that module server on its own would. A sibling module's
failure leaves `is_ok` true; a *fatal* session error counts against every scope,
since it belongs to no namespace. Scopes nest (`outer-inner`), and
`root_scope()` gets back to the session.

Setting a session-wide `.clientdata_*` id through a scope raises rather than
silently building an id nothing reads -- client data is read from the root in
real Shiny too.

Id resolution and validation reuse `ResolvedId`, so the separator and the rules
for a legal id are shared with the rest of shiny rather than restated here.
`with ts.make_scope("counter") as counter:` and the `async with` equivalent.
Both enter and exit are deliberate no-ops -- a scope owns nothing, and the
session it views outlives it -- so this is purely so a module's assertions can
be indented under the module they belong to, and so a scope reads the same way
as the session it came from.
Setting inputs is an action, not a value. Returning `self` only ever read well
in the sync flavor -- the async one needs `(await ts.set_inputs(...)).get_output(...)`
-- so the chaining it enabled was never symmetric, and `flush()` already
returned nothing.

Also adds a test asserting the four flavors present one API: the public name
sets match, only `set_inputs`/`flush` differ (in being awaitable), the shared
read methods take the same arguments, `dict()` yields the same keys, and each
flavor has the context-manager protocol its tests use. The one deliberate
difference -- scopes have `root_scope()` -- is spelled out rather than skipped.
Reverts the previous commit's `-> None` and goes further: `flush()` now returns
self too, so a whole sequence of interactions can be one chain.

    assert ts.set_inputs(name="Ada").set_inputs(n=10).get_output("doubled") == "20"

`root_scope()` moves onto `TestServerSession` and `AsyncTestServerSession`,
returning self, exactly as `AppSession.root_scope()` does. All four classes now
present the identical public API, so the drift test's scope-only exception is
gone and it checks that `set_inputs`/`flush` return their own type everywhere.
@schloerke
schloerke marked this pull request as ready for review September 11, 2026 19:45
…imeout knob

`test_test_server_plot_renders_without_a_browser` timed out at 5s during
session initialization on Windows / py3.10. `pytest.importorskip` imports
pyplot, but matplotlib builds its font cache on the first *draw* -- which
happened inside the session's initial flush, so a cold runner spent that time
against the session's budget. The test now renders a throwaway figure first, so
the timeout measures shiny rather than matplotlib's startup.

That failure also showed the timeout errors never mention `timeout_secs=`, the
one thing a reader needs. Every one of them now says so.
… error

Teardown only forgets modules loaded from the app's own directory, so
lazily imported libraries (matplotlib, pandas, shiny submodules) are not
re-executed by the next test. set_inputs() on an ended session raises a
RuntimeError naming the fatal error instead of waiting out the flush
timeout.
@schloerke
schloerke enabled auto-merge (squash) September 11, 2026 20:15
@schloerke
schloerke merged commit 667d01c into main Sep 11, 2026
176 checks passed
@schloerke
schloerke deleted the feat-shiny-simulate branch September 11, 2026 20:22
schloerke added a commit to posit-dev/py-shiny-site that referenced this pull request Sep 13, 2026
* build: bump py-shiny to 667d01ca for shiny.testserver

Brings in test_server() (posit-dev/py-shiny#2470) so the testing API
reference generates shiny.testserver pages. Regenerates the chat page's
relevant-functions signature, which drifted with the new ui.chat_ui().

* test: opt page_html out of the component-page coverage check

The submodule bump adds `ui.page_html`, a new public export with no
doc page, which fails `test_every_ui_export_has_a_page`. It belongs
with the other page_* functions awaiting a layouts/ page.

* build: bump py-shiny to d64f1e1c (local_server, status rename)

Picks up three testserver commits on py-shiny main:

- #2494: the old "silent" status (meaning "never rendered") is renamed to
  "never-rendered", and "silent" is re-purposed for an output whose latest
  render produced nothing because a req() failed.
- #2495: the built-in `local_server` pytest fixture; TestServerScope drops
  its context-manager methods.
- #2491: the bundled shiny-for-python skill documents test_server().

Nothing else changed on py-shiny main since 667d01ca, and no deprecations
were added, so the components smoke sweep is unaffected. Regenerating
relevant-functions (strict) and the shinylive links against this pointer
produces zero drift. The generated api/testing/testserver.* pages now match
the prose in docs/unit-testing.qmd: four value statuses, no scope __enter__.

* build: bump py-shiny to a0a14eeb (#2496 timeout-hint wording)

The only commit on py-shiny main past d64f1e1c. Docstring/test-only: the
flush-timeout hint now names reactive cycles as the other cause of a flush
that never finishes. No API change, no deprecations; examples, quartodoc,
relevant-functions (strict) and shinylive links are all clean against it.

* docs: document in-memory server testing (test_server / local_server) (#462)

* docs: add a Server Testing page for test_server()

Documents shiny.testserver.test_server(): what it accepts, set_inputs()
and the value readers, the ok/error/silent statuses, modules and scopes,
client data, fixtures, snapshots, and test_server_async().

Reframes unit-testing.qmd around three levels of testing rather than two
-- the claim that an app's reactive logic can't be tested without a
browser is no longer true -- and cross-links from end-to-end-testing.qmd
and test-mode.qmd.

Closes #461

* test: opt page_html out of the component-page coverage check

The submodule bump adds `ui.page_html`, a new public export with no
doc page, which fails `test_every_ui_export_has_a_page`. It belongs
with the other page_* functions awaiting a layouts/ page.

* docs: fold Server Testing into Unit testing; align with local_server

test_server() is unit testing: in-process, no browser, an ordinary pytest
test. Keeping it on its own page invented a third "level" between unit and
end-to-end that readers do not think in, and with the `local_server` fixture
the in-memory approach is one test argument away. Merge docs/test-server.qmd
into docs/unit-testing.qmd as a "Testing the server function" section, and
retarget the inbound links from end-to-end-testing.qmd and test-mode.qmd.
The page never shipped, so there is no URL to redirect.

Align with posit-dev/py-shiny#2495 (head 1f27481a):

- `local_server` is the default; direct `test_server()` is reserved for what
  the fixture cannot express (a server function / App object, client_data=,
  timeout_secs=, values that outlive the session).
- TestServerScope is no longer a context manager; the two-counters example
  now takes two scopes instead of two `with` blocks.
- Value status is a four-way split: "silent" is scoped to "latest render
  produced nothing because a req() failed", and "never-rendered" covers an
  output that has not run at all (hidden, hence suspended). Verified against
  the PR head: req(False) -> "silent", client_data={"output_hidden": True} ->
  "never-rendered".

Every example on the page was extracted and run against the PR head: 15
tests pass (7 doubled/greeting incl. an indirect-parametrized other_app.py
and an async test, 4 module/scope, 3 export/plot client data, 1 penguins).
The submodule pointer is unchanged (#2495 is unmerged), so the generated
api/testing pages lag the prose until the next bump.

Refs #461

* docs: wording tweaks on the unit-testing page

"fresh app" -> "fresh instance" for the local_server scoping note, and drop
the backticks from the callout heading so the rendered label reads
"Important: Comparing a non-ok value raises" without inline code in a title.

* docs: name reactive cycles in the timeout_secs guidance

Mirrors posit-dev/py-shiny#2496: raising timeout_secs is the right move for
a slow start and exactly the wrong one for a reactive cycle, which never
drains no matter how long the flush is allowed to run.

* docs: say that local_server enables test mode itself

The bullet said "no test_mode=True" without saying why that works: the
session turns test mode on (SHINY_TESTMODE=1 and App._test_mode=True in
_start_impl), which is what makes get_export() read anything at all.

* docs: introduce the test snapshot where it is first consumed

The three testing pages cross-referenced each other because the shared
concept -- the server snapshot of inputs, outputs, and exports -- lived on
the Test mode page, which unit-testing had to forward-reference for
get_export() while test-mode back-referenced end-to-end for its examples.

Move the concept to its first and simplest consumer: unit-testing's server
section now explains the three blocks, adds an "Exported values" subsection
that shows export_test_values() on the running example (a reactive.calc
exported alongside the output that renders it), and points forward to Test
mode as "the same snapshot, from a running app". Test mode is refocused as
the browser-side reader: its intro back-links the in-process readers, the
exporting section recaps and links back instead of introducing, the
redundant "reading exports without a browser" callout is dropped, and the
scrubbing section notes preprocessors apply to local_server values too.
Page order and URLs are unchanged.

Two facts on the pages were run, not just read from code (a0a14eeb):

- Snapshot preprocessors apply in memory to both outputs and inputs:
  stamp.snapshot_preprocess() and snapshot_preprocess_input() both show
  their scrubbed values through local_server.get_output()/get_input().
- UI defaults are not sent in memory: with ui.input_numeric("n", "N", 10),
  get_input("n") raises KeyError and outputs reading it are "silent" until
  set_inputs(n=...). Added as a bullet under local_server's behaviour.

The exports example (calc exported, output rendering it) passes against
the pinned py-shiny: get_export("double") == 60, get_output("doubled") == "60".

* build: re-pin py-shiny to v1.8.0 and shinylive to 0.8.12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants