Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .claude/skills/shinyreact-build-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
58 changes: 32 additions & 26 deletions .claude/skills/shinyreact-build-app/references/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |

Expand All @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -100,36 +100,41 @@ 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
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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/shinyreact-convert-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 26 additions & 29 deletions examples/01-hello/tests/test_outputs.py
Original file line number Diff line number Diff line change
@@ -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::

Expand All @@ -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"
2 changes: 1 addition & 1 deletion examples/02-columns/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
67 changes: 35 additions & 32 deletions examples/02-columns/tests/test_moves.py
Original file line number Diff line number Diff line change
@@ -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::

Expand All @@ -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"],
Expand All @@ -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"]
2 changes: 1 addition & 1 deletion examples/05-temperature/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
Loading
Loading