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
23 changes: 13 additions & 10 deletions .claude/skills/shinyreact-build-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,8 @@ a list. Use `type: "shinyreact.asis"` for the parsed value untouched.
Do not stop at "the code is written". Three steps, in order:

1. **Factor pure logic out of the app file** — binning, formatting,
conversions go in a module beside the app. Logic inside `app.py` /
`app.R` next to the page call cannot be reached by a test at all.
conversions go in a module beside the app, where a test can import them
without starting a session at all.
2. **Write down what the app does, in plain English, before the tests.** An
agent that writes the client and then writes the client's tests is
agreeing with itself — both encode the same misunderstanding. A
Expand All @@ -394,16 +394,19 @@ Do not stop at "the code is written". Three steps, in order:
directory** — `pytest`, or `[r]` `shiny::runTests()`, which needs the
`tests/testthat.R` + `tests/testthat/` layout.

`[r]` `shiny::testServer()` drives the reactive graph with no browser:
`session$setInputs(bins = 9)` then assert on `output$dist_data`, which is the
JSON value the client would have received. **`[py]` has no equivalent**, so
factoring logic into an importable module matters more there.
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
client would have received.

[`references/testing.md`](references/testing.md) has the four layers, the test
layout for each language, `testServer()` for plain and module servers, how to
mount the real `www/ui.js` against a fake Shiny, and the traps that cost time
(React ignores raw `change` events; debounce coalesces within a tick even at
`debounceMs: 0`).
layout for each language, `testServer()` / `test_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`
events; debounce coalesces within a tick even at `debounceMs: 0`).

## Debugging

Expand Down
86 changes: 74 additions & 12 deletions .claude/skills/shinyreact-build-app/references/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
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; logic sitting
inside `app.py` / `app.R` next to the page call cannot be reached at all.
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
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
behavior file beside the app, one atomically checkable claim per line
("the caption reads `N eruptions in M bins`, singular `bin` at M=1"). An
Expand All @@ -16,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 |
| `[r]` `shiny::testServer()` | the reactive graph: inputs in, `reactive_output` values out | low, and no browser |
| `shiny::testServer()` `[r]` / `shiny.testserver.test_server()` `[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 @@ -31,7 +34,8 @@ myapp/
faithful.py the factored logic
www/ui.js
tests/
test_faithful.py [py] pytest
test_faithful.py [py] pytest — the factored logic, called directly
test_outputs.py [py] pytest — the app, via test_server()
testthat.R [r] runner: library(testthat); test_dir("testthat")
testthat/
test-histogram.R [r]
Expand All @@ -58,11 +62,17 @@ sys.path.insert(0, str(EXAMPLE))
from faithful import histogram, waiting # noqa: E402
```

## `[r]` Testing the server with `testServer()`
## Testing the server: `testServer()` `[r]`, `test_server()` `[py]`

`reactive_output` is an ordinary Shiny render function, so `shiny::testServer()`
drives it with no browser and no client: set inputs, read outputs, and get the
JSON value the client would have received.
**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
both languages can assert it with no browser and no client: set inputs, read
outputs, and get the JSON value the client would have received.

### `[r]` `shiny::testServer()`

`reactive_output` is an ordinary Shiny render function, so `testServer()`
drives it directly.

```r
test_that("the histogram recomputes when bins changes", {
Expand Down Expand Up @@ -90,10 +100,62 @@ which is most of what a shinyreact server does.
Module servers work the same way: `testServer(card_server, args = list(id =
"left"), { ... })`.

**`[py]` has no `testServer()` equivalent.** Cover the Python server by
factoring its logic into a module and testing that with pytest, and let the
jsdom and Playwright layers cover the wiring. This is a real gap, not an
oversight in your app.
### `[py]` `shiny.testserver.test_server()`

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:

```python
from pathlib import Path
from shiny.testserver import test_server

APP = Path(__file__).resolve().parents[1] / "app.py"


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"
```

`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
appends it on the wire, but both Python handlers are no-ops, so
`set_inputs(bins=9)` is equivalent.
- **A typed one does.** `set_inputs(**{"when:shiny.datetime": 1756382400})` is
what makes the handler run and `input.when()` a `datetime`; without the
suffix you are testing a different app than the one the client drives.
- **An event input needs two calls.** `useShinyInput` registers its default at
mount and sends the event after, so an output behind
`@reactive.event(..., ignore_init=True)` only fires on the *second*
`set_inputs` for that id.
- **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.)

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:

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

## The jsdom layer — mount the client the app ships

Expand Down
16 changes: 10 additions & 6 deletions .claude/skills/shinyreact-convert-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,16 @@ rendered), then the rest of the outputs, then layout and polish.

## Phase 5 — verify

Three layers, cheapest first: factor pure logic out of the app file so it is
importable and test it directly; 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 app does not ship); and reserve Playwright
for layout and real bindings, which the other two structurally cannot see. The
`shinyreact-build-app` skill's `references/testing.md` has the traps.
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
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
app does not ship); and reserve Playwright for layout and real bindings, which
the others structurally cannot see. The `shinyreact-build-app` skill's
[`references/testing.md`](../shinyreact-build-app/references/testing.md) has
the details and the traps.

A port has one advantage a new app does not: **the original still runs.** Where
the logic is a pure transform, capture its output from the original app and
Expand Down
15 changes: 10 additions & 5 deletions examples/01-hello/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ a unit test; `(verify)` marks a claim not yet checked against the code.

## Server

- output `dist_data` → `{breaks: number[], counts: number[]}`
- output `dist_data` → `{breaks: number[], counts: number[]}` `(test)`
- the two keys, in that order, and nothing else `(test)`
- equal-width bins over `[min, max]`: `breaks` has `bins + 1` entries,
`counts` has `bins` `(test)`
- binning matches R's `hist()`: half-open `(lo, hi]` with the first bin
Expand All @@ -35,16 +36,20 @@ a unit test; `(verify)` marks a claim not yet checked against the code.
- `bins = 9` → `counts == [16, 37, 30, 16, 14, 57, 67, 29, 6]`, identical in
R and Python `(test)`
- `[r]` vectors are wrapped in `I()` so a one-bin result serializes as a JSON
array, not a scalar
- output `dist_caption` → `"272 eruptions in N bins"`
array, not a scalar — the value the output delivers carries the `AsIs`
class `(test)`
- output `dist_caption` → `"272 eruptions in N bins"` `(test)`
- singular `"bin"` when `N == 1` `(test)`
- the count is the dataset length, not the bin count
- `[py]` `app.py` (Express) and `app-core.py` (Core) produce identical values
for both outputs — the same `tests/test_outputs.py` runs against each
`(test)`
- before the client's first `bins` message
- `[py]` `input.bins()` raises a silent exception, so neither output produces
a value
a value — not an error, and not a `None` value `(test)`
- `[r]` `input$bins` is `NULL` and both outputs return `NULL` explicitly —
`req()` is deliberately not used, because its silent error still reaches
the client console
the client console `(test)`
- the server never renders an image: no plotting library, no `plotOutput`
placeholder

Expand Down
62 changes: 62 additions & 0 deletions examples/01-hello/tests/test_outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""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
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.

Run it from the app directory, the way a user of the app would::

pytest

An input id needs no `:shinyreact.default` suffix here: the React hook appends
it on the wire, but Python's handler is a no-op, so `set_inputs(bins=9)`
reaches the server identically.
"""

from __future__ import annotations

from pathlib import Path

import pytest
from shiny.testserver import test_server

EXAMPLE = Path(__file__).resolve().parents[1]
APPS = [EXAMPLE / "app.py", EXAMPLE / "app-core.py"]

# 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)


@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"

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]


@pytest.mark.parametrize("app", APPS, ids=lambda p: p.name)
def test_neither_output_renders_before_the_first_bins_message(app: Path) -> 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"
66 changes: 66 additions & 0 deletions examples/01-hello/tests/testthat/test-outputs.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Pins app.R's outputs as the client sees them, using shiny::testServer() --
# inputs in, output values out, with no browser and no client.
#
# test-histogram.R next door pins the *math* by reimplementing app.R's two
# lines, because that logic lives inside server(). This file drives server()
# itself, so it pins what the app actually answers with. The Python
# counterpart is test_outputs.py beside them, which does the same thing with
# shiny.testserver.test_server().
#
# Run it from the app directory, the way a user of the app would:
#
# Rscript -e 'shiny::runTests()'
# Rscript -e 'testthat::test_dir("tests/testthat")'
#
# The shinyreact package's own suite also runs it, via
# pkg-r/tests/testthat/test-examples.R.
#
# app_dir is "../../" from tests/testthat/, the same default AppDriver uses.
app_dir <- testthat::test_path("..", "..")

test_that("dist_data is the {breaks, counts} the client draws", {
shiny::testServer(app_dir, {
session$setInputs(bins = 9)

expect_named(output$dist_data, c("breaks", "counts"))
# Mirrors COUNTS_9 in test_faithful.py and test-histogram.R.
expect_equal(
unclass(output$dist_data$counts),
c(16L, 37L, 30L, 16L, 14L, 57L, 67L, 29L, 6L)
)
expect_equal(unclass(output$dist_data$breaks)[[1]], 43)
expect_equal(unclass(output$dist_data$breaks)[[10]], 96)
})
})

test_that("dist_caption pluralizes on the bin count", {
shiny::testServer(app_dir, {
session$setInputs(bins = 9)
expect_equal(output$dist_caption, "272 eruptions in 9 bins")

session$setInputs(bins = 1)
expect_equal(output$dist_caption, "272 eruptions in 1 bin")
expect_equal(unclass(output$dist_data$counts), 272L)
expect_equal(unclass(output$dist_data$breaks), c(43, 96))
})
})

test_that("the vectors carry I(), so a one-bin result is a JSON array", {
# The class is the behavior: without it toJSON() emits `272` where the
# client expects `[272]`. test-histogram.R pins the serialization; this
# pins that app.R really applies it.
shiny::testServer(app_dir, {
session$setInputs(bins = 1)
expect_s3_class(output$dist_data$counts, "AsIs")
expect_s3_class(output$dist_data$breaks, "AsIs")
})
})

test_that("both outputs are NULL before the client's first bins message", {
# app.R returns NULL explicitly rather than using req(), so there is no
# silent error to reach the client console.
shiny::testServer(app_dir, {
expect_null(output$dist_data)
expect_null(output$dist_caption)
})
})
14 changes: 9 additions & 5 deletions examples/02-columns/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,22 @@ a unit test; `(verify)` marks a claim not yet checked against the code.

## Server (`app.py`, Express)

- output `column_data` → the whole `{col: item[]}` dict
- output `column_data` → the whole `{col: item[]}` dict `(test)`
- it renders from the initial contents before any input arrives `(test)`
- input `move_item` → `{item, from, to}`
- handled by a `@reactive.effect` + `@reactive.event(input.move_item,
ignore_init=True)`, so the initial `null` from mount is ignored
ignore_init=True)`, so the initial `null` from mount is ignored `(test)`
- the move is applied to a copy of the dict, then `columns.set(...)` — the
reactive value is replaced, not mutated in place
- if `item` is not in `data[from]`, nothing changes (no error)
- if `item` is not in `data[from]`, nothing changes (no error) `(test)`
- the item is appended to the end of the destination column, never inserted
`(test)`
- successive moves accumulate `(test)`
- `[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 and no unit test covers it — logic in a module beside `app.py`
would be testable
not importable — `tests/test_moves.py` drives the app itself with
`shiny.testserver.test_server()` instead, sending the mount-time `null`
first so the move is not swallowed as the init

## Client (`www/ui.js`)

Expand Down
Loading
Loading