Skip to content

test: drive servers in memory with test_server() / testServer() - #304

Merged
schloerke merged 4 commits into
mainfrom
schloerke/py-shiny-2470-testing
Sep 11, 2026
Merged

schloerke merged 4 commits into
mainfrom
schloerke/py-shiny-2470-testing

Conversation

@schloerke

Copy link
Copy Markdown
Collaborator

py-shiny#2470 added shiny.testserver.test_server(), the Python counterpart to R's testServer(): it runs an app's server against a mock connection, no browser and no subprocess.

That suits shinyreact unusually well. A ui.tsx server contains only reactive computation, so "input X produces output Y" is the server, and the JSON a test asserts is exactly what useShinyOutputValue() receives:

with test_server(APP) as ts:
    ts.set_inputs(bins=9)
    assert ts.get_output("dist_caption") == "272 eruptions in 9 bins"

Package tests

  • pkg-py/tests/test_in_memory_server.py — three tests, reusing the Playwright fixture apps as-is:

    • reactive_output publishing through a real session. test_reactive_output.py only reaches Renderer.transform(), so nothing pinned the wire path.
    • module namespace isolation via ts.make_scope("a") — the in-memory twin of playwright/test_module_namespaces.py, which spends a browser on a purely server-side claim.
    • output error statuses and messages — the server half of playwright/test_output_error.py.

    It needs no tests-e2e extras, so it belongs in the unit suite rather than the Playwright subtree.

  • pkg-r/tests/testthat/test-render.R — the R mirrors of the last two, cross-referenced by name, with the divergence stated where it exists: R's testServer() raises (expect_error(output$answer, class = "shiny.silent.error")) where Python's reports .status.

Example tests

One Python test file per example with a server (01, 02, 05, 06, 07, 08, 10), plus R counterparts for the two that ship an app.R — 07-plotly had no R test directory at all, so it gains tests/testthat.R too.

Two FEATURES.md leaves turned out to be wrong, and are now corrected and pinned: 05-temperature and 08-input-handler both claimed input.x() is None before the client's first message, so the server returns None / "—". It raises a silent exception — those branches are unreachable from a real client, and the outputs never render. That is the kind of claim only a test that drives the app can falsify, which is the argument for this whole PR in one leaf.

(test) markers added across seven example trees for what these pin: wire shapes, zone thresholds, pluralization, the two-step event handling, the AsIs wrapping that makes a one-bin R result a JSON array, and the plotly payload's plotly-main-but-not-plotly-binding dependency split.

examples/01-hello/tests/test_faithful.py opened with "app.py calls set_react_page() at module scope, so the two outputs' logic cannot be unit tested". No longer true anywhere in the tree.

Docs and skills

  • pkg-py/docs/articles/testing.qmd and its R mirror pkg-r/vignettes/articles/testing.Rmd are retitled Testing and gain a server-testing half ahead of the wire-tap material, tabbed py/R. Both render; headings and tabsets verified in the built HTML.
  • The shipped skills were actively wrong. shinyreact-build-app/references/testing.md said "[py] has no testServer() equivalent … This is a real gap, not an oversight in your app." It now documents test_server() alongside R's, and shinyreact-convert-app's verify phase goes from three layers to four. make update-skills run.
  • Four things are documented because each cost time to find: pass an absolute Path (a relative one resolves against the test file's directory); an untyped id needs no :shinyreact.default suffix (both Python handlers are no-ops) but a typed id does, since the suffix is what runs the handler; an event input needs two set_inputs calls, mirroring the client's register-at-mount then send; and an unset input means status == "silent", not a None value.

test_server() is newer than shiny 1.7.0 — which this repo already depends on via the py-shiny main git dep — and the docs and skills say so explicitly.

Two upstream issues filed from this work

  • posit-dev/py-shiny#2492 — a silent req() failure is invisible in memory: get_output() keeps the previous value with status="ok" while the browser blanks the output, so a test can pass asserting a value the user cannot see. Includes R evidence that testServer() does not retain the stale value, which is the parity rationale the current behavior cites.
  • posit-dev/py-shiny#2493status == "silent" means "never rendered", the exact inversion of what Shiny already taught req()/silent to mean. Suggests "never-rendered", which also frees the name for #2492.

Neither blocks this PR; the one leaf that would have asserted the missing behavior is written as a comment naming the issue instead.

Drive-by

pkg-r/tests/testthat/test-examples.R read every file in an example directory and then filtered to app.R + www/, so the new __pycache__ produced 208 readLines() warnings. It filters first now.

Verification

  • uv run pytest — 195 passed; tox py310 and py314 — 195 each
  • Rscript -e 'testthat::test_local()' — 320 pass, 0 warn (was 208 warn)
  • make r-check — 0 errors, 0 warnings, 0 notes
  • each example's tests pass from the app directory: pytest, shiny::runTests()
  • both doc sites render

py-shiny#2470 added `shiny.testserver.test_server()`, the Python counterpart
to R's `testServer()`. A ui.tsx server is only reactive computation, so "input
X produces output Y" *is* the server, and both languages can now assert it
with no browser.

Package tests:

- `pkg-py/tests/test_in_memory_server.py` — `reactive_output` publishing
  through a real session (`test_reactive_output.py` only reaches
  `Renderer.transform()`), module namespace isolation via `make_scope()`, and
  output error statuses. Reuses the Playwright fixture apps; needs no e2e
  extras, so it lives in the unit suite.
- `pkg-r/tests/testthat/test-render.R` — the R mirrors of those two, with the
  divergence stated: R's `testServer()` re-raises where Python reports
  `.status`.

Example tests, one per app with a Python server, plus R counterparts for the
two that ship an `app.R` (07-plotly had no R test directory):

- two `FEATURES.md` leaves were wrong and are now correct and tested:
  05-temperature and 08-input-handler both claimed `input.x()` is `None`
  before the client's first message, so the server returns `None` / `"—"`. It
  raises a silent exception, so those branches are unreachable from a real
  client.
- `(test)` markers added across seven example trees for the wire shapes,
  thresholds, pluralization, event handling, and `AsIs` wrapping these pin.

Docs and skills:

- the Quarto article and its R vignette mirror are retitled "Testing" and gain
  a server-testing half ahead of the wire-tap material.
- `shinyreact-build-app/references/testing.md` said "`[py]` has no
  `testServer()` equivalent … This is a real gap"; it now documents
  `test_server()`, and `shinyreact-convert-app`'s verify phase goes from three
  layers to four. Shipped copies refreshed with `make update-skills`.
- documented, because each cost time to find: pass an absolute `Path`; an
  untyped id needs no `:shinyreact.default` suffix but a typed id does; an
  event input needs two `set_inputs` calls; an unset input is `status ==
  "silent"`, not a `None` value.

`test_server()` is newer than shiny 1.7.0, which the docs and skills say
explicitly.

Two upstream issues filed from this work: posit-dev/py-shiny#2492 (a silent
`req()` failure is invisible in memory — `get_output()` keeps the stale value
while the browser blanks the output) and posit-dev/py-shiny#2493 (`status ==
"silent"` means "never rendered", inverting Shiny's own meaning).

Drive-by: `test-examples.R` read every file in an example directory before
filtering to `app.R` + `www/`, so `__pycache__` produced 208 `readLines()`
warnings.
…70-testing

# Conflicts:
#	pkg-r/tests/testthat/test-examples.R
Merging main brought in a new example. Its tests deliberately never load the
app -- in Python that would trigger its build-on-first-run -- so test_server()
is out of reach there, which is worth a leaf rather than a silent gap.
- 07-plotly: hoist skip_if_not_installed("plotly") to file scope, since
  app.R calls plotly::renderPlotly() in server() and every testServer()
  call needs it, not just the scatter test
- 08-input-handler: replace a tautological stdlib assertion with an
  exact check on the echoed datetime; the handler decodes as UTC and
  strips tzinfo, so the repr is machine-independent. Fix the FEATURES.md
  leaf that claimed a tzinfo= in the repr.
@schloerke
schloerke enabled auto-merge (squash) September 11, 2026 21:34
@schloerke
schloerke merged commit 336daab into main Sep 11, 2026
18 checks passed
@schloerke
schloerke deleted the schloerke/py-shiny-2470-testing branch September 11, 2026 21:40
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.

1 participant