diff --git a/.claude/skills/shinyreact-build-app/SKILL.md b/.claude/skills/shinyreact-build-app/SKILL.md index 0a225be..a204ff7 100644 --- a/.claude/skills/shinyreact-build-app/SKILL.md +++ b/.claude/skills/shinyreact-build-app/SKILL.md @@ -396,13 +396,13 @@ Do not stop at "the code is written". Three steps, in order: Both languages drive the reactive graph with no browser, which for a `ui.tsx` app is most of the server: `[r]` `shiny::testServer()` (`session$setInputs(bins -= 9)`, then assert on `output$dist_data`) and `[py]` -`shiny.testserver.test_server()` (`ts.set_inputs(bins=9)`, then -`ts.get_output("dist_data")`). Either way the value you assert is the JSON the += 9)`, then assert on `output$dist_data`) and `[py]` the built-in +`local_server` pytest fixture (`local_server.set_inputs(bins=9)`, then +`local_server.get_output("dist_data")`). Either way the value you assert is the JSON the client would have received. [`references/testing.md`](references/testing.md) has the four layers, the test -layout for each language, `testServer()` / `test_server()` for plain and module +layout for each language, `testServer()` / `local_server` for plain and module servers — including the input ids that need a `:type` suffix and the event inputs that need two `set_inputs` calls — how to mount the real `www/ui.js` against a fake Shiny, and the traps that cost time (React ignores raw `change` diff --git a/.claude/skills/shinyreact-build-app/references/testing.md b/.claude/skills/shinyreact-build-app/references/testing.md index 245ede5..00a9551 100644 --- a/.claude/skills/shinyreact-build-app/references/testing.md +++ b/.claude/skills/shinyreact-build-app/references/testing.md @@ -5,7 +5,7 @@ Do not stop at "the code is written". At minimum: 1. **Factor pure logic out of the app file.** Binning, formatting, conversions go in a module beside the app so a test can import them directly, with no session at all. Logic left inside `app.py` / `app.R` next to the page call - is still reachable — `testServer()` / `test_server()` below drive the app + is still reachable — `testServer()` / `local_server` below drive the app itself — but only through an input, which is a slower and blunter tool than calling a function. 2. **Write down what the app does, in plain English, before the tests** — a @@ -19,7 +19,7 @@ Do not stop at "the code is written". At minimum: | Layer | Proves | Cost | |---|---|---| | pure functions in their own module | binning, formatting, conversions | trivial — always do this | -| `shiny::testServer()` `[r]` / `shiny.testserver.test_server()` `[py]` | the reactive graph: inputs in, `reactive_output` values out | low, and no browser | +| `shiny::testServer()` `[r]` / the `local_server` fixture `[py]` | the reactive graph: inputs in, `reactive_output` values out | low, and no browser | | the client mounted in jsdom against a fake Shiny | rendering, input wiring, wire ids, status handling | low, and it exercises the file the app ships | | Playwright | layout, real Shiny, real bindings | high; reserve for what the others cannot see | @@ -35,7 +35,7 @@ myapp/ www/ui.js tests/ test_faithful.py [py] pytest — the factored logic, called directly - test_outputs.py [py] pytest — the app, via test_server() + test_outputs.py [py] pytest — the app, via `local_server` testthat.R [r] runner: library(testthat); test_dir("testthat") testthat/ test-histogram.R [r] @@ -62,7 +62,7 @@ sys.path.insert(0, str(EXAMPLE)) from faithful import histogram, waiting # noqa: E402 ``` -## Testing the server: `testServer()` `[r]`, `test_server()` `[py]` +## Testing the server: `testServer()` `[r]`, `local_server` `[py]` **This is the highest-value layer for a `ui.tsx` app.** The server contains only reactive computation, so "input X produces output Y" *is* the server, and @@ -100,26 +100,34 @@ which is most of what a shinyreact server does. Module servers work the same way: `testServer(card_server, args = list(id = "left"), { ... })`. -### `[py]` `shiny.testserver.test_server()` +### `[py]` the `local_server` fixture -The Python counterpart (py-shiny#2470, so newer than shiny 1.7.0). It loads the -app file — Express or Core, `shiny.App` or `shinyreact.ReactApp` — and runs its -server against a mock connection: +The Python counterpart (py-shiny#2470, so newer than shiny 1.7.0). `local_server` +is a built-in pytest fixture — nothing to import — holding an already-started +`shiny.testserver.test_server()` session. It loads the app file — Express or +Core, `shiny.App` or `shinyreact.ReactApp` — and runs its server against a mock +connection: ```python -from pathlib import Path -from shiny.testserver import test_server +import pytest +from shiny.testserver import TestServerSession -APP = Path(__file__).resolve().parents[1] / "app.py" +# The fixture defaults to `app.py` beside the test file; ours is a directory up. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) -def test_the_histogram_recomputes_when_bins_changes(): - with test_server(APP) as ts: - ts.set_inputs(bins=9) - assert ts.get_output("dist_data").value["counts"] == [16, 37, 30, 16, 14, 57, 67, 29, 6] - assert ts.get_output("dist_caption") == "272 eruptions in 9 bins" +def test_the_histogram_recomputes_when_bins_changes(local_server: TestServerSession): + local_server.set_inputs(bins=9) + counts = local_server.get_output("dist_data").value["counts"] + assert counts == [16, 37, 30, 16, 14, 57, 67, 29, 6] + assert local_server.get_output("dist_caption") == "272 eruptions in 9 bins" ``` +The fixture is function-scoped, so each test gets a fresh session. Call +`test_server()` directly (as a context manager — it returns an *unstarted* +session) when the fixture cannot express what you need: a server function or +`App` object, `client_data=`, `timeout_secs=`. + `get_output()` returns a value that compares equal to the underlying one, so assert on it directly; use `.value` when you need to index into it, and `.status` (`"ok"` / `"error"` / `"silent"`) or `.error` to assert the @@ -127,9 +135,6 @@ non-value outcomes. Traditional renderers are readable too, so `@render.data_frame` / `@render_plotly` outputs mounted through `ShinyOutput` can be checked at the wire level. -Pass an **absolute `Path`**: a relative one resolves against the test file's -directory, and the app is a directory up from `tests/`. - Four things to know, all of them shinyreact-specific: - **An untyped input id needs no `:shinyreact.default` suffix.** The hook @@ -145,16 +150,17 @@ Four things to know, all of them shinyreact-specific: - **An unset input means `status == "silent"`, not a `None` value.** `input.x()` raises a silent exception while unset, so a `if x is None:` branch in your server is unreachable from a real client — assert the status - instead. (A *later* `req()` failure is not visible in memory at all: - py-shiny#2492.) + instead. A later `req()` failure reports `"silent"` too: the status describes + the *latest* render, matching the blank the browser shows. -Module ids can be read as the session sees them (`ts.get_output("counter-n")`) -or through a scope, which strips the namespace on the way in and out: +Module ids can be read as the session sees them +(`local_server.get_output("counter-n")`) or through a scope, which strips the +namespace on the way in and out: ```python -with ts.make_scope("counter") as counter: - counter.set_inputs(n=7) - assert counter.get_output("label") == "n=7" +counter = local_server.make_scope("counter") +counter.set_inputs(n=7) +assert counter.get_output("label") == "n=7" ``` ## The jsdom layer — mount the client the app ships diff --git a/.claude/skills/shinyreact-convert-app/SKILL.md b/.claude/skills/shinyreact-convert-app/SKILL.md index c818492..98163c5 100644 --- a/.claude/skills/shinyreact-convert-app/SKILL.md +++ b/.claude/skills/shinyreact-convert-app/SKILL.md @@ -151,7 +151,7 @@ rendered), then the rest of the outputs, then layout and polish. Four layers, cheapest first: factor pure logic out of the app file so it is importable and test it directly; drive the ported server with no browser — -`[r]` `shiny::testServer()`, `[py]` `shiny.testserver.test_server()` — which +`[r]` `shiny::testServer()`, `[py]` the `local_server` pytest fixture — which for a `ui.tsx` app covers most of it, since the server is only reactive computation; test the client by evaluating the real `www/ui.js` against a fake `window.Shiny` in jsdom (not by importing the component — that tests a copy the diff --git a/examples/01-hello/tests/test_outputs.py b/examples/01-hello/tests/test_outputs.py index 1d1db9b..ae92a8e 100644 --- a/examples/01-hello/tests/test_outputs.py +++ b/examples/01-hello/tests/test_outputs.py @@ -1,12 +1,13 @@ """Pins this example's server outputs as the client sees them. `test_faithful.py` next to this file tests the binner as a pure function; -this file drives the *app* — `shiny.testserver.test_server()` runs the server +this file drives the *app* — shiny's `local_server` fixture runs the server against a mock connection, so `dist_data` and `dist_caption` can be asserted exactly as they arrive at `useShinyOutputValue()`. No browser, no subprocess. Both Python servers are covered, because `app.py` (Express) and `app-core.py` -(Core) are claimed to be interchangeable over one `www/` client. +(Core) are claimed to be interchangeable over one `www/` client — an indirect +parametrization points the fixture at each in turn. Run it from the app directory, the way a user of the app would:: @@ -19,44 +20,40 @@ from __future__ import annotations -from pathlib import Path - import pytest -from shiny.testserver import test_server +from shiny.testserver import TestServerSession -EXAMPLE = Path(__file__).resolve().parents[1] -APPS = [EXAMPLE / "app.py", EXAMPLE / "app-core.py"] +# The apps are a directory up from this `tests/` folder, and both are covered. +pytestmark = pytest.mark.parametrize( + "local_server", ["../app.py", "../app-core.py"], indirect=True +) # Mirrored in test_faithful.py, tests/test-histogram.R and tests/ui.test.ts. COUNTS_9 = [16, 37, 30, 16, 14, 57, 67, 29, 6] -@pytest.mark.parametrize("app", APPS, ids=lambda p: p.name) -def test_dist_data_wire_shape(app: Path) -> None: - with test_server(app) as ts: - ts.set_inputs(bins=9) - data = ts.get_output("dist_data").value - assert list(data) == ["breaks", "counts"] - assert data["counts"] == COUNTS_9 - assert data["breaks"][0] == 43.0 - assert data["breaks"][-1] == pytest.approx(96.0) +def test_dist_data_wire_shape(local_server: TestServerSession) -> None: + local_server.set_inputs(bins=9) + data = local_server.get_output("dist_data").value + assert list(data) == ["breaks", "counts"] + assert data["counts"] == COUNTS_9 + assert data["breaks"][0] == 43.0 + assert data["breaks"][-1] == pytest.approx(96.0) -@pytest.mark.parametrize("app", APPS, ids=lambda p: p.name) -def test_dist_caption_pluralizes(app: Path) -> None: - with test_server(app) as ts: - ts.set_inputs(bins=9) - assert ts.get_output("dist_caption") == "272 eruptions in 9 bins" +def test_dist_caption_pluralizes(local_server: TestServerSession) -> None: + local_server.set_inputs(bins=9) + assert local_server.get_output("dist_caption") == "272 eruptions in 9 bins" - ts.set_inputs(bins=1) - assert ts.get_output("dist_caption") == "272 eruptions in 1 bin" - assert ts.get_output("dist_data").value["counts"] == [272] + local_server.set_inputs(bins=1) + assert local_server.get_output("dist_caption") == "272 eruptions in 1 bin" + assert local_server.get_output("dist_data").value["counts"] == [272] -@pytest.mark.parametrize("app", APPS, ids=lambda p: p.name) -def test_neither_output_renders_before_the_first_bins_message(app: Path) -> None: +def test_neither_output_renders_before_the_first_bins_message( + local_server: TestServerSession, +) -> None: # `input.bins()` raises a silent exception while unset, so both outputs # have no value at all — not an error, and not a `None` value. - with test_server(app) as ts: - assert ts.get_output("dist_data").status == "silent" - assert ts.get_output("dist_caption").status == "silent" + assert local_server.get_output("dist_data").status == "silent" + assert local_server.get_output("dist_caption").status == "silent" diff --git a/examples/02-columns/FEATURES.md b/examples/02-columns/FEATURES.md index 45ab0a6..c1e5afa 100644 --- a/examples/02-columns/FEATURES.md +++ b/examples/02-columns/FEATURES.md @@ -31,7 +31,7 @@ a unit test; `(verify)` marks a claim not yet checked against the code. - `[py]` only — this example has no R server - the server logic lives inside `app.py` next to `set_react_page()`, so it is not importable — `tests/test_moves.py` drives the app itself with - `shiny.testserver.test_server()` instead, sending the mount-time `null` + the `local_server` fixture instead, sending the mount-time `null` first so the move is not swallowed as the init ## Client (`www/ui.js`) diff --git a/examples/02-columns/tests/test_moves.py b/examples/02-columns/tests/test_moves.py index 9b8b79b..17ac2a6 100644 --- a/examples/02-columns/tests/test_moves.py +++ b/examples/02-columns/tests/test_moves.py @@ -1,6 +1,6 @@ """Pins the server's move handling as the client drives it. -`shiny.testserver.test_server()` runs `app.py`'s server against a mock +Shiny's `local_server` fixture runs `app.py`'s server against a mock connection, so the `move_item` → `column_data` round trip can be asserted without a browser. Run it from the app directory:: @@ -14,11 +14,12 @@ from __future__ import annotations -from pathlib import Path +import pytest +from shiny.testserver import TestServerSession -from shiny.testserver import test_server - -APP = Path(__file__).resolve().parents[1] / "app.py" +# The app is a directory up from this `tests/` folder, so the `local_server` +# fixture (an already-started `test_server()` session) gets pointed at it. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) INITIAL = { "A": ["Apple", "Apricot"], @@ -27,38 +28,40 @@ } -def test_column_data_starts_at_the_initial_three_columns() -> None: - with test_server(APP) as ts: - assert ts.get_output("column_data") == INITIAL +def test_column_data_starts_at_the_initial_three_columns( + local_server: TestServerSession, +) -> None: + assert local_server.get_output("column_data") == INITIAL -def test_a_move_removes_from_the_source_and_appends_to_the_target() -> None: - with test_server(APP) as ts: - ts.set_inputs(move_item=None) # the hook's default, sent at mount - ts.set_inputs(move_item={"item": "Apple", "from": "A", "to": "C"}) +def test_a_move_removes_from_the_source_and_appends_to_the_target( + local_server: TestServerSession, +) -> None: + local_server.set_inputs(move_item=None) # the hook's default, sent at mount + local_server.set_inputs(move_item={"item": "Apple", "from": "A", "to": "C"}) - assert ts.get_output("column_data") == { - "A": ["Apricot"], - "B": ["Banana", "Blueberry"], - # Appended, not inserted in sorted position. - "C": ["Cherry", "Cranberry", "Apple"], - } + assert local_server.get_output("column_data") == { + "A": ["Apricot"], + "B": ["Banana", "Blueberry"], + # Appended, not inserted in sorted position. + "C": ["Cherry", "Cranberry", "Apple"], + } -def test_a_move_of_an_item_the_source_does_not_hold_is_ignored() -> None: - with test_server(APP) as ts: - ts.set_inputs(move_item=None) - ts.set_inputs(move_item={"item": "Cherry", "from": "A", "to": "B"}) - assert ts.get_output("column_data") == INITIAL +def test_a_move_of_an_item_the_source_does_not_hold_is_ignored( + local_server: TestServerSession, +) -> None: + local_server.set_inputs(move_item=None) + local_server.set_inputs(move_item={"item": "Cherry", "from": "A", "to": "B"}) + assert local_server.get_output("column_data") == INITIAL -def test_moves_accumulate() -> None: - with test_server(APP) as ts: - ts.set_inputs(move_item=None) - ts.set_inputs(move_item={"item": "Apple", "from": "A", "to": "B"}) - ts.set_inputs(move_item={"item": "Apple", "from": "B", "to": "C"}) +def test_moves_accumulate(local_server: TestServerSession) -> None: + local_server.set_inputs(move_item=None) + local_server.set_inputs(move_item={"item": "Apple", "from": "A", "to": "B"}) + local_server.set_inputs(move_item={"item": "Apple", "from": "B", "to": "C"}) - data = ts.get_output("column_data").value - assert data["A"] == ["Apricot"] - assert data["B"] == ["Banana", "Blueberry"] - assert data["C"] == ["Cherry", "Cranberry", "Apple"] + data = local_server.get_output("column_data").value + assert data["A"] == ["Apricot"] + assert data["B"] == ["Banana", "Blueberry"] + assert data["C"] == ["Cherry", "Cranberry", "Apple"] diff --git a/examples/05-temperature/FEATURES.md b/examples/05-temperature/FEATURES.md index 6203b3b..940aea6 100644 --- a/examples/05-temperature/FEATURES.md +++ b/examples/05-temperature/FEATURES.md @@ -25,7 +25,7 @@ a unit test; `(verify)` marks a claim not yet checked against the code. - `[py]` only — this example has no R server - the logic lives inside `app.py` next to `set_react_page()`, so it is not importable — `tests/test_display.py` drives the app itself with - `shiny.testserver.test_server()` instead + the `local_server` fixture instead ## Client (`www/ui.js`) diff --git a/examples/05-temperature/tests/test_display.py b/examples/05-temperature/tests/test_display.py index b49b2ae..35e083c 100644 --- a/examples/05-temperature/tests/test_display.py +++ b/examples/05-temperature/tests/test_display.py @@ -3,7 +3,7 @@ The client converts locally and the server converts again; a threshold or rounding change on one side without the other is a visible divergence, so both sides need a test. `ui.test.ts` next to this file covers the client; -`shiny.testserver.test_server()` covers the server, in memory. Run it from the +shiny's `local_server` fixture covers the server, in memory. Run it from the app directory:: pytest @@ -15,28 +15,26 @@ from __future__ import annotations -from pathlib import Path - import pytest -from shiny.testserver import test_server +from shiny.testserver import TestServerSession -APP = Path(__file__).resolve().parents[1] / "app.py" +# The app is a directory up from this `tests/` folder, so the `local_server` +# fixture (an already-started `test_server()` session) gets pointed at it. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) -def test_display_wire_shape() -> None: - with test_server(APP) as ts: - ts.set_inputs(celsius=20) - assert ts.get_output("display") == { - "celsius": 20, - "fahrenheit": 68.0, - "zone": "Comfortable", - } +def test_display_wire_shape(local_server: TestServerSession) -> None: + local_server.set_inputs(celsius=20) + assert local_server.get_output("display") == { + "celsius": 20, + "fahrenheit": 68.0, + "zone": "Comfortable", + } -def test_fahrenheit_keeps_one_decimal() -> None: - with test_server(APP) as ts: - ts.set_inputs(celsius=37) - assert ts.get_output("display").value["fahrenheit"] == 98.6 +def test_fahrenheit_keeps_one_decimal(local_server: TestServerSession) -> None: + local_server.set_inputs(celsius=37) + assert local_server.get_output("display").value["fahrenheit"] == 98.6 @pytest.mark.parametrize( @@ -52,15 +50,17 @@ def test_fahrenheit_keeps_one_decimal() -> None: (60, "Hot"), ], ) -def test_zone_thresholds(celsius: int, zone: str) -> None: - with test_server(APP) as ts: - ts.set_inputs(celsius=celsius) - assert ts.get_output("display").value["zone"] == zone +def test_zone_thresholds( + local_server: TestServerSession, celsius: int, zone: str +) -> None: + local_server.set_inputs(celsius=celsius) + assert local_server.get_output("display").value["zone"] == zone -def test_no_echo_before_the_clients_first_message() -> None: +def test_no_echo_before_the_clients_first_message( + local_server: TestServerSession, +) -> None: # `input.celsius()` raises a silent exception while unset, so `display` # never renders at all — the `c is None` guard in `app.py` is unreachable # from a real client. - with test_server(APP) as ts: - assert ts.get_output("display").status == "silent" + assert local_server.get_output("display").status == "silent" diff --git a/examples/06-data-frame/tests/test_table.py b/examples/06-data-frame/tests/test_table.py index ad0f493..281614d 100644 --- a/examples/06-data-frame/tests/test_table.py +++ b/examples/06-data-frame/tests/test_table.py @@ -1,6 +1,6 @@ """Pins both outputs, including the traditional `@render.data_frame` one. -`shiny.testserver.test_server()` reads a `reactive_output` and a traditional +Shiny's `local_server` fixture reads a `reactive_output` and a traditional renderer the same way, so the payload `ShinyOutput` hands to `shiny-data-frame` can be asserted in memory. Run it from the app directory:: @@ -9,34 +9,32 @@ from __future__ import annotations -from pathlib import Path +import pytest +from shiny.testserver import TestServerSession -from shiny.testserver import test_server +# The app is a directory up from this `tests/` folder, so the `local_server` +# fixture (an already-started `test_server()` session) gets pointed at it. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) -APP = Path(__file__).resolve().parents[1] / "app.py" +def test_greeting_counts_the_rows(local_server: TestServerSession) -> None: + local_server.set_inputs(row_count=3) + assert local_server.get_output("greeting") == "Showing 3 rows" -def test_greeting_counts_the_rows() -> None: - with test_server(APP) as ts: - ts.set_inputs(row_count=3) - assert ts.get_output("greeting") == "Showing 3 rows" +def test_data_frame_payload(local_server: TestServerSession) -> None: + local_server.set_inputs(row_count=3) + payload = local_server.get_output("my_table").value["payload"] -def test_data_frame_payload() -> None: - with test_server(APP) as ts: - ts.set_inputs(row_count=3) - payload = ts.get_output("my_table").value["payload"] + assert payload["columns"] == ["Name", "Value", "Category"] + assert payload["data"] == [ + ["Item 1", 10, "B"], + ["Item 2", 20, "A"], + ["Item 3", 30, "B"], + ] - assert payload["columns"] == ["Name", "Value", "Category"] - assert payload["data"] == [ - ["Item 1", 10, "B"], - ["Item 2", 20, "A"], - ["Item 3", 30, "B"], - ] - -def test_row_count_drives_both_outputs() -> None: - with test_server(APP) as ts: - ts.set_inputs(row_count=5) - assert ts.get_output("greeting") == "Showing 5 rows" - assert len(ts.get_output("my_table").value["payload"]["data"]) == 5 +def test_row_count_drives_both_outputs(local_server: TestServerSession) -> None: + local_server.set_inputs(row_count=5) + assert local_server.get_output("greeting") == "Showing 5 rows" + assert len(local_server.get_output("my_table").value["payload"]["data"]) == 5 diff --git a/examples/07-plotly/tests/test_scatter.py b/examples/07-plotly/tests/test_scatter.py index ffd9340..3118424 100644 --- a/examples/07-plotly/tests/test_scatter.py +++ b/examples/07-plotly/tests/test_scatter.py @@ -1,6 +1,6 @@ """Pins both outputs: the JSON echo and the plotly widget. -`shiny.testserver.test_server()` reads a `reactive_output` and a +Shiny's `local_server` fixture reads a `reactive_output` and a `@render_plotly` widget the same way, so this example's central claim — a traditional widget renderer needs no `*Output()` placeholder to produce its value — is checkable without a browser. Run it from the app directory:: @@ -10,34 +10,36 @@ from __future__ import annotations -from pathlib import Path +import pytest +from shiny.testserver import TestServerSession -from shiny.testserver import test_server +# The app is a directory up from this `tests/` folder, so the `local_server` +# fixture (an already-started `test_server()` session) gets pointed at it. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) -APP = Path(__file__).resolve().parents[1] / "app.py" +def test_greeting_counts_the_points(local_server: TestServerSession) -> None: + local_server.set_inputs(num_points=50) + assert local_server.get_output("greeting") == "Showing 50 random points" -def test_greeting_counts_the_points() -> None: - with test_server(APP) as ts: - ts.set_inputs(num_points=50) - assert ts.get_output("greeting") == "Showing 50 random points" + local_server.set_inputs(num_points=1) + assert local_server.get_output("greeting") == "Showing 1 random points" - ts.set_inputs(num_points=1) - assert ts.get_output("greeting") == "Showing 1 random points" +def test_scatter_renders_a_widget_with_no_placeholder( + local_server: TestServerSession, +) -> None: + local_server.set_inputs(num_points=50) + widget = local_server.get_output("scatter").value -def test_scatter_renders_a_widget_with_no_placeholder() -> None: - with test_server(APP) as ts: - ts.set_inputs(num_points=50) - widget = ts.get_output("scatter").value + # What shinywidgets puts on the wire: a widget reference, not a figure. + # The client's `ShinyOutput` is what turns it into a plot. + assert widget["widget_pkg"] == "plotly" + assert widget["model_id"] - # What shinywidgets puts on the wire: a widget reference, not a figure. - # The client's `ShinyOutput` is what turns it into a plot. - assert widget["widget_pkg"] == "plotly" - assert widget["model_id"] - -def test_neither_output_renders_before_the_first_message() -> None: - with test_server(APP) as ts: - assert ts.get_output("greeting").status == "silent" - assert ts.get_output("scatter").status == "silent" +def test_neither_output_renders_before_the_first_message( + local_server: TestServerSession, +) -> None: + assert local_server.get_output("greeting").status == "silent" + assert local_server.get_output("scatter").status == "silent" diff --git a/examples/08-input-handler/tests/test_when.py b/examples/08-input-handler/tests/test_when.py index c1adee8..779602b 100644 --- a/examples/08-input-handler/tests/test_when.py +++ b/examples/08-input-handler/tests/test_when.py @@ -1,7 +1,7 @@ """Pins the input handler's coercion at the server, in memory. This example exists to show that `useShinyInput(..., {type: "shiny.datetime"})` -makes the server read a `datetime`. `shiny.testserver.test_server()` can assert +makes the server read a `datetime`. Shiny's `local_server` fixture can assert exactly that, because `set_inputs` takes the **wire** id — so the `:type` suffix the hook appends is part of the test, and the handler really runs. Run it from the app directory:: @@ -11,35 +11,37 @@ from __future__ import annotations -from pathlib import Path +import pytest +from shiny.testserver import TestServerSession -from shiny.testserver import test_server +# The app is a directory up from this `tests/` folder, so the `local_server` +# fixture (an already-started `test_server()` session) gets pointed at it. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) -APP = Path(__file__).resolve().parents[1] / "app.py" +def test_the_handler_turns_unix_seconds_into_a_datetime( + local_server: TestServerSession, +) -> None: + # What the client sends: unix *seconds*, under the suffixed wire id. + local_server.set_inputs(**{"when:shiny.datetime": 1756382400}) + echoed = local_server.get_output("when_info").value -def test_the_handler_turns_unix_seconds_into_a_datetime() -> None: - with test_server(APP) as ts: - # What the client sends: unix *seconds*, under the suffixed wire id. - ts.set_inputs(**{"when:shiny.datetime": 1756382400}) - echoed = ts.get_output("when_info").value + # Shiny's handler decodes as UTC and strips the tzinfo, so the value + # is the same on every machine: 1756382400 is 2025-08-28T12:00:00Z. + assert echoed == "datetime → datetime.datetime(2025, 8, 28, 12, 0)" - # Shiny's handler decodes as UTC and strips the tzinfo, so the value - # is the same on every machine: 1756382400 is 2025-08-28T12:00:00Z. - assert echoed == "datetime → datetime.datetime(2025, 8, 28, 12, 0)" - -def test_an_unsuffixed_value_is_not_coerced() -> None: +def test_an_unsuffixed_value_is_not_coerced(local_server: TestServerSession) -> None: # The suffix is what buys the coercion: without it the number arrives as a # number. This is the failure mode the per-id type contract exists to # prevent, and the reason to write the suffix out in the test above. - with test_server(APP) as ts: - ts.set_inputs(when=1756382400) - assert ts.get_output("when_info") == "int → 1756382400" + local_server.set_inputs(when=1756382400) + assert local_server.get_output("when_info") == "int → 1756382400" -def test_nothing_renders_before_the_clients_first_message() -> None: +def test_nothing_renders_before_the_clients_first_message( + local_server: TestServerSession, +) -> None: # `input.when()` raises a silent exception while unset, so the `None` # branch in `app.py` (the em dash) is unreachable from a real client. - with test_server(APP) as ts: - assert ts.get_output("when_info").status == "silent" + assert local_server.get_output("when_info").status == "silent" diff --git a/examples/10-bookmarking/tests/test_greeting.py b/examples/10-bookmarking/tests/test_greeting.py index 2fcd31e..eb8c475 100644 --- a/examples/10-bookmarking/tests/test_greeting.py +++ b/examples/10-bookmarking/tests/test_greeting.py @@ -1,6 +1,6 @@ """Pins the echo output and the bookmark effect's inputs. -`shiny.testserver.test_server()` loads this app through `ReactApp`, so the +Shiny's `local_server` fixture loads this app through `ReactApp`, so the server half of the example runs in memory. The *restore* half does not: it is a property of the rendered page and the browser URL, and stays pinned by `pkg-py/tests/test_bookmark_restore.py` and its Playwright counterpart. Run it @@ -11,41 +11,41 @@ from __future__ import annotations -from pathlib import Path +import pytest +from shiny.testserver import TestServerSession -from shiny.testserver import test_server +# The app is a directory up from this `tests/` folder, so the `local_server` +# fixture (an already-started `test_server()` session) gets pointed at it. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) -APP = Path(__file__).resolve().parents[1] / "app.py" +def test_greeting_wire_shape(local_server: TestServerSession) -> None: + local_server.set_inputs(txt="hi", num=5, chk=True) + # `checked` is the string "yes"/"no", and `txt` is repr'd, so quoted. + assert local_server.get_output("greeting") == "text='hi' num=5 checked=yes" -def test_greeting_wire_shape() -> None: - with test_server(APP) as ts: - ts.set_inputs(txt="hi", num=5, chk=True) - # `checked` is the string "yes"/"no", and `txt` is repr'd, so quoted. - assert ts.get_output("greeting") == "text='hi' num=5 checked=yes" + local_server.set_inputs(chk=False) + assert local_server.get_output("greeting") == "text='hi' num=5 checked=no" - ts.set_inputs(chk=False) - assert ts.get_output("greeting") == "text='hi' num=5 checked=no" - -def test_greeting_renders_from_the_hook_defaults() -> None: +def test_greeting_renders_from_the_hook_defaults( + local_server: TestServerSession, +) -> None: # The client renders immediately rather than gating on # `useShinyInitialized()`, so the first values the server sees are the # hook defaults (or the restored ones). - with test_server(APP) as ts: - ts.set_inputs(txt="", num=0, chk=False) - assert ts.get_output("greeting") == "text='' num=0 checked=no" + local_server.set_inputs(txt="", num=0, chk=False) + assert local_server.get_output("greeting") == "text='' num=0 checked=no" -def test_a_bookmark_click_is_an_event_input() -> None: +def test_a_bookmark_click_is_an_event_input(local_server: TestServerSession) -> None: # `bookmark_clicks` is write-only and `priority: "event"`, with the count # incremented per click; `ignore_init=True` means the mount-time 0 does # not bookmark. The URL rewrite itself needs a browser — see the # Playwright suite — so all this asserts is that the effect runs cleanly. - with test_server(APP) as ts: - ts.set_inputs(txt="hi", num=5, chk=True) - ts.set_inputs(bookmark_clicks=0) - ts.set_inputs(bookmark_clicks=1) + local_server.set_inputs(txt="hi", num=5, chk=True) + local_server.set_inputs(bookmark_clicks=0) + local_server.set_inputs(bookmark_clicks=1) - assert ts.to_values().is_ok - assert ts.get_output("greeting") == "text='hi' num=5 checked=yes" + assert local_server.to_values().is_ok + assert local_server.get_output("greeting") == "text='hi' num=5 checked=yes" diff --git a/examples/11-npm-local/FEATURES.md b/examples/11-npm-local/FEATURES.md index 6c401f5..ae0b875 100644 --- a/examples/11-npm-local/FEATURES.md +++ b/examples/11-npm-local/FEATURES.md @@ -58,7 +58,7 @@ a unit test; `(verify)` marks a claim not yet checked against the code. a value - `[r]` `input$bins` is `NULL` and both outputs return `NULL` explicitly - alone among the examples, the outputs are **not** driven in memory: - `test_server()` / `testServer()` load the app, which is the one thing this + `local_server` / `testServer()` load the app, which is the one thing this example's tests avoid — `[py]` loading `app.py` triggers its build-on-first-run. `examples/01-hello` pins the same binning and the same two output shapes against three servers diff --git a/examples/README.md b/examples/README.md index 172cbc9..af4570d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -113,20 +113,24 @@ Rscript -e 'shiny::runTests()' # the app's R tests (also shinytest2::t npx vitest run --root .. 01-hello # the app's UI tests ``` -Most of the Python tests drive the app itself, with -[`shiny.testserver.test_server()`](https://shiny.posit.co/py/api/testing/): -inputs in, output values out, in memory. That is a good fit for these apps +Most of the Python tests drive the app itself, with shiny's built-in +[`local_server`](https://shiny.posit.co/py/api/testing/) pytest fixture — an +already-started `test_server()` session: inputs in, output values out, in +memory. That is a good fit for these apps because a `ui.tsx` server contains only reactive computation, so the JSON a test asserts is exactly what `useShinyOutputValue()` receives: ```python -with test_server(APP) as ts: - ts.set_inputs(bins=9) - assert ts.get_output("dist_caption") == "272 eruptions in 9 bins" +# The fixture defaults to `app.py` beside the test file; ours is a directory up. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) + + +def test_dist_caption(local_server): + local_server.set_inputs(bins=9) + assert local_server.get_output("dist_caption") == "272 eruptions in 9 bins" ``` -Pass an absolute `Path` (`Path(__file__).resolve().parents[1] / "app.py"`), and -mind two shinyreact-specific details: an **untyped** input id needs no +Mind two shinyreact-specific details: an **untyped** input id needs no `:shinyreact.default` suffix (the hook adds it on the wire, but Python's handler is a no-op), while a **typed** one does, because the suffix is what runs the handler; and an input read through `@reactive.event(..., diff --git a/pkg-py/README.md b/pkg-py/README.md index 9b6b6ee..2e3331e 100644 --- a/pkg-py/README.md +++ b/pkg-py/README.md @@ -78,6 +78,6 @@ shiny run shinyreact/examples/01-hello/app.py - [Get started](https://posit-dev.github.io/shinyreact/) walks through the `ui.tsx` pattern: inputs, outputs, messages, and embedding traditional Shiny renderers. - [TSX files and JavaScript build tools](https://posit-dev.github.io/shinyreact/articles/tsx-and-build-tools.html) explains `.tsx`, JSX, TypeScript, and what `npm run build` does. - [Client hooks](https://posit-dev.github.io/shinyreact/articles/hooks.html) lists everything at `window.shinyreact`. -- [Testing](https://posit-dev.github.io/shinyreact/articles/testing.html) covers `shiny.testserver.test_server()` for driving a server with no browser, and `WireTap` for wire payloads in Playwright tests. +- [Testing](https://posit-dev.github.io/shinyreact/articles/testing.html) covers shiny's `local_server` pytest fixture for driving a server with no browser, and `WireTap` for wire payloads in Playwright tests. - [Agent Skills](https://posit-dev.github.io/shinyreact/articles/agent-skills.html) explains the skills that ship with the package for coding agents. - The [examples catalog](https://github.com/posit-dev/shinyreact/blob/main/examples/README.md) lists runnable apps from no-build to Vite + HMR. diff --git a/pkg-py/docs/articles/testing.qmd b/pkg-py/docs/articles/testing.qmd index fc7527c..5ad53d7 100644 --- a/pkg-py/docs/articles/testing.qmd +++ b/pkg-py/docs/articles/testing.qmd @@ -14,23 +14,24 @@ Shiny can run one against a mock connection — no browser, no subprocess — an ### Python -`shiny.testserver.test_server()` loads the app file (Express or Core, `shiny.App` or `shinyreact.ReactApp`) and runs its server: +Shiny's built-in `local_server` pytest fixture — an already-started `shiny.testserver.test_server()` session — loads the app file (Express or Core, `shiny.App` or `shinyreact.ReactApp`) and runs its server: ```python -from pathlib import Path +import pytest -from shiny.testserver import test_server +# The fixture defaults to `app.py` beside the test file; ours is a directory up. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) -APP = Path(__file__).resolve().parents[1] / "app.py" - -def test_dist_outputs(): - with test_server(APP) as ts: - ts.set_inputs(bins=9) - assert ts.get_output("dist_caption") == "272 eruptions in 9 bins" - assert ts.get_output("dist_data").value["counts"][0] == 16 +def test_dist_outputs(local_server): + local_server.set_inputs(bins=9) + assert local_server.get_output("dist_caption") == "272 eruptions in 9 bins" + assert local_server.get_output("dist_data").value["counts"][0] == 16 ``` +The fixture is function-scoped, so each test gets a fresh session. +Call `test_server()` directly — as a context manager, since it returns an *unstarted* session — when the fixture cannot express what you need: a server function or `App` object, `client_data=`, or `timeout_secs=`. + `get_output()` compares equal to the underlying value, so assert on it directly; reach for `.value` to index into it, and `.status` (`"ok"`, `"error"`, `"silent"`) or `.error` for the non-value outcomes. Traditional renderers embedded with [`ShinyOutput`](hooks.qmd) are readable the same way, so a `@render.data_frame` payload can be checked here too. @@ -42,7 +43,7 @@ Three details are specific to shinyreact: An input read through `@reactive.event(..., ignore_init=True)` needs two `set_inputs` calls, mirroring the client: `useShinyInput` registers its default at mount and sends the event after. -`test_server()` is newer than shiny 1.7.0. +`local_server` and `test_server()` are newer than shiny 1.7.0. ### R diff --git a/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/SKILL.md b/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/SKILL.md index 0a225be..a204ff7 100644 --- a/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/SKILL.md +++ b/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/SKILL.md @@ -396,13 +396,13 @@ Do not stop at "the code is written". Three steps, in order: Both languages drive the reactive graph with no browser, which for a `ui.tsx` app is most of the server: `[r]` `shiny::testServer()` (`session$setInputs(bins -= 9)`, then assert on `output$dist_data`) and `[py]` -`shiny.testserver.test_server()` (`ts.set_inputs(bins=9)`, then -`ts.get_output("dist_data")`). Either way the value you assert is the JSON the += 9)`, then assert on `output$dist_data`) and `[py]` the built-in +`local_server` pytest fixture (`local_server.set_inputs(bins=9)`, then +`local_server.get_output("dist_data")`). Either way the value you assert is the JSON the client would have received. [`references/testing.md`](references/testing.md) has the four layers, the test -layout for each language, `testServer()` / `test_server()` for plain and module +layout for each language, `testServer()` / `local_server` for plain and module servers — including the input ids that need a `:type` suffix and the event inputs that need two `set_inputs` calls — how to mount the real `www/ui.js` against a fake Shiny, and the traps that cost time (React ignores raw `change` diff --git a/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/references/testing.md b/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/references/testing.md index 245ede5..00a9551 100644 --- a/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/references/testing.md +++ b/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/references/testing.md @@ -5,7 +5,7 @@ Do not stop at "the code is written". At minimum: 1. **Factor pure logic out of the app file.** Binning, formatting, conversions go in a module beside the app so a test can import them directly, with no session at all. Logic left inside `app.py` / `app.R` next to the page call - is still reachable — `testServer()` / `test_server()` below drive the app + is still reachable — `testServer()` / `local_server` below drive the app itself — but only through an input, which is a slower and blunter tool than calling a function. 2. **Write down what the app does, in plain English, before the tests** — a @@ -19,7 +19,7 @@ Do not stop at "the code is written". At minimum: | Layer | Proves | Cost | |---|---|---| | pure functions in their own module | binning, formatting, conversions | trivial — always do this | -| `shiny::testServer()` `[r]` / `shiny.testserver.test_server()` `[py]` | the reactive graph: inputs in, `reactive_output` values out | low, and no browser | +| `shiny::testServer()` `[r]` / the `local_server` fixture `[py]` | the reactive graph: inputs in, `reactive_output` values out | low, and no browser | | the client mounted in jsdom against a fake Shiny | rendering, input wiring, wire ids, status handling | low, and it exercises the file the app ships | | Playwright | layout, real Shiny, real bindings | high; reserve for what the others cannot see | @@ -35,7 +35,7 @@ myapp/ www/ui.js tests/ test_faithful.py [py] pytest — the factored logic, called directly - test_outputs.py [py] pytest — the app, via test_server() + test_outputs.py [py] pytest — the app, via `local_server` testthat.R [r] runner: library(testthat); test_dir("testthat") testthat/ test-histogram.R [r] @@ -62,7 +62,7 @@ sys.path.insert(0, str(EXAMPLE)) from faithful import histogram, waiting # noqa: E402 ``` -## Testing the server: `testServer()` `[r]`, `test_server()` `[py]` +## Testing the server: `testServer()` `[r]`, `local_server` `[py]` **This is the highest-value layer for a `ui.tsx` app.** The server contains only reactive computation, so "input X produces output Y" *is* the server, and @@ -100,26 +100,34 @@ which is most of what a shinyreact server does. Module servers work the same way: `testServer(card_server, args = list(id = "left"), { ... })`. -### `[py]` `shiny.testserver.test_server()` +### `[py]` the `local_server` fixture -The Python counterpart (py-shiny#2470, so newer than shiny 1.7.0). It loads the -app file — Express or Core, `shiny.App` or `shinyreact.ReactApp` — and runs its -server against a mock connection: +The Python counterpart (py-shiny#2470, so newer than shiny 1.7.0). `local_server` +is a built-in pytest fixture — nothing to import — holding an already-started +`shiny.testserver.test_server()` session. It loads the app file — Express or +Core, `shiny.App` or `shinyreact.ReactApp` — and runs its server against a mock +connection: ```python -from pathlib import Path -from shiny.testserver import test_server +import pytest +from shiny.testserver import TestServerSession -APP = Path(__file__).resolve().parents[1] / "app.py" +# The fixture defaults to `app.py` beside the test file; ours is a directory up. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) -def test_the_histogram_recomputes_when_bins_changes(): - with test_server(APP) as ts: - ts.set_inputs(bins=9) - assert ts.get_output("dist_data").value["counts"] == [16, 37, 30, 16, 14, 57, 67, 29, 6] - assert ts.get_output("dist_caption") == "272 eruptions in 9 bins" +def test_the_histogram_recomputes_when_bins_changes(local_server: TestServerSession): + local_server.set_inputs(bins=9) + counts = local_server.get_output("dist_data").value["counts"] + assert counts == [16, 37, 30, 16, 14, 57, 67, 29, 6] + assert local_server.get_output("dist_caption") == "272 eruptions in 9 bins" ``` +The fixture is function-scoped, so each test gets a fresh session. Call +`test_server()` directly (as a context manager — it returns an *unstarted* +session) when the fixture cannot express what you need: a server function or +`App` object, `client_data=`, `timeout_secs=`. + `get_output()` returns a value that compares equal to the underlying one, so assert on it directly; use `.value` when you need to index into it, and `.status` (`"ok"` / `"error"` / `"silent"`) or `.error` to assert the @@ -127,9 +135,6 @@ non-value outcomes. Traditional renderers are readable too, so `@render.data_frame` / `@render_plotly` outputs mounted through `ShinyOutput` can be checked at the wire level. -Pass an **absolute `Path`**: a relative one resolves against the test file's -directory, and the app is a directory up from `tests/`. - Four things to know, all of them shinyreact-specific: - **An untyped input id needs no `:shinyreact.default` suffix.** The hook @@ -145,16 +150,17 @@ Four things to know, all of them shinyreact-specific: - **An unset input means `status == "silent"`, not a `None` value.** `input.x()` raises a silent exception while unset, so a `if x is None:` branch in your server is unreachable from a real client — assert the status - instead. (A *later* `req()` failure is not visible in memory at all: - py-shiny#2492.) + instead. A later `req()` failure reports `"silent"` too: the status describes + the *latest* render, matching the blank the browser shows. -Module ids can be read as the session sees them (`ts.get_output("counter-n")`) -or through a scope, which strips the namespace on the way in and out: +Module ids can be read as the session sees them +(`local_server.get_output("counter-n")`) or through a scope, which strips the +namespace on the way in and out: ```python -with ts.make_scope("counter") as counter: - counter.set_inputs(n=7) - assert counter.get_output("label") == "n=7" +counter = local_server.make_scope("counter") +counter.set_inputs(n=7) +assert counter.get_output("label") == "n=7" ``` ## The jsdom layer — mount the client the app ships diff --git a/pkg-py/src/shinyreact/.agents/skills/shinyreact-convert-app/SKILL.md b/pkg-py/src/shinyreact/.agents/skills/shinyreact-convert-app/SKILL.md index c818492..98163c5 100644 --- a/pkg-py/src/shinyreact/.agents/skills/shinyreact-convert-app/SKILL.md +++ b/pkg-py/src/shinyreact/.agents/skills/shinyreact-convert-app/SKILL.md @@ -151,7 +151,7 @@ rendered), then the rest of the outputs, then layout and polish. Four layers, cheapest first: factor pure logic out of the app file so it is importable and test it directly; drive the ported server with no browser — -`[r]` `shiny::testServer()`, `[py]` `shiny.testserver.test_server()` — which +`[r]` `shiny::testServer()`, `[py]` the `local_server` pytest fixture — which for a `ui.tsx` app covers most of it, since the server is only reactive computation; test the client by evaluating the real `www/ui.js` against a fake `window.Shiny` in jsdom (not by importing the component — that tests a copy the diff --git a/pkg-py/tests/test_in_memory_server.py b/pkg-py/tests/test_in_memory_server.py index 595efe2..d1ca6b3 100644 --- a/pkg-py/tests/test_in_memory_server.py +++ b/pkg-py/tests/test_in_memory_server.py @@ -1,15 +1,17 @@ """Server-side behavior of the ui.tsx pattern, driven in memory. -`shiny.testserver.test_server()` (py-shiny#2470) runs an app's server function -against a mock connection: inputs in, output values out, no browser and no -subprocess. That suits shinyreact particularly well — a ui.tsx server is *only* -reactive computation, so what a test wants to assert is the JSON that reaches +Shiny's `local_server` pytest fixture (py-shiny#2470) is an already-started +`test_server()` session: it runs an app's server function against a mock +connection — inputs in, output values out, no browser and no subprocess. That +suits shinyreact particularly well — a ui.tsx server is *only* reactive +computation, so what a test wants to assert is the JSON that reaches `useShinyOutputValue()`, which is exactly what `get_output()` hands back. -The apps here are the Playwright fixture apps, reused as-is: these tests pin -the server half of claims whose client half still needs a browser (see -`pkg-py/tests/playwright/`). `test_server` needs no `tests-e2e` extras, so this -file lives in the unit suite. +The fixture defaults to `app.py` beside the test file; the apps here are the +Playwright fixture apps, reused as-is, so each test points the fixture at one +with an indirect parametrization. These tests pin the server half of claims +whose client half still needs a browser (see `pkg-py/tests/playwright/`). The +fixture needs no `tests-e2e` extras, so this file lives in the unit suite. Two shinyreact-specific notes for anyone adding to this file: @@ -26,67 +28,69 @@ from __future__ import annotations -from pathlib import Path +import pytest +from shiny.testserver import TestServerSession -from shiny.testserver import test_server +MODULE_COUNTER = pytest.mark.parametrize( + "local_server", ["playwright/apps/module_counter/app.py"], indirect=True +) +OUTPUT_ERROR = pytest.mark.parametrize( + "local_server", ["playwright/apps/output-error/app.py"], indirect=True +) -APPS = Path(__file__).parent / "playwright" / "apps" - -def test_reactive_output_publishes_raw_json() -> None: +@MODULE_COUNTER +def test_reactive_output_publishes_raw_json(local_server: TestServerSession) -> None: """The value a `reactive_output` returns reaches the client unwrapped. `test_reactive_output.py` asserts this through `Renderer.transform()`; here it goes through a real session, so nothing between the render function and the wire can re-wrap it. """ - with test_server(APPS / "module_counter" / "app.py") as ts: - ts.set_inputs(**{"a-count": 3}) - assert ts.get_output("a-serverCount") == 3 + local_server.set_inputs(**{"a-count": 3}) + assert local_server.get_output("a-serverCount") == 3 -def test_module_namespaces_stay_isolated() -> None: +@MODULE_COUNTER +def test_module_namespaces_stay_isolated(local_server: TestServerSession) -> None: """In-memory twin of `playwright/test_module_namespaces.py`. A scope is the test-side counterpart of the `SessionProxy` a module server receives, so the module's ids can be read bare. """ - with test_server(APPS / "module_counter" / "app.py") as ts: - with ts.make_scope("a") as a: - a.set_inputs(count=2) - assert a.get_output("serverCount") == 2 + a = local_server.make_scope("a") + a.set_inputs(count=2) + assert a.get_output("serverCount") == 2 - # Namespace isolation: counter "b" is untouched, and its output has not - # rendered at all. - assert ts.get_output("b-serverCount").status == "silent" - assert ts.get_output("a-serverCount") == 2 + # Namespace isolation: counter "b" is untouched, and its output has not + # rendered at all. + assert local_server.get_output("b-serverCount").status == "silent" + assert local_server.get_output("a-serverCount") == 2 -def test_output_error_statuses() -> None: +@OUTPUT_ERROR +def test_output_error_statuses(local_server: TestServerSession) -> None: """Server half of `playwright/test_output_error.py`. Which of the three outcomes an input produces — a value, a silent `req()`, or an error message — is decided server-side; only the rendering of each needs the browser. """ - with test_server(APPS / "output-error" / "app.py") as ts: - ts.set_inputs(n=1) - assert ts.get_output("answer") == "ok: 1" - assert ts.get_output("answer").status == "ok" - - # A *silent* req() failure is invisible in memory: real Shiny sends a - # null value that blanks the output (asserted in the e2e test), but - # `test_server` leaves the previous value recorded, so `status` stays - # "ok" with the stale "ok: 1". "silent" means "never rendered", not - # "rendered nothing this time" — don't assert silence here. - ts.set_inputs(n=-1) - assert ts.get_output("answer") == "ok: 1" - - ts.set_inputs(n=0) - assert ts.get_output("answer").status == "error" - assert ts.get_output("answer").error == "invalid number of 'breaks'" - - # Recovering clears the error. - ts.set_inputs(n=2) - assert ts.get_output("answer") == "ok: 2" - assert ts.get_output("answer").error is None + local_server.set_inputs(n=1) + assert local_server.get_output("answer") == "ok: 1" + assert local_server.get_output("answer").status == "ok" + + # A silent req() failure blanks the output: `status` describes the *latest* + # render, so it reports "silent" with no value rather than the stale + # "ok: 1" — matching the blank the browser shows (asserted in the e2e test). + local_server.set_inputs(n=-1) + assert local_server.get_output("answer").status == "silent" + + local_server.set_inputs(n=0) + assert local_server.get_output("answer").status == "error" + assert local_server.get_output("answer").error == "invalid number of 'breaks'" + + # Recovering clears the error. + local_server.set_inputs(n=2) + assert local_server.get_output("answer") == "ok: 2" + assert local_server.get_output("answer").error is None diff --git a/pkg-r/inst/skills/shinyreact-build-app/SKILL.md b/pkg-r/inst/skills/shinyreact-build-app/SKILL.md index 0a225be..a204ff7 100644 --- a/pkg-r/inst/skills/shinyreact-build-app/SKILL.md +++ b/pkg-r/inst/skills/shinyreact-build-app/SKILL.md @@ -396,13 +396,13 @@ Do not stop at "the code is written". Three steps, in order: Both languages drive the reactive graph with no browser, which for a `ui.tsx` app is most of the server: `[r]` `shiny::testServer()` (`session$setInputs(bins -= 9)`, then assert on `output$dist_data`) and `[py]` -`shiny.testserver.test_server()` (`ts.set_inputs(bins=9)`, then -`ts.get_output("dist_data")`). Either way the value you assert is the JSON the += 9)`, then assert on `output$dist_data`) and `[py]` the built-in +`local_server` pytest fixture (`local_server.set_inputs(bins=9)`, then +`local_server.get_output("dist_data")`). Either way the value you assert is the JSON the client would have received. [`references/testing.md`](references/testing.md) has the four layers, the test -layout for each language, `testServer()` / `test_server()` for plain and module +layout for each language, `testServer()` / `local_server` for plain and module servers — including the input ids that need a `:type` suffix and the event inputs that need two `set_inputs` calls — how to mount the real `www/ui.js` against a fake Shiny, and the traps that cost time (React ignores raw `change` diff --git a/pkg-r/inst/skills/shinyreact-build-app/references/testing.md b/pkg-r/inst/skills/shinyreact-build-app/references/testing.md index 245ede5..00a9551 100644 --- a/pkg-r/inst/skills/shinyreact-build-app/references/testing.md +++ b/pkg-r/inst/skills/shinyreact-build-app/references/testing.md @@ -5,7 +5,7 @@ Do not stop at "the code is written". At minimum: 1. **Factor pure logic out of the app file.** Binning, formatting, conversions go in a module beside the app so a test can import them directly, with no session at all. Logic left inside `app.py` / `app.R` next to the page call - is still reachable — `testServer()` / `test_server()` below drive the app + is still reachable — `testServer()` / `local_server` below drive the app itself — but only through an input, which is a slower and blunter tool than calling a function. 2. **Write down what the app does, in plain English, before the tests** — a @@ -19,7 +19,7 @@ Do not stop at "the code is written". At minimum: | Layer | Proves | Cost | |---|---|---| | pure functions in their own module | binning, formatting, conversions | trivial — always do this | -| `shiny::testServer()` `[r]` / `shiny.testserver.test_server()` `[py]` | the reactive graph: inputs in, `reactive_output` values out | low, and no browser | +| `shiny::testServer()` `[r]` / the `local_server` fixture `[py]` | the reactive graph: inputs in, `reactive_output` values out | low, and no browser | | the client mounted in jsdom against a fake Shiny | rendering, input wiring, wire ids, status handling | low, and it exercises the file the app ships | | Playwright | layout, real Shiny, real bindings | high; reserve for what the others cannot see | @@ -35,7 +35,7 @@ myapp/ www/ui.js tests/ test_faithful.py [py] pytest — the factored logic, called directly - test_outputs.py [py] pytest — the app, via test_server() + test_outputs.py [py] pytest — the app, via `local_server` testthat.R [r] runner: library(testthat); test_dir("testthat") testthat/ test-histogram.R [r] @@ -62,7 +62,7 @@ sys.path.insert(0, str(EXAMPLE)) from faithful import histogram, waiting # noqa: E402 ``` -## Testing the server: `testServer()` `[r]`, `test_server()` `[py]` +## Testing the server: `testServer()` `[r]`, `local_server` `[py]` **This is the highest-value layer for a `ui.tsx` app.** The server contains only reactive computation, so "input X produces output Y" *is* the server, and @@ -100,26 +100,34 @@ which is most of what a shinyreact server does. Module servers work the same way: `testServer(card_server, args = list(id = "left"), { ... })`. -### `[py]` `shiny.testserver.test_server()` +### `[py]` the `local_server` fixture -The Python counterpart (py-shiny#2470, so newer than shiny 1.7.0). It loads the -app file — Express or Core, `shiny.App` or `shinyreact.ReactApp` — and runs its -server against a mock connection: +The Python counterpart (py-shiny#2470, so newer than shiny 1.7.0). `local_server` +is a built-in pytest fixture — nothing to import — holding an already-started +`shiny.testserver.test_server()` session. It loads the app file — Express or +Core, `shiny.App` or `shinyreact.ReactApp` — and runs its server against a mock +connection: ```python -from pathlib import Path -from shiny.testserver import test_server +import pytest +from shiny.testserver import TestServerSession -APP = Path(__file__).resolve().parents[1] / "app.py" +# The fixture defaults to `app.py` beside the test file; ours is a directory up. +pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True) -def test_the_histogram_recomputes_when_bins_changes(): - with test_server(APP) as ts: - ts.set_inputs(bins=9) - assert ts.get_output("dist_data").value["counts"] == [16, 37, 30, 16, 14, 57, 67, 29, 6] - assert ts.get_output("dist_caption") == "272 eruptions in 9 bins" +def test_the_histogram_recomputes_when_bins_changes(local_server: TestServerSession): + local_server.set_inputs(bins=9) + counts = local_server.get_output("dist_data").value["counts"] + assert counts == [16, 37, 30, 16, 14, 57, 67, 29, 6] + assert local_server.get_output("dist_caption") == "272 eruptions in 9 bins" ``` +The fixture is function-scoped, so each test gets a fresh session. Call +`test_server()` directly (as a context manager — it returns an *unstarted* +session) when the fixture cannot express what you need: a server function or +`App` object, `client_data=`, `timeout_secs=`. + `get_output()` returns a value that compares equal to the underlying one, so assert on it directly; use `.value` when you need to index into it, and `.status` (`"ok"` / `"error"` / `"silent"`) or `.error` to assert the @@ -127,9 +135,6 @@ non-value outcomes. Traditional renderers are readable too, so `@render.data_frame` / `@render_plotly` outputs mounted through `ShinyOutput` can be checked at the wire level. -Pass an **absolute `Path`**: a relative one resolves against the test file's -directory, and the app is a directory up from `tests/`. - Four things to know, all of them shinyreact-specific: - **An untyped input id needs no `:shinyreact.default` suffix.** The hook @@ -145,16 +150,17 @@ Four things to know, all of them shinyreact-specific: - **An unset input means `status == "silent"`, not a `None` value.** `input.x()` raises a silent exception while unset, so a `if x is None:` branch in your server is unreachable from a real client — assert the status - instead. (A *later* `req()` failure is not visible in memory at all: - py-shiny#2492.) + instead. A later `req()` failure reports `"silent"` too: the status describes + the *latest* render, matching the blank the browser shows. -Module ids can be read as the session sees them (`ts.get_output("counter-n")`) -or through a scope, which strips the namespace on the way in and out: +Module ids can be read as the session sees them +(`local_server.get_output("counter-n")`) or through a scope, which strips the +namespace on the way in and out: ```python -with ts.make_scope("counter") as counter: - counter.set_inputs(n=7) - assert counter.get_output("label") == "n=7" +counter = local_server.make_scope("counter") +counter.set_inputs(n=7) +assert counter.get_output("label") == "n=7" ``` ## The jsdom layer — mount the client the app ships diff --git a/pkg-r/inst/skills/shinyreact-convert-app/SKILL.md b/pkg-r/inst/skills/shinyreact-convert-app/SKILL.md index c818492..98163c5 100644 --- a/pkg-r/inst/skills/shinyreact-convert-app/SKILL.md +++ b/pkg-r/inst/skills/shinyreact-convert-app/SKILL.md @@ -151,7 +151,7 @@ rendered), then the rest of the outputs, then layout and polish. Four layers, cheapest first: factor pure logic out of the app file so it is importable and test it directly; drive the ported server with no browser — -`[r]` `shiny::testServer()`, `[py]` `shiny.testserver.test_server()` — which +`[r]` `shiny::testServer()`, `[py]` the `local_server` pytest fixture — which for a `ui.tsx` app covers most of it, since the server is only reactive computation; test the client by evaluating the real `www/ui.js` against a fake `window.Shiny` in jsdom (not by importing the component — that tests a copy the diff --git a/pkg-r/tests/testthat/test-render.R b/pkg-r/tests/testthat/test-render.R index 0bffdcc..09e46bc 100644 --- a/pkg-r/tests/testthat/test-render.R +++ b/pkg-r/tests/testthat/test-render.R @@ -87,9 +87,8 @@ test_that("reactive_output surfaces errors and silent errors to the test", { # Mirrors test_output_error_statuses in pkg-py/tests/test_in_memory_server.py # -- with a deliberate divergence in the *testing* API, not in shinyreact: # R's testServer() re-raises, so reading the output is the assertion, while - # Python's test_server() reports `.status` / `.error` instead. Python also - # keeps the previous value after a silent error where R does not - # (posit-dev/py-shiny#2492). + # Python's `local_server` reports `.status` / `.error` instead. Both drop the + # previous value after a silent error (posit-dev/py-shiny#2492). server <- function(input, output, session) { output$answer <- reactive_output({ n <- input$n