Skip to content

Commit cf423f8

Browse files
docs(testing): Add quarto page for testing (#1461)
Co-authored-by: Barret Schloerke <barret@posit.co>
1 parent a70a145 commit cf423f8

File tree

13 files changed

+315
-48
lines changed

13 files changed

+315
-48
lines changed

docs/.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,4 @@ _sidebar.yml
55
/.quarto/
66
objects.json
77
site_libs/
8-
_objects_core.json
9-
_objects_express.json
8+
_objects_*.json

docs/Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ deps: $(PYBIN) dev-htmltools dev-shinylive ## Install build dependencies
4949
$(PYBIN)/pip install pip --upgrade
5050
$(PYBIN)/pip install ..[doc]
5151

52-
quartodoc: quartodoc_build_core quartodoc_build_express quartodoc_post ## Build quartodocs for express and core
52+
quartodoc: quartodoc_build_core quartodoc_build_express quartodoc_build_test quartodoc_post ## Build quartodocs for express and core
5353

5454
## Build interlinks for API docs
5555
quartodoc_interlinks: $(PYBIN)
@@ -78,6 +78,14 @@ quartodoc_build_express: $(PYBIN) quartodoc_interlinks
7878
&& mv objects.json _objects_express.json \
7979
&& echo "::endgroup::"
8080

81+
## Build test API docs
82+
quartodoc_build_test: $(PYBIN) quartodoc_interlinks
83+
. $(PYBIN)/activate \
84+
&& echo "::group::quartodoc build testing docs" \
85+
&& quartodoc build --config _quartodoc-testing.yml --verbose \
86+
&& mv objects.json _objects_test.json \
87+
&& echo "::endgroup::"
88+
8189
## Clean up after quartodoc build
8290
quartodoc_post: $(PYBIN)
8391
. $(PYBIN)/activate \

docs/_combine_objects_json.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ def write_objects_file(objects: QuartodocObject, path: str) -> None:
4040
print("\nCombining objects json files...")
4141
objects_core = read_objects_file("_objects_core.json")
4242
objects_express = read_objects_file("_objects_express.json")
43+
objects_test = read_objects_file("_objects_test.json")
4344

4445
items_map: dict[str, QuartodocObjectItem] = {}
4546

46-
for item in [*objects_core.items, *objects_express.items]:
47+
for item in [*objects_core.items, *objects_express.items, *objects_test.items]:
4748
if item.name in items_map:
4849
continue
4950
items_map[item.name] = item
@@ -58,6 +59,7 @@ def write_objects_file(objects: QuartodocObject, path: str) -> None:
5859

5960
print("Core:", objects_core.count)
6061
print("Express:", objects_express.count)
62+
print("Testing:", objects_test.count)
6163
print("Combined:", objects_ret.count)
6264

6365
# Save combined objects file info

docs/_quarto.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ website:
2222
file: api/express/index.qmd
2323
- text: "Core API"
2424
file: api/core/index.qmd
25+
- text: "Testing API"
26+
file: api/test/index.qmd
2527
right:
2628
- icon: github
2729
href: https://github.com/posit-dev/py-shiny

docs/_quartodoc-testing.yml

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
quartodoc:
2+
style: pkgdown
3+
dir: api/testing
4+
out_index: index.qmd
5+
package: shiny
6+
rewrite_all_pages: false
7+
sidebar: api/testing/_sidebar.yml
8+
dynamic: false
9+
renderer:
10+
style: _renderer.py
11+
show_signature_annotations: false
12+
sections:
13+
- title: UI Layouts
14+
desc: Methods for interacting with Shiny app multiple UI component controls.
15+
contents:
16+
- playwright.controls.Accordion
17+
- playwright.controls.AccordionPanel
18+
- playwright.controls.Card
19+
- playwright.controls.Popover
20+
- playwright.controls.Sidebar
21+
- playwright.controls.Tooltip
22+
- title: UI Inputs
23+
desc: Methods for interacting with Shiny app input value controls.
24+
contents:
25+
- playwright.controls.InputActionLink
26+
- playwright.controls.InputCheckbox
27+
- playwright.controls.InputCheckboxGroup
28+
- playwright.controls.InputDarkMode
29+
- playwright.controls.InputDate
30+
- playwright.controls.InputDateRange
31+
- playwright.controls.InputFile
32+
- playwright.controls.InputNumeric
33+
- playwright.controls.InputPassword
34+
- playwright.controls.InputRadioButtons
35+
- playwright.controls.InputSelect
36+
- playwright.controls.InputSelectize
37+
- playwright.controls.InputSlider
38+
- playwright.controls.InputSliderRange
39+
- playwright.controls.InputSwitch
40+
- playwright.controls.InputTaskButton
41+
- playwright.controls.InputText
42+
- playwright.controls.InputTextArea
43+
- title: Value boxes
44+
desc: Methods for interacting with Shiny app valuebox controls.
45+
contents:
46+
- playwright.controls.ValueBox
47+
- title: Navigation (tab) panels
48+
desc: Methods for interacting with Shiny app UI content controls.
49+
contents:
50+
- playwright.controls.NavItem
51+
- playwright.controls.NavsetBar
52+
- playwright.controls.NavsetCardPill
53+
- playwright.controls.NavsetCardTab
54+
- playwright.controls.NavsetCardUnderline
55+
- playwright.controls.NavsetHidden
56+
- playwright.controls.NavsetPill
57+
- playwright.controls.NavsetPillList
58+
- playwright.controls.NavsetTab
59+
- playwright.controls.NavsetUnderline
60+
- title: Upload and download
61+
desc: Methods for interacting with Shiny app uploading and downloading controls.
62+
contents:
63+
- playwright.controls.DownloadButton
64+
- playwright.controls.DownloadLink
65+
- title: Rendering outputs
66+
desc: Render output in a variety of ways.
67+
contents:
68+
- playwright.controls.OutputCode
69+
- playwright.controls.OutputDataFrame
70+
- playwright.controls.OutputImage
71+
- playwright.controls.OutputPlot
72+
- playwright.controls.OutputTable
73+
- playwright.controls.OutputText
74+
- playwright.controls.OutputTextVerbatim
75+
- playwright.controls.OutputUi
76+
- title: "Playwright Expect"
77+
desc: "Methods for testing the state of a locator within a Shiny app."
78+
contents:
79+
- playwright.expect.expect_to_change
80+
- playwright.expect.expect_attribute_to_have_value
81+
- playwright.expect.expect_to_have_class
82+
- playwright.expect.expect_not_to_have_class
83+
- playwright.expect.expect_to_have_style
84+
- title: "Pytest"
85+
desc: "Fixtures used for testing Shiny apps with Pytest."
86+
contents:
87+
- pytest.create_app_fixture
88+
- pytest.ScopeName
89+
- title: "Run"
90+
desc: "Methods for starting a local Shiny app in the background"
91+
contents:
92+
- run.run_shiny_app
93+
- run.ShinyAppProc

shiny/_main.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,45 @@ def try_import_module(module: str) -> Optional[types.ModuleType]:
503503
}
504504

505505

506+
@main.group(help="""Add files to enhance your Shiny app.""")
507+
def add() -> None:
508+
pass
509+
510+
511+
@add.command(
512+
help="""Add a test file for a specified Shiny app.
513+
514+
Add an empty test file for a specified app. You will be prompted with a destination
515+
folder. If you don't provide a destination folder, it will be added in the current
516+
working directory based on the app name.
517+
518+
After creating the shiny app file, you can use `pytest` to run the tests:
519+
520+
pytest TEST_FILE
521+
"""
522+
)
523+
@click.option(
524+
"--app",
525+
"-a",
526+
type=str,
527+
help="Please provide the path to the app file for which you want to create a test file.",
528+
)
529+
@click.option(
530+
"--test-file",
531+
"-t",
532+
type=str,
533+
help="Please provide the name of the test file you want to create. The basename of the test file should start with `test_` and be unique across all test files.",
534+
)
535+
# Param for app.py, param for test_name
536+
def test(
537+
app: Path | None,
538+
test_file: Path | None,
539+
) -> None:
540+
from ._template_utils import add_test_file
541+
542+
add_test_file(app_file=app, test_file=test_file)
543+
544+
506545
@main.command(
507546
help="""Create a Shiny application from a template.
508547

shiny/_template_utils.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,3 +322,111 @@ def rename_unlink(file_to_rename: str, file_to_delete: str, dir: Path = app_dir)
322322
(app_dir / "app-core.py").rename(app_dir / "app.py")
323323

324324
return app_dir
325+
326+
327+
def add_test_file(
328+
*,
329+
app_file: Path | None,
330+
test_file: Path | None,
331+
):
332+
333+
if app_file is None:
334+
335+
def path_exists(x: Path) -> bool | str:
336+
if not isinstance(x, (str, Path)):
337+
return False
338+
if Path(x).is_dir():
339+
return "Please provide a file path to your Shiny app"
340+
return Path(x).exists() or f"Shiny app file can not be found: {x}"
341+
342+
app_file_val = questionary.path(
343+
"Enter the path to the app file:",
344+
default=build_path_string("app.py"),
345+
validate=path_exists,
346+
).ask()
347+
else:
348+
app_file_val = app_file
349+
# User quit early
350+
if app_file_val is None:
351+
sys.exit(1)
352+
app_file = Path(app_file_val)
353+
354+
if test_file is None:
355+
356+
def path_does_not_exist(x: Path) -> bool | str:
357+
if not isinstance(x, (str, Path)):
358+
return False
359+
if Path(x).is_dir():
360+
return "Please provide a file path for your test file."
361+
if Path(x).exists():
362+
return "Test file already exists. Please provide a new file name."
363+
if not Path(x).name.startswith("test_"):
364+
return "Test file must start with 'test_'"
365+
return True
366+
367+
test_file_val = questionary.path(
368+
"Enter the path to the test file:",
369+
default=build_path_string(
370+
os.path.relpath(app_file.parent / "tests" / "test_app.py", ".")
371+
),
372+
validate=path_does_not_exist,
373+
).ask()
374+
else:
375+
test_file_val = test_file
376+
377+
# User quit early
378+
if test_file_val is None:
379+
sys.exit(1)
380+
test_file = Path(test_file_val)
381+
382+
# Make sure app file exists
383+
if not app_file.exists():
384+
raise FileExistsError("App file does not exist: ", test_file)
385+
# Make sure output test file doesn't exist
386+
if test_file.exists():
387+
raise FileExistsError("Test file already exists: ", test_file)
388+
if not test_file.name.startswith("test_"):
389+
return "Test file must start with 'test_'"
390+
391+
# if app path directory is the same as the test file directory, use `local_app`
392+
# otherwise, use `create_app_fixture`
393+
is_same_dir = app_file.parent == test_file.parent
394+
395+
test_name = test_file.name.replace(".py", "")
396+
rel_path = os.path.relpath(app_file, test_file.parent)
397+
398+
template = (
399+
f"""\
400+
from playwright.sync_api import Page
401+
402+
from shiny.playwright.controls import <IMPORT REQUIRED CONTROLS>
403+
from shiny.run import ShinyAppProc
404+
405+
406+
def {test_name}(page: Page, local_app: ShinyAppProc):
407+
408+
page.goto(local_app.url)
409+
# Add tests code here
410+
"""
411+
if is_same_dir
412+
else f"""\
413+
from playwright.sync_api import Page
414+
415+
from shiny.playwright.controls import <IMPORT REQUIRED CONTROLS>
416+
from shiny.pytest import create_app_fixture
417+
from shiny.run import ShinyAppProc
418+
419+
app = create_app_fixture("{rel_path}")
420+
421+
422+
def {test_name}(page: Page, app: ShinyAppProc):
423+
424+
page.goto(app.url)
425+
# Add tests code here
426+
"""
427+
)
428+
# Make sure test file directory exists
429+
test_file.parent.mkdir(parents=True, exist_ok=True)
430+
431+
# Write template to test file
432+
test_file.write_text(template)

shiny/playwright/controls/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from ._controls import (
22
Accordion,
3+
AccordionPanel,
34
Card,
45
DownloadButton,
56
DownloadLink,
@@ -77,6 +78,7 @@
7778
"ValueBox",
7879
"Card",
7980
"Accordion",
81+
"AccordionPanel",
8082
"Sidebar",
8183
"Popover",
8284
"Tooltip",

0 commit comments

Comments
 (0)