Skip to content

Commit 647becf

Browse files
authored
feat(fixtures): Add array support for create_app_fixture(app) (#1869)
1 parent 45699fa commit 647becf

File tree

7 files changed

+242
-12
lines changed

7 files changed

+242
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3535

3636
* `ui.input_text()`, `ui.input_text_area()`, `ui.input_numeric()` and `ui.input_password()` all gain an `update_on` option. `update_on="change"` is the default and previous behavior, where the input value updates immediately whenever the value changes. With `update_on="blur"`, the input value will update only when the text input loses focus or when the user presses Enter (or Cmd/Ctrl + Enter for `ui.input_text_area()`). (#1874)
3737

38+
* `shiny.pytest.create_app_fixture(app)` gained support for multiple app file paths when creating your test fixture. If multiple file paths are given, it will behave as a parameterized fixture value and execute the test for each app path. (#1869)
39+
3840
### Bug fixes
3941

4042
* `ui.Chat()` now correctly handles new `ollama.chat()` return value introduced in `ollama` v0.4. (#1787)

pyrightconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"_dev",
1010
"docs",
1111
"tests/playwright/deploys/*/app.py",
12-
"shiny/templates"
12+
"shiny/templates",
13+
"tests/playwright/shiny/tests_for_ai_generated_apps"
1314
],
1415
"typeCheckingMode": "strict",
1516
"reportImportCycles": "none",

shiny/pytest/_fixture.py

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from pathlib import Path, PurePath
4-
from typing import Literal, Union
4+
from typing import Literal
55

66
import pytest
77

@@ -34,20 +34,24 @@
3434

3535
@no_example()
3636
def create_app_fixture(
37-
app: Union[PurePath, str],
37+
app: PurePath | str | list[PurePath | str],
3838
scope: ScopeName = "module",
3939
):
4040
"""
4141
Create a fixture for a local Shiny app directory.
4242
43-
Creates a fixture for a local Shiny app that is not contained within the same folder. This fixture is used to start the Shiny app process and return the local URL of the app.
43+
Creates a fixture for a local Shiny app that is not contained within the same
44+
folder. This fixture is used to start the Shiny app process and return the local URL
45+
of the app.
4446
45-
If the app path is located in the same directory as the test file, then `create_app_fixture()` can be skipped and `local_app` test fixture can be used instead.
47+
If the app path is located in the same directory as the test file, then
48+
`create_app_fixture()` can be skipped and `local_app` test fixture can be used
49+
instead.
4650
4751
Parameters
4852
----------
4953
app
50-
The path to the Shiny app file.
54+
The path (or a list of paths) to the Shiny app file.
5155
5256
If `app` is a `Path` or `PurePath` instance and `Path(app).is_file()` returns
5357
`True`, then this value will be used directly. Note, `app`'s file path will be
@@ -58,8 +62,14 @@ def create_app_fixture(
5862
the test function was collected.
5963
6064
To be sure that your `app` path is always relative, supply a `str` value.
65+
66+
If `app` is a list of path values, then the fixture will be parametrized and each test
67+
will be run for each path in the list.
6168
scope
62-
The scope of the fixture.
69+
The scope of the fixture. The default is `module`, which means that the fixture
70+
will be created once per module. See [Pytest fixture
71+
scopes](https://docs.pytest.org/en/stable/how-to/fixtures.html#fixture-scopes)
72+
for more details.
6373
6474
Returns
6575
-------
@@ -85,13 +95,54 @@ def test_app_code(page: Page, app: ShinyAppProc):
8595
# Add test code here
8696
...
8797
```
98+
99+
```python
100+
from playwright.sync_api import Page
101+
102+
from shiny.playwright import controller
103+
from shiny.pytest import create_app_fixture
104+
from shiny.run import ShinyAppProc
105+
106+
# The variable name `app` MUST match the parameter name in the test function
107+
# The tests below will run for each path provided
108+
app = create_app_fixture(["relative/path/to/first/app.py", "relative/path/to/second/app.py"])
109+
110+
def test_app_code(page: Page, app: ShinyAppProc):
111+
112+
page.goto(app.url)
113+
# Add test code here
114+
...
115+
116+
def test_more_app_code(page: Page, app: ShinyAppProc):
117+
118+
page.goto(app.url)
119+
# Add test code here
120+
...
121+
```
88122
"""
89123

90-
@pytest.fixture(scope=scope)
91-
def fixture_func(request: pytest.FixtureRequest):
124+
def get_app_path(request: pytest.FixtureRequest, app: PurePath | str):
92125
app_purepath_exists = isinstance(app, PurePath) and Path(app).is_file()
93126
app_path = app if app_purepath_exists else request.path.parent / app
94-
sa_gen = shiny_app_gen(app_path)
95-
yield next(sa_gen)
127+
return app_path
128+
129+
if isinstance(app, list):
130+
131+
# Multiple app values provided
132+
# Will display the app value as a parameter in the logs
133+
@pytest.fixture(scope=scope, params=app)
134+
def fixture_func(request: pytest.FixtureRequest):
135+
app_path = get_app_path(request, request.param)
136+
sa_gen = shiny_app_gen(app_path)
137+
yield next(sa_gen)
138+
139+
else:
140+
# Single app value provided
141+
# No indication of the app value in the logs
142+
@pytest.fixture(scope=scope)
143+
def fixture_func(request: pytest.FixtureRequest):
144+
app_path = get_app_path(request, app)
145+
sa_gen = shiny_app_gen(app_path)
146+
yield next(sa_gen)
96147

97148
return fixture_func

shiny/pytest/_pytest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ def local_app(request: pytest.FixtureRequest) -> Generator[ShinyAppProc, None, N
1717
Parameters:
1818
request (pytest.FixtureRequest): The request object for the fixture.
1919
"""
20-
sa_gen = shiny_app_gen(PurePath(request.path).parent / "app.py")
20+
# Get the app_file from the parametrize marker if available
21+
app_file = getattr(request, "param", "app.py")
22+
sa_gen = shiny_app_gen(PurePath(request.path).parent / app_file)
2123
yield next(sa_gen)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from shiny import App, render, ui
2+
3+
# Define the UI
4+
app_ui = ui.page_fluid(
5+
# Add Font Awesome CSS in the head section
6+
ui.tags.head(
7+
ui.HTML(
8+
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">'
9+
)
10+
),
11+
# Create accordion with panels
12+
ui.accordion(
13+
# Basic panel
14+
ui.accordion_panel(
15+
"Panel A", "This is a basic accordion panel with default settings."
16+
),
17+
# Panel with custom icon
18+
ui.accordion_panel(
19+
"Panel B",
20+
"This panel has a custom star icon and is open by default.",
21+
icon=ui.HTML('<i class="fa-solid fa-star" style="color: gold;"></i>'),
22+
),
23+
# Basic panel that starts closed
24+
ui.accordion_panel(
25+
"Panel C", "This is another basic panel that starts closed."
26+
),
27+
# Panel with longer content
28+
ui.accordion_panel(
29+
"Panel D",
30+
ui.markdown(
31+
"""
32+
This panel contains longer content to demonstrate scrolling:
33+
34+
- Item 1
35+
- Item 2
36+
- Item 3
37+
38+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
39+
eiusmod tempor incididunt ut labore et dolore magna aliqua.
40+
"""
41+
),
42+
),
43+
id="acc_demo",
44+
open=["Panel B", "Panel D"],
45+
multiple=True,
46+
),
47+
# Output for showing which panels are open
48+
ui.output_text("selected_panels"),
49+
)
50+
51+
52+
# Define the server
53+
def server(input, output, session):
54+
@render.text
55+
def selected_panels():
56+
return f"Currently open panels: {input.acc_demo()}"
57+
58+
59+
# Create and return the app
60+
app = App(app_ui, server)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from shiny.express import input, render, ui
2+
3+
# Add Font Awesome CSS in the head section
4+
ui.head_content(
5+
ui.HTML(
6+
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">'
7+
)
8+
)
9+
10+
# Create a list of accordion panels with different configurations
11+
with ui.accordion(id="acc_demo", open=["Panel B", "Panel D"], multiple=True):
12+
# Basic panel
13+
with ui.accordion_panel("Panel A"):
14+
"This is a basic accordion panel with default settings."
15+
16+
# Panel with custom icon
17+
with ui.accordion_panel(
18+
"Panel B", icon=ui.HTML('<i class="fa-solid fa-star" style="color: gold;"></i>')
19+
):
20+
"This panel has a custom star icon and is open by default."
21+
22+
# Basic panel that starts closed
23+
with ui.accordion_panel("Panel C"):
24+
"This is another basic panel that starts closed."
25+
26+
# Panel with longer content
27+
with ui.accordion_panel("Panel D"):
28+
ui.markdown(
29+
"""
30+
This panel contains longer content to demonstrate scrolling:
31+
32+
- Item 1
33+
- Item 2
34+
- Item 3
35+
36+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
37+
eiusmod tempor incididunt ut labore et dolore magna aliqua.
38+
"""
39+
)
40+
41+
42+
# Show which panels are currently open
43+
@render.text
44+
def selected_panels():
45+
return f"Currently open panels: {input.acc_demo()}"
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from playwright.sync_api import Page
2+
3+
from shiny.playwright import controller
4+
from shiny.pytest import create_app_fixture
5+
from shiny.run import ShinyAppProc
6+
7+
app = create_app_fixture(["app-core.py", "app-express.py"])
8+
9+
# For this file, separate the tests to "prove" that the fixture exists for the whole module
10+
11+
12+
def test_accordion_demo1(page: Page, app: ShinyAppProc) -> None:
13+
page.goto(app.url)
14+
15+
# Test accordion
16+
accordion = controller.Accordion(page, "acc_demo")
17+
18+
# Test initial state - Panel B and D should be open by default
19+
accordion.expect_multiple(True)
20+
21+
# Test individual panels
22+
panel_a = accordion.accordion_panel("Panel A")
23+
panel_b = accordion.accordion_panel("Panel B")
24+
panel_c = accordion.accordion_panel("Panel C")
25+
panel_d = accordion.accordion_panel("Panel D")
26+
27+
# Test initial states (open/closed)
28+
panel_a.expect_open(False)
29+
panel_b.expect_open(True) # Should be open by default
30+
panel_c.expect_open(False)
31+
panel_d.expect_open(True) # Should be open by default
32+
33+
# Test panel labels
34+
panel_a.expect_label("Panel A")
35+
panel_b.expect_label("Panel B")
36+
panel_c.expect_label("Panel C")
37+
panel_d.expect_label("Panel D")
38+
39+
40+
def test_accordion_demo2(page: Page, app: ShinyAppProc) -> None:
41+
page.goto(app.url)
42+
43+
# Test accordion
44+
accordion = controller.Accordion(page, "acc_demo")
45+
46+
# Test initial state - Panel B and D should be open by default
47+
accordion.expect_multiple(True)
48+
49+
# Test individual panels
50+
panel_a = accordion.accordion_panel("Panel A")
51+
panel_b = accordion.accordion_panel("Panel B")
52+
panel_c = accordion.accordion_panel("Panel C")
53+
# panel_d = accordion.accordion_panel("Panel D")
54+
55+
# Test panel content
56+
panel_a.expect_body("This is a basic accordion panel with default settings.")
57+
panel_b.expect_body("This panel has a custom star icon and is open by default.")
58+
panel_c.expect_body("This is another basic panel that starts closed.")
59+
60+
# Test opening and closing panels
61+
panel_c.set(True) # Open panel C
62+
panel_c.expect_open(True)
63+
64+
panel_b.set(False) # Close panel B
65+
panel_b.expect_open(False)
66+
67+
# Test the output text showing currently open panels
68+
output_text = controller.OutputText(page, "selected_panels")
69+
output_text.expect_value("Currently open panels: ('Panel C', 'Panel D')")

0 commit comments

Comments
 (0)