Skip to content
Open
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
8 changes: 8 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@
path = submodules/plone.api
url = https://github.com/plone/plone.api.git
branch = main
[submodule "submodules/plone.app.testing"]
path = submodules/plone.app.testing
url = https://github.com/plone/plone.app.testing.git
branch = master
[submodule "submodules/plone.testing"]
path = submodules/plone.testing
url = https://github.com/plone/plone.testing.git
branch = master
22 changes: 22 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
extensions = [
"myst_parser",
"notfound.extension",
"autodoc2", # static API docs from source, no package import needed
"sphinx.ext.autodoc",
"sphinx.ext.autosummary", # plone.api
"sphinx.ext.doctest", # plone.api
Expand Down Expand Up @@ -301,6 +302,26 @@
# Don't show class signature with the class' name.
autodoc_class_signature = "separated"

# -- Options for autodoc2 -----------------------------------------------------
# autodoc2 analyses the source statically, so no package import and no Plone
# installation are needed. It reads the package submodules directly.
# The ``module`` key gives each namespace package its full dotted name.
autodoc2_packages = [
{
"path": "../submodules/plone.app.testing/src/plone/app/testing",
"module": "plone.app.testing",
"auto_mode": False,
},
{
"path": "../submodules/plone.testing/src/plone/testing",
"module": "plone.testing",
"auto_mode": False,
},
]
autodoc2_render_plugin = "myst"
# The plone.app.testing docstrings are reStructuredText, not MyST.
autodoc2_docstring_parser_regexes = [(r".*", "rst")]

# -- Options for MyST markdown conversion to HTML -----------------------------

# For more information see:
Expand All @@ -310,6 +331,7 @@
"attrs_inline", # Support parsing of inline attributes.
"colon_fence", # You can also use ::: delimiters to denote code fences, instead of ```.
"deflist", # Support definition lists. https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#definition-lists
"fieldlist", # Render reST field lists (:param:) from autodoc2 docstrings.
"html_image", # For inline images. See https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#html-images
"linkify", # Identify "bare" web URLs and add hyperlinks.
"strikethrough", # See https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#syntax-strikethrough
Expand Down
93 changes: 93 additions & 0 deletions docs/developer-guide/testing/drive-the-test-browser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
myst:
html_meta:
"description": "How to drive Plone with zope.testbrowser in a functional test: open URLs, follow links, submit forms."
"property=og:description": "How to drive Plone with zope.testbrowser in a functional test: open URLs, follow links, submit forms."
"property=og:title": "Drive the test browser"
"keywords": "Plone, testing, zope.testbrowser, functional test, browser"
---

(drive-the-test-browser)=

# Drive the test browser

This guide shows you how to write an end-to-end functional test that drives Plone through `zope.testbrowser`, which acts as a web browser connected to Zope in-process.

```{important}
`zope.testbrowser` runs entirely in Python and does **not** run JavaScript.
Use it for server-rendered pages (Blicca, the frontend formerly called Classic UI).
To test a Volto frontend, see [Test add-ons](/volto/development/add-ons/test-add-ons-19) instead.
```

You need a **functional** layer, either `PLONE_FUNCTIONAL_TESTING` or your own layer built with `FunctionalTesting`.
The test browser cannot see an integration layer's uncommitted transaction; see {ref}`how-testing-layers-work` for why.

## Get a browser

```python
from plone.testing.zope import Browser

browser = Browser(app)
```

`app` is the Zope root (the `app` resource from the layer).

## Make content visible to the browser

The browser runs in a separate transaction, so it sees only **committed** data.
If a test creates content and then visits it, commit first:

```python
import transaction
from plone.app.testing import setRoles, TEST_USER_ID

setRoles(portal, TEST_USER_ID, ["Manager"])
portal.invokeFactory("Folder", "f1", title="Folder 1")
setRoles(portal, TEST_USER_ID, ["Member"])

transaction.commit() # now the browser can see f1
```

## Open a page and inspect it

```python
browser.open(portal.absolute_url())

assert "Welcome" in browser.contents
assert browser.headers["content-type"] == "text/html; charset=utf-8"
```

## Follow links

```python
browser.getLink("Edit").click() # by link text
browser.getLink(id="edit-link").click() # by HTML id

assert browser.url == portal.absolute_url() + "/edit"
```

## Fill in and submit a form

```python
browser.getControl("Age").value = "30" # by the control's label
browser.getControl(name="age:int").value = "30" # by form variable name

browser.getControl("Save").click() # submit by button label
```

See the [zope.testbrowser documentation](https://github.com/zopefoundation/zope.testbrowser) for selecting and manipulating every control type.

## Debugging

When a submission does not do what you expect, print the response to see what the browser actually got:

```python
print(browser.contents)
```

An unhandled exception on the server is re-raised in the test by default, so you get the real traceback rather than a rendered error page.

```{seealso}
- {ref}`how-testing-layers-work`—why functional layers commit and integration layers do not.
- pytest users testing the REST API rather than HTML: {doc}`pytest` and pytest-plone's request fixtures.
```
108 changes: 108 additions & 0 deletions docs/developer-guide/testing/how-testing-layers-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
myst:
html_meta:
"description": "How Plone testing layers work: the plone.testing layer model, resources, bases, sandboxing, and isolation."
"property=og:description": "How Plone testing layers work: the plone.testing layer model, resources, bases, sandboxing, and isolation."
"property=og:title": "How testing layers work"
"keywords": "Plone, testing, plone.testing, layers, DemoStorage, component registry, isolation"
---

(how-testing-layers-work)=

# How testing layers work

This page explains the machinery beneath the testing layers—the [plone.testing](https://github.com/plone/plone.testing) model that [plone.app.testing](https://github.com/plone/plone.app.testing) builds on.

You do not need this to write ordinary tests.
Read it when you write a non-trivial base layer, or when you need to understand why a layer behaves the way it does.

For *why* Plone tests with layers at all, and the trade-off between integration and functional testing, read {ref}`about-testing-in-plone` first.
For the classes and helpers named here, see {doc}`testing-api-reference`.

## A layer is an object with a lifecycle

A layer is an object with four lifecycle methods:

- `setUp` and `tearDown` run **once**, around the whole group of tests that share the layer.
This is where the expensive work goes—start Zope, create the Plone site, load ZCML, install a profile.
- `testSetUp` and `testTearDown` run **around every test**. This is where the cheap per-test isolation goes.

Splitting the expensive from the cheap is the entire point: the costly setup happens once and is shared, while each test still starts from a clean state.

## Layers share state through resources

A layer is also a mapping.
Set-up code stores things in it by key, and tests read them back:

```python
def setUp(self):
self["app"] = ... # the Zope root
```

```python
def test_something(self):
app = self.layer["app"]
```

Resources are **stacked**.
When a layer sets a key that one of its bases already set, the layer's value shadows the base's for the duration of that layer, and the base's value reappears when the layer tears down.
This is how a functional layer can, for example, replace the database with a sandboxed copy without disturbing the layer it builds on.

## Layers compose through bases

A layer declares its bases—the layers it builds on.
When you write a reusable layer class, you set them as the `defaultBases` class attribute.
When you instantiate a layer directly to combine existing ones, you pass them as the `bases` argument instead; that is the exception, not the rule.
Either way, the test runner sets up each base once, in order, before the layer itself, and reuses an already-set-up base rather than building it again.

The result is a tree of layers, each built once.
A typical add-on's stack looks like this:

```text
Zope startup (plone.testing.zope.STARTUP)
└─ PloneFixture (a Plone site: PLONE_FIXTURE)
└─ your fixture (your PloneSandboxLayer subclass: loads ZCML, installs your profile)
├─ IntegrationTesting (per-test transaction)
└─ FunctionalTesting (per-test DemoStorage)
```

`PLONE_FIXTURE` sits in the middle: it is the shared Plone site every add-on layer builds on.
You never use it in a test directly—you build your own fixture on it, then derive integration and functional layers from that.

Notice that both `IntegrationTesting` and `FunctionalTesting` are built on the *same* fixture, by passing it as their `bases`.
This is the point of separating the fixture from the lifecycle: the expensive site is built once, and the two layers add only the cheap per-test behavior on top.
The same trick lets a package reuse a base layer with a different lifecycle, or add a second fixture beside it, without paying for the expensive setup twice.

## How isolation works

Isolation happens in the cheap per-test half, and the two layer kinds do it differently.

An **integration** layer begins a transaction in `testSetUp` and **aborts** it in `testTearDown`.
Nothing a test writes is committed, so the next test sees the pristine site.
This is fast, and it is what most tests use.

A **functional** layer instead stacks a temporary `DemoStorage` on the database in `testSetUp` and discards it in `testTearDown`.
The test may **commit** for real, and a separate process—a browser, an HTTP client—can see the result, because the data really is in the (sandboxed) database.
When the test ends, the whole stacked storage is thrown away.
This is more expensive, which is why you reach for it only when a request has to travel over the network.

(zca-sandbox)=

## The component-registry sandbox

Plone relies heavily on the Zope Component Architecture—a global registry of components, populated by loading ZCML.
If a test layer loaded ZCML into the one global registry and never undid it, registrations would leak from one layer into the next, and tests would interfere with each other in ways that depend on run order.

To prevent this, a sandboxing layer **pushes a new component registry** on set-up and **pops it** on tear-down, so every registration it makes lives only as long as the layer.
`PloneSandboxLayer` does this for you—that is what the "sandbox" in its name means.
The primitives are {ref}`pushGlobalRegistry and popGlobalRegistry <testing-api-reference>`; you rarely call them directly.

## Server fixtures for real HTTP

An in-process functional test can drive Plone through a test browser without a socket.
When a test needs a **real** HTTP server—a live URL that an external client hits—add `WSGI_SERVER_FIXTURE` (from `plone.testing.zope`) to the functional layer's bases.
It starts a WSGI server for the duration of the layer and exposes its address, so requests genuinely travel over the network.

```{seealso}
The full model, including the ZODB and component-architecture helpers, lives in [plone.testing](https://github.com/plone/plone.testing/blob/master/src/plone/testing/README.rst).
```
18 changes: 18 additions & 0 deletions docs/developer-guide/testing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,28 @@ Choose pytest for new work.

Do not use both in one package.

## Guides

- {doc}`write-a-testing-layer`: the `testing.py` layer both runners share.
- {doc}`zope-testrunner`: write and run tests with `unittest` and `zope.testrunner`.
- {doc}`pytest`: write and run tests with pytest.
- {doc}`install-add-ons-in-tests`: apply profiles and install add-ons in a test.
- {doc}`drive-the-test-browser`: end-to-end tests with `zope.testbrowser`.

## Reference and background

- {doc}`testing-api-reference`: the layers, fixtures, helpers, and sandboxing, across `plone.app.testing` and `plone.testing`.
- {doc}`how-testing-layers-work`: the layer model beneath it all.

```{toctree}
:hidden:
:maxdepth: 1

write-a-testing-layer
zope-testrunner
pytest
install-add-ons-in-tests
drive-the-test-browser
testing-api-reference
how-testing-layers-work
```
92 changes: 92 additions & 0 deletions docs/developer-guide/testing/install-add-ons-in-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
myst:
html_meta:
"description": "How to install add-ons and GenericSetup profiles in a Plone test, and verify the result."
"property=og:description": "How to install add-ons and GenericSetup profiles in a Plone test, and verify the result."
"property=og:title": "Install add-ons and profiles in a test"
"keywords": "Plone, testing, applyProfile, quickInstallProduct, GenericSetup, add-on"
---

(install-add-ons-in-tests)=

# Install add-ons and profiles in a test

This guide shows you how to install a GenericSetup profile or an add-on inside a test, and how to check that the installation did what you expect.

It uses the helpers from {doc}`testing-api-reference`.
The examples assume a layer whose fixture already loaded your add-on's ZCML—see {doc}`write-a-testing-layer`.

```{tip}
If you use pytest, {doc}`pytest-plone </developer-guide/testing/pytest>` also offers an `installer` fixture and an `@pytest.mark.portal(profiles=[...])` marker that do the same thing with less boilerplate.
Comment thread
gforcada marked this conversation as resolved.
```

## Apply a profile

The preferred way to install an add-on's configuration is to apply its GenericSetup profile:

```python
from plone.app.testing import applyProfile

applyProfile(portal, "my.addon:default")
```

You would usually do this once in your layer's `setUpPloneSite`, so every test in the layer runs against the installed add-on.
Do it in an individual test only when you are testing the installation itself.

## Install through the add-ons control panel

To install exactly as a site administrator would, through the add-ons control panel:

```python
from plone.app.testing import quickInstallProduct

quickInstallProduct(portal, "my.addon")
```

To force a reinstall—uninstall, then install again:

```python
quickInstallProduct(portal, "my.addon", reinstall=True)
```

Both assume the add-on's ZCML has been loaded, which the layer set-up normally does.

## Verify the installation

When you write an add-on with an install profile, you usually want a test that the profile did its job.

Check the add-on is installed:

```python
from plone.base.utils import get_installer

installer = get_installer(portal)
assert installer.is_product_installed("my.addon")
```

Check a content type was registered (via `types.xml`):

```python
types_tool = portal.portal_types
assert types_tool.getTypeInfo("MyType") is not None
```

Check a catalog index was added (via `catalog.xml`):

```python
catalog = portal.portal_catalog
assert "my_index" in catalog.indexes()
```

Check a workflow was installed and assigned (via `workflows.xml`):

```python
workflow_tool = portal.portal_workflow
assert workflow_tool.getWorkflowById("my_workflow") is not None
assert dict(workflow_tool.listChainOverrides())["MyType"] == ("my_workflow",)
```

```{seealso}
- {doc}`testing-api-reference`—`applyProfile`, `quickInstallProduct`, and the rest.
- The uninstall side of this suite: pytest-plone's `uninstalled` fixture, in {doc}`pytest`.
```
Loading