Testing Example Apps
Overview
Component example apps (components/**/app.py, components/**/app-*.py) are the source
of truth for each page — the shinylive links in index.qmd are generated from them. Smoke
coverage (it loads with no server, JS, or output errors) is centralized: a single
parametrized components/test_examples_smoke.py auto-discovers and smoke-tests every such app,
one test_example_app_smoke[<relative path>] case per app. This skill covers adding
py-shiny Playwright
interaction tests for the primary Core + Express apps via py-shiny's controllers — you
do not write per-component smoke tests.
Everything uses the public shiny package API — shiny.pytest.create_app_fixture,
shiny.playwright.controller, shiny.run.ShinyAppProc. No custom test runner.
Reference implementation: components/layout/accordion/test_accordion.py.
Shared infrastructure (already in place — do not re-create)
pytest.ini(repo root):testpaths = componentsscopes pytest to the site's tests and away from thepy-shiny/submodule.components/conftest.pyprovides:create_example_fixture(HERE / "app-NAME.py")— returns a fixture yielding a runningShinyAppProc. Splits multi-file## file:shinylive apps into a temp dir; launches single-file apps directly.smoke_testfixture — a callablesmoke_test(page, app, *, allow_stderr=(), allow_js=())that navigates, waits for Shiny idle, and asserts no un-allow-listed server stderr, no JS console errors, and zero.shiny-output-error.example_app_paths()/launch_example_app()— power the centralized smoke sweep below; you generally don't call these directly from a component'stest_<name>.py.
components/test_examples_smoke.py— smoke-tests EVERY discovered example app (app.py/app-*.py) as its own parametrized case; you do NOT write per-component smoke tests.components/test_component_pages.py— a static (non-browser) check that every component page (components/<section>/<name>/index.qmd) ships at least one example app (app.py/app-*.py). All discovered pages are enforced; a page may only opt out viaEXEMPT_PAGES(normally empty) with a documented reason. Runs undermake test-components-examples.
Steps to test a component
- Create
components/<section>/<name>/test_<name>.pynext to the app files. - Smoke coverage is automatic — just make sure every example app is named
app.pyorapp-<name>.pyso it is discovered. Yourtest_<name>.pycontains only interaction tests: drive the primary Core AND Express apps withshiny.playwright.controller:
Find the controller for your component infrom shiny.playwright import controller def test_core_interaction(page: Page, core_app: ShinyAppProc) -> None: page.goto(core_app.url) acc = controller.Accordion(page, "acc") acc.expect_open(["Section A"]) acc.set(["Section B"]) acc.expect_open(["Section B"])py-shiny/shiny/playwright/controller/(e.g.Accordion,InputSelect,OutputCode,Card,Sidebar, ...).
Value readouts: use output_code, not output_text_verbatim
Example apps that display a value (e.g. the currently open panels) should render it with
ui.output_code(...) / @render.code — the project's preferred verbatim output. Assert
on it with controller.OutputCode(page, "<id>").
Do NOT use controller.OutputTextVerbatim unless the component under test IS
ui.output_text_verbatim (i.e. you are on the output-text-verbatim component page
itself). Using it elsewhere silently fails to match output_code's <pre> and, worse,
encourages example apps to diverge (a bare Express @render.text renders a <div>, not a
<pre> — inconsistent with a Core output_text_verbatim). Standardize on output_code.
The one exception is the outputs/verbatim-text/ component — it documents
ui.output_text_verbatim itself, so its example apps use ui.output_text_verbatim /
@render.text (and would assert with controller.OutputTextVerbatim), not output_code.
Running
pytest.ini sets --browser chromium -n auto as defaults, so you never pass them.
# Make targets (install deps + chromium, then run)
make test-components-smoke # smoke sweep over every app
make test-components-examples # per-component interaction tests
make test-components-smoke PYTEST_ARGS='-k "layout/accordion"' # narrow to one component
make test-components-smoke PYTEST_ARGS='--num-shards 6 --shard-id 0' # one shard (CI does this)
Or drive pytest directly via uv run for fast local iteration (uv run
auto-discovers .venv — nothing to activate; chromium + xdist still apply from
pytest.ini; add -n0 to run serially / one app at a time):
uv run pytest components/test_examples_smoke.py # whole smoke sweep
uv run pytest components/test_examples_smoke.py -k "layout/accordion"
uv run pytest components/layout/accordion/test_accordion.py # one component's interaction tests
Rules
- Test the raw
app-*.pyfiles, never the shinylive URLs (they are derived). After editing anyapp-*.py, regenerate the page's shinylive links (make docs-update-shinylive-links FILES="components/<section>/<name>/index.qmd") and commit the updatedindex.qmd, or thetest-docsCI workflow fails. - Display values with
output_code/@render.code; only usecontroller.OutputTextVerbatimwhen the component under test is itselfoutput_text_verbatim. - In Core apps, pair matching output/render names:
ui.output_code("id")↔@render.codenamedid(neverui.output_code+@render.text). Everyui.output_<X>has a same-named@render.<X>. - Known-benign warnings: pass them per test via
smoke_test(..., allow_stderr=[...])— do NOT broaden the global default inconftest.py. - Never add
*.pyto_quarto.ymlresources:—.pyfiles are already excluded from_build/. - Name every example app
app.pyorapp-<name>.py— the smoke collector (components/test_examples_smoke.py) discovers apps by that convention; a differently named file is silently untested. - Smoke coverage is automatic for every discovered
app.py/app-*.py; per-componenttest_<name>.pyfiles add interaction tests for the primary Core + Express apps.