feat(testserver): add test_server() for in-memory server testing - #2470
Merged
Merged
Conversation
karangattu
force-pushed
the
feat-shiny-simulate
branch
from
August 25, 2026 06:32
c8d80c3 to
2a9b50e
Compare
…ess, and remove sleeps
…s subscript error
…ation, and async callbacks
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.
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".
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.
schloerke
reviewed
Sep 11, 2026
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
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
enabled auto-merge (squash)
September 11, 2026 20:15
This was referenced Sep 11, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Adds
test_server()— the Python counterpart to R Shiny'stestServer(). 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.Exported from
shiny.testserver:test_server,test_server_async,TestServerSession,AsyncTestServerSession,TestServerScope,AsyncTestServerScope,TestServerValue,TestServerValues,DEFAULT_CLIENT_DATA.It lives beside
shiny.runandshiny.playwrightas a capability package rather than undershiny.pytest, which is reserved for pytest integration (create_app_fixture,ScopeName) and raisesImportErrorwithout pytest installed.test_serverneeds no pytest — it imports only stdlib and shiny internals — so it stays usable fromunittest, a script, or a REPL.API
Naming what to test
appis the single positional, defaulting toapp.pybeside the test file — the same convention as thelocal_appfixture:A
str, or aPaththat 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 mirrorscreate_app_fixture, including its rule that passing astrguarantees a path stays relative.Express apps are module-level code, so they are loaded from a file like any other app.
Context manager only
withis the only way to run a session —start()/close()are private. Teardown is therefore not something a caller can skip, soSHINY_TESTMODE,sys.path,sys.modules, and the app object's patchedserverattribute are always restored, including when an assertion fails. Every member that needs a running session raises aRuntimeErrornaming the fix.test_server_async()is the same thing forasynctests; the sync version drives its own event loop and raises a pointed error if one is already running.set_inputstakes 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 andflush()return what they were called on, so a sequence of interactions can be one chain:Values
get_input/get_output/get_exportreturn aTestServerValue, which compares equal to the underlying value so the common assertion needs no unwrapping:statusisok,error, orsilent— the last meaning the item never rendered because a dependency was unavailable.A
TestServerValuenever pretends to hold a value it does not have:valueisMISSING, notNone, unlessstatusis"ok". A renderer can legitimately returnNone, so the two must be distinguishable.__post_init__rejects any instance whosestatus,value,error, andtracebackdisagree. This isshiny.types.MISSING, not a second sentinel;MISSING_TYPEgained the__copy__/__deepcopy__identity guard that requires (a deep-copied sentinel stops comparing equal) and a__repr__naming itself.ValueError. Comparing presumes a value exists, so returning "not equal" would answer a different question than the test asked — and would letassert ts.get_output("txt") != "hi"pass for an output that never rendered. Comparing twoTestServerValues still compares structurally and never raises.KeyErrornaming what is available, rather than returning a placeholder. There is no "missing" status: aTestServerValuealways describes something real.errorandtracebacktravel together, bothNonewhen nothing raised.""was a value pretending to be a traceback.to_values()captures aTestServerValuessnapshot of copies that outlives the block.dict(values)anddict(session)are the plain-data form, keyed onstatus— an item that produced nothing has novaluekey at all — and is JSON-serializable.TestServerValues.is_okfolds every output and export plus any fatal error into one answer; there is no per-itemis_ok, since that would restatestatus == "ok".Modules
A module's ids are namespaced, so they can be read as the session sees them:
…or through a scope, which is the test-side counterpart of the
SessionProxya module's server function receives — bare ids in, bare ids out:make_scope,root_scope, and the publicns: ResolvedIdmirrorshiny.Session, and id resolution reusesResolvedId, 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/errorare all scoped, so a scope reports exactly what a test of that module server on its own would: a sibling module's failure leavesis_oktrue, 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.plotraises a silent exception and produces nothing, andsession.clientdata.url_pathname()never resolves — invisibly, with a passingis_ok.The session sends defaults at startup, overridable per test:
Keys are named after the readers on
shiny.session.ClientData: anoutput_*key applies to every output, everything else is session-wide.Nonemeans the same as{}.Changes outside
shiny/testserver/shiny/session/_session.pygains per-item error tracebacks. The output error handler calledtraceback.print_exc()and discarded the result, so a failed output reported a message with no origin. It now passes the formatted traceback toset_error(), which stores it in a newtest_tracebacksdict only when_record_test_valuesis 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 whatsanitize_errorsexists to prevent.test_serverreads 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_TYPEgains__copy__/__deepcopy__returningself, and a__repr__of"MISSING"/"DEPRECATED". A sentinel is identified by identity, socopy.deepcopyminting a second one is a bug wherever it happens —dataclasses.asdict/astupledeep-copy their fields.Testing
tests/pytest/test_test_server.py— target resolution, Express apps, the context-manager contract, repeated and chainedset_inputs, value statuses, equality andrepr, construction-time validation, dict conversion, per-item tracebacks, client data, Core and Express modules, and scopes.test_test_server_flavors_present_the_same_apiguards against drift acrossTestServerSession/AsyncTestServerSession/TestServerScope/AsyncTestServerScope: the public name sets must match, onlyset_inputs/flushmay 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— theMISSING/DEPRECATEDsentinels 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_configsonly rejected duplicates and the existing completeness test was scoped toshiny.playwright.controller, so an export could ship with no published docs.test_all_testing_exports_are_documentedcloses that for bothshiny.pytestandshiny.testserver.Full unit suite passes.
make format check-lint check-types,make check-pyright, andmake docsare all clean.Note for review
Every new name is registered in
docs/_quartodoc-testing.ymlunder a new "Test server" section, with@no_example()on the two functions to matchcreate_app_fixture.