Skip to content

Commit 03863e4

Browse files
authored
Add uninstalled, functional-layer, and request-builder fixtures (#43)
* Add uninstalled fixture (#38) Adds an `uninstalled` fixture that reads a user-provided `package_name` fixture and calls `installer.uninstall_product`. Removes the per-project boilerplate that downstream codebases duplicate in their canonical add-on uninstall smoke test. Closes #38 * Add functional-layer fixtures (#39) Adds `functional_app`, `functional_portal`, and `functional_http_request` as functional-layer counterparts to the existing integration-layer fixtures. `functional_portal` honors `@pytest.mark.portal`, matching the behavior of `portal`. This eliminates the most common reason downstream codebases override pytest-plone fixtures — aliasing `functional["portal"]` as `portal`. Closes #39 * Add request-builder fixtures (#40) Introduces `request_factory`, `manager_request`, and `anon_request`, replacing the 5+ near-identical session fixtures that downstream codebases reimplement. `request_factory` returns a built-in `RelativeSession` (a thin `requests.Session` subclass) bound to the functional portal. Supported modes: `role="Manager"`, `role="Anonymous"` (default), or explicit `basic_auth=(user, password)`. The `++api++` suffix is added by default. The standalone `RelativeSession` avoids pulling in `plone.restapi[test]`'s fragile import chain (`plone.app.iterate`, `collective.MockMailHost`) while keeping the same calling convention downstream code already expects. Closes #40 * Silence mypy import-untyped warning for requests CI runs `uvx mypy src` in an isolated environment that does not honor the project's `ignore_missing_imports=true` setting in pyproject.toml. Add an inline type: ignore on the `requests` import rather than broadening CI or adding `types-requests` just for a single import.
1 parent 71c686d commit 03863e4

14 files changed

Lines changed: 663 additions & 0 deletions

File tree

README.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,58 @@ def test_myproduct_controlpanel_view(portal, http_request):
139139

140140
```
141141

142+
### functional_app
143+
144+
| | |
145+
| --- | --- |
146+
| Description | Zope root bound to the **functional** testing layer. |
147+
| Required Fixture | **functional** |
148+
| Scope | **Function** |
149+
150+
Use this when you need a functional-layer counterpart to `app` — typically for REST API or browser tests.
151+
152+
```python
153+
def test_functional_app(functional_app):
154+
"""Test app title."""
155+
assert functional_app.getPhysicalPath() == ("", )
156+
```
157+
158+
### functional_portal
159+
160+
| | |
161+
| --- | --- |
162+
| Description | Portal object bound to the **functional** testing layer. Honors `@pytest.mark.portal`. |
163+
| Required Fixture | **functional** |
164+
| Scope | **Function** |
165+
166+
Parallel to `portal`, but bound to the functional layer. Accepts the same `@pytest.mark.portal` marker for profiles, content, and roles — see the **Markers** section.
167+
168+
```python
169+
def test_functional_portal_title(functional_portal):
170+
"""Test portal title on the functional layer."""
171+
assert functional_portal.title == "Plone site"
172+
```
173+
174+
### functional_http_request
175+
176+
| | |
177+
| --- | --- |
178+
| Description | HTTP Request bound to the **functional** testing layer. |
179+
| Required Fixture | **functional** |
180+
| Scope | **Function** |
181+
182+
```python
183+
from plone import api
184+
185+
186+
def test_functional_view(functional_portal, functional_http_request):
187+
"""Test a browser view on the functional layer."""
188+
view = api.content.get_view(
189+
"myproduct-controlpanel", functional_portal, functional_http_request
190+
)
191+
assert view is not None
192+
```
193+
142194
### installer
143195

144196
| | |
@@ -178,6 +230,35 @@ def test_dependency_installed(installer, package):
178230

179231
```
180232

233+
### uninstalled
234+
235+
| | |
236+
| --- | --- |
237+
| Description | Uninstall the add-on under test from the current portal. |
238+
| Required Fixture | **installer**, **package_name** (user-provided) |
239+
| Scope | **Function** |
240+
241+
This fixture removes the duplicate per-project boilerplate from the canonical uninstall smoke test. You must define a `package_name` fixture in your `conftest.py` (or test module) that returns the distribution name of your add-on.
242+
243+
```python
244+
import pytest
245+
246+
247+
@pytest.fixture
248+
def package_name() -> str:
249+
"""Distribution name of the add-on under test."""
250+
return "collective.person"
251+
252+
253+
class TestSetupUninstall:
254+
@pytest.fixture(autouse=True)
255+
def _uninstalled(self, uninstalled):
256+
"""Uninstall the add-on before every test in this class."""
257+
258+
def test_product_uninstalled(self, installer, package_name):
259+
assert installer.is_product_installed(package_name) is False
260+
```
261+
181262
### browser_layers
182263

183264
| | |
@@ -363,6 +444,61 @@ def test_manager_action(portal, grant_roles):
363444
# test user now has Manager role on portal
364445
```
365446

447+
### request_factory
448+
449+
| | |
450+
| --- | --- |
451+
| Description | Callable that builds a `RelativeSession` against the functional portal. |
452+
| Required Fixture | **functional_portal** |
453+
| Scope | **Function** |
454+
455+
Replaces the 5+ near-identical request-session fixtures that downstream codebases reimplement. Returns a `RelativeSession` (a thin `requests.Session` subclass that resolves relative URLs against the portal's base URL) with sensible defaults:
456+
457+
- `role="Manager"` — authenticate as the portal owner.
458+
- `role="Anonymous"` (default) — no authentication.
459+
- `basic_auth=(user, password)` — any other identity; takes precedence over `role`.
460+
- `api=True` (default) — suffix the base URL with `++api++` so relative calls hit the REST API.
461+
462+
Sessions are closed automatically at the end of the test.
463+
464+
```python
465+
def test_list_content(request_factory):
466+
"""Test that the Manager role can list content."""
467+
session = request_factory(role="Manager")
468+
response = session.get("/")
469+
assert response.status_code == 200
470+
```
471+
472+
### manager_request
473+
474+
| | |
475+
| --- | --- |
476+
| Description | `RelativeSession` pre-authenticated as the portal owner (Manager). |
477+
| Required Fixture | **request_factory** |
478+
| Scope | **Function** |
479+
480+
```python
481+
def test_controlpanels(manager_request):
482+
"""Test listing of control panels."""
483+
response = manager_request.get("/@controlpanels")
484+
assert response.status_code == 200
485+
```
486+
487+
### anon_request
488+
489+
| | |
490+
| --- | --- |
491+
| Description | `RelativeSession` with no authentication (Anonymous). |
492+
| Required Fixture | **request_factory** |
493+
| Scope | **Function** |
494+
495+
```python
496+
def test_public_endpoint(anon_request):
497+
"""Test a public REST API endpoint."""
498+
response = anon_request.get("/")
499+
assert response.status_code == 200
500+
```
501+
366502
## Markers
367503

368504
### @pytest.mark.portal

news/38.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added the `uninstalled` fixture — reads a user-provided `package_name` fixture and calls `installer.uninstall_product`, removing boilerplate from the canonical add-on uninstall smoke test. @ericof

news/39.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added `functional_app`, `functional_portal`, and `functional_http_request` fixtures — functional-layer counterparts to `app`, `portal`, and `http_request`. `functional_portal` honors the `@pytest.mark.portal` marker. @ericof

news/40.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added `request_factory`, `manager_request`, and `anon_request` fixtures — build a `RelativeSession` against the functional portal with Manager, Anonymous, or custom basic-auth credentials. Replaces boilerplate duplicated across downstream codebases. @ericof

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ dependencies = [
4040
"plone.browserlayer",
4141
"plone.dexterity",
4242
"Products.CMFPlone[test]",
43+
"requests",
4344
"zope.component",
4445
"zope.schema",
4546
]

src/pytest_plone/_types.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from plone.dexterity.content import DexterityContent
55
from plone.dexterity.fti import DexterityFTI
66
from Products.CMFPlone.Portal import PloneSite
7+
from typing import Any
78
from typing import Protocol
89
from typing import TypeAlias
910
from zope.schema.vocabulary import SimpleVocabulary
@@ -45,3 +46,13 @@ def __call__(
4546

4647
class RolesGranter(Protocol):
4748
def __call__(self, context: DexterityContent | Item, roles: list[str]) -> None: ...
49+
50+
51+
class RequestFactory(Protocol):
52+
def __call__(
53+
self,
54+
*,
55+
role: str = ...,
56+
basic_auth: tuple[str, str] | None = ...,
57+
api: bool = ...,
58+
) -> Any: ...

src/pytest_plone/fixtures/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,49 @@
66
from .addons import installer
77
from .addons import profile_last_version
88
from .addons import setup_tool
9+
from .addons import uninstalled
910
from .base import app
11+
from .base import functional_app
12+
from .base import functional_http_request
13+
from .base import functional_portal
1014
from .base import http_request
1115
from .base import portal
1216
from .content import create_content
1317
from .content import get_behaviors
1418
from .content import get_fti
1519
from .env import generate_mo
20+
from .requests import anon_request
21+
from .requests import manager_request
22+
from .requests import request_factory
1623
from .security import grant_roles
1724
from .vocabularies import get_vocabulary
1825

1926
import pytest
2027

2128

2229
__all__ = [
30+
"anon_request",
2331
"app",
2432
"apply_profiles",
2533
"browser_layers",
2634
"controlpanel_actions",
2735
"create_content",
36+
"functional_app",
37+
"functional_http_request",
38+
"functional_portal",
2839
"generate_mo",
2940
"get_behaviors",
3041
"get_fti",
3142
"get_vocabulary",
3243
"grant_roles",
3344
"http_request",
3445
"installer",
46+
"manager_request",
3547
"portal",
3648
"profile_last_version",
49+
"request_factory",
3750
"setup_tool",
51+
"uninstalled",
3852
]
3953

4054

src/pytest_plone/fixtures/addons.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,38 @@ def uninstalled(self, installer):
3030
return get_installer(portal)
3131

3232

33+
@pytest.fixture
34+
def uninstalled(installer: InstallerView, package_name: str) -> None:
35+
"""Uninstall the add-on under test from the current portal.
36+
37+
Requires a ``package_name`` fixture defined in your ``conftest.py``
38+
that returns the distribution name of the add-on being tested.
39+
40+
Example usage:
41+
```python
42+
# conftest.py
43+
import pytest
44+
45+
46+
@pytest.fixture
47+
def package_name() -> str:
48+
return "collective.person"
49+
50+
51+
# tests/test_setup.py
52+
class TestSetupUninstall:
53+
@pytest.fixture(autouse=True)
54+
def uninstalled(self, uninstalled):
55+
# add-on is now uninstalled for every test in this class
56+
pass
57+
58+
def test_product_uninstalled(self, installer, package_name):
59+
assert installer.is_product_installed(package_name) is False
60+
```
61+
"""
62+
installer.uninstall_product(package_name)
63+
64+
3365
@pytest.fixture
3466
def browser_layers(portal: PloneSite) -> list[InterfaceClass]:
3567
"""List of browser layers registered in a portal.

src/pytest_plone/fixtures/base.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,54 @@ def test_request(self, request):
5959
```
6060
"""
6161
return integration["request"]
62+
63+
64+
@pytest.fixture()
65+
def functional_app(functional: Layer) -> Application:
66+
"""Returns the root of a Zope application for a functional Layer.
67+
68+
Mirrors :func:`app` but bound to the ``functional`` layer. Use this in
69+
REST API, browser, or other tests that require transaction-level
70+
isolation instead of the integration-layer stacked-DemoStorage.
71+
72+
Example usage:
73+
```python
74+
def test_functional_app(self, functional_app):
75+
assert functional_app.title == "Zope"
76+
```
77+
"""
78+
return functional["app"]
79+
80+
81+
@pytest.fixture()
82+
def functional_portal(functional: Layer, request: pytest.FixtureRequest) -> PloneSite:
83+
"""Returns the default Plone Site for a functional Layer.
84+
85+
Mirrors :func:`portal` but bound to the ``functional`` layer and also
86+
honors ``@pytest.mark.portal`` for GenericSetup profiles, pre-created
87+
content, and test-user roles.
88+
89+
Example usage:
90+
```python
91+
def test_functional_portal(self, functional_portal):
92+
assert functional_portal.title == "Plone site"
93+
```
94+
"""
95+
portal: PloneSite = functional["portal"]
96+
apply_portal_marker(portal, request)
97+
return portal
98+
99+
100+
@pytest.fixture
101+
def functional_http_request(functional: Layer) -> HTTPRequest:
102+
"""Returns the current request object for a functional Layer.
103+
104+
Mirrors :func:`http_request` but bound to the ``functional`` layer.
105+
106+
Example usage:
107+
```python
108+
def test_functional_request(self, functional_http_request):
109+
assert functional_http_request.method == "GET"
110+
```
111+
"""
112+
return functional["request"]

0 commit comments

Comments
 (0)