diff --git a/.gitmodules b/.gitmodules index 0e7410d05..9f95e13a8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/docs/conf.py b/docs/conf.py index ce9fd132d..19a0c7d4f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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 @@ -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: @@ -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 diff --git a/docs/developer-guide/testing/drive-the-test-browser.md b/docs/developer-guide/testing/drive-the-test-browser.md new file mode 100644 index 000000000..774cff5be --- /dev/null +++ b/docs/developer-guide/testing/drive-the-test-browser.md @@ -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. +``` diff --git a/docs/developer-guide/testing/how-testing-layers-work.md b/docs/developer-guide/testing/how-testing-layers-work.md new file mode 100644 index 000000000..6568f485d --- /dev/null +++ b/docs/developer-guide/testing/how-testing-layers-work.md @@ -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 `; 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). +``` diff --git a/docs/developer-guide/testing/index.md b/docs/developer-guide/testing/index.md index d9a3dfef9..6d2afc9ad 100644 --- a/docs/developer-guide/testing/index.md +++ b/docs/developer-guide/testing/index.md @@ -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 ``` diff --git a/docs/developer-guide/testing/install-add-ons-in-tests.md b/docs/developer-guide/testing/install-add-ons-in-tests.md new file mode 100644 index 000000000..6ebd9feeb --- /dev/null +++ b/docs/developer-guide/testing/install-add-ons-in-tests.md @@ -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 ` also offers an `installer` fixture and an `@pytest.mark.portal(profiles=[...])` marker that do the same thing with less boilerplate. +``` + +## 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`. +``` diff --git a/docs/developer-guide/testing/testing-api-reference.md b/docs/developer-guide/testing/testing-api-reference.md new file mode 100644 index 000000000..c847c6f3e --- /dev/null +++ b/docs/developer-guide/testing/testing-api-reference.md @@ -0,0 +1,219 @@ +--- +myst: + html_meta: + "description": "Reference for the Plone testing API: layers, fixtures, helpers, and sandboxing, from plone.app.testing and plone.testing." + "property=og:description": "Reference for the Plone testing API: layers, fixtures, helpers, and sandboxing, from plone.app.testing and plone.testing." + "property=og:title": "Testing API reference" + "keywords": "Plone, testing, plone.app.testing, plone.testing, layers, fixtures, helpers" +--- + +(testing-api-reference)= + +# Testing API reference + +Technical reference for Plone's testing API, drawn from two packages: + +- [plone.app.testing](https://github.com/plone/plone.app.testing): the Plone-specific layers and helpers. +- [plone.testing](https://github.com/plone/plone.testing): the underlying layer model and the Zope-level tools. + +You import from whichever package a symbol lives in, but from a test author's point of view they are one toolkit, so this page is organized by task rather than by package. +Each symbol is documented at its canonical location; where one package re-exports a symbol from the other, that is noted. + +To use these to write a `testing.py`, see {doc}`write-a-testing-layer`. +For the model behind them, see {doc}`how-testing-layers-work`. +Signatures are generated from the source, so they always match the installed version. + +## The layer model + +The base class and the composition helper come from `plone.testing`; the Plone-specific layers build on them. + +```{autodoc2-object} plone.testing.layer.Layer +render_plugin = "myst" +``` + +```{autodoc2-object} plone.testing.layer.layered +render_plugin = "myst" +``` + +```{autodoc2-object} plone.app.testing.helpers.PloneSandboxLayer +render_plugin = "myst" +``` + +```{autodoc2-object} plone.app.testing.helpers.PloneWithPackageLayer +render_plugin = "myst" +``` + +```{autodoc2-object} plone.app.testing.layers.IntegrationTesting +render_plugin = "myst" +``` + +```{autodoc2-object} plone.app.testing.layers.FunctionalTesting +render_plugin = "myst" +``` + +```{autodoc2-object} plone.app.testing.layers.PloneFixture +render_plugin = "myst" +``` + +```{autodoc2-object} plone.app.testing.layers.PloneTestLifecycle +render_plugin = "myst" +``` + +## Pre-built layers and fixtures + +Ready-made layer instances. +The `PLONE_*` layers give you a plain Plone site; the `plone.testing` fixtures are lower-level building blocks you add to a layer's bases. + +| Layer / fixture | Package | Use | +| --- | --- | --- | +| `PLONE_FIXTURE` | plone.app.testing | A plain Plone site. The base you build your own fixture on, not used in tests directly. | +| `PLONE_INTEGRATION_TESTING` | plone.app.testing | A ready integration layer for a plain Plone site. | +| `PLONE_FUNCTIONAL_TESTING` | plone.app.testing | A ready functional layer for a plain Plone site. | +| `MOCK_MAILHOST_FIXTURE` | plone.app.testing | Replaces the mail host so tests can capture outgoing email. | +| `STARTUP` | plone.testing.zope | Starts Zope. The root of most layer stacks. | +| `WSGI_SERVER_FIXTURE` | plone.testing.zope | Runs a real WSGI server, for tests that make requests over a socket. Add it to a functional layer's bases. | +| `UNIT_TESTING` | plone.testing.zca | An isolated component registry for tests that need one without a full site. | +| `EMPTY_ZODB` | plone.testing.zodb | An empty database. | +| `LAYER_CLEANUP` | plone.testing.zca | Runs the `zope.testing` cleanup handlers on tear-down. | + +```{note} +`PLONE_ZSERVER` and `PLONE_FTP_SERVER` exist for the legacy ZServer. +Plone 6 runs on WSGI; use `WSGI_SERVER_FIXTURE` for a real HTTP server instead. +``` + +## Browser and HTTP + +For end-to-end tests that drive Plone as a browser would. +See {doc}`drive-the-test-browser` for the task-oriented guide. + +### `Browser` + +```python +from plone.testing.zope import Browser + +browser = Browser(app) +``` + +A [zope.testbrowser](https://github.com/zopefoundation/zope.testbrowser) browser wired to the Zope application under test. +It speaks to Zope in-process, over a special channel rather than a real socket, so it needs a functional layer but not `WSGI_SERVER_FIXTURE`. +`app` is the Zope root, the `app` resource from the layer. +See the [zope.testbrowser documentation](https://github.com/zopefoundation/zope.testbrowser) for the full browser API. + +### `zopeApp` + +```{autodoc2-object} plone.testing.zope.zopeApp +render_plugin = "myst" +``` + +## Helpers + +### Users and roles + +These are the `plone.app.testing` helpers; they wrap the lower-level `plone.testing.zope` versions and are the ones you normally import. + +```{autodoc2-object} plone.app.testing.helpers.login +render_plugin = "myst" +``` + +```{autodoc2-object} plone.app.testing.helpers.logout +render_plugin = "myst" +``` + +```{autodoc2-object} plone.app.testing.helpers.setRoles +render_plugin = "myst" +``` + +### Products and profiles + +`applyProfile` and `quickInstallProduct` install Plone add-ons; `installProduct` and `uninstallProduct` register a Zope product at the Zope level, which you usually only need in a layer's `setUpZope`. + +```{autodoc2-object} plone.app.testing.helpers.applyProfile +render_plugin = "myst" +``` + +```{autodoc2-object} plone.app.testing.helpers.quickInstallProduct +render_plugin = "myst" +``` + +```{autodoc2-object} plone.testing.zope.installProduct +render_plugin = "myst" +``` + +```{autodoc2-object} plone.testing.zope.uninstallProduct +render_plugin = "myst" +``` + +### Working with the site + +```{autodoc2-object} plone.app.testing.helpers.ploneSite +render_plugin = "myst" +``` + +## Sandboxing primitives + +Set-up code uses these to keep global state from leaking between layers. +`PloneSandboxLayer` calls them for you, so you rarely need them directly. +For why the sandboxing exists, see {ref}`the component-registry sandbox `. + +The component registry (populated by ZCML): + +```{autodoc2-object} plone.testing.zca.pushGlobalRegistry +render_plugin = "myst" +``` + +```{autodoc2-object} plone.testing.zca.popGlobalRegistry +render_plugin = "myst" +``` + +```{note} +`plone.app.testing` re-exports `pushGlobalRegistry` and `popGlobalRegistry`, so `from plone.app.testing import pushGlobalRegistry` also works. +``` + +The security checkers: + +```{autodoc2-object} plone.testing.security.pushCheckers +render_plugin = "myst" +``` + +```{autodoc2-object} plone.testing.security.popCheckers +render_plugin = "myst" +``` + +The database: + +```{autodoc2-object} plone.testing.zodb.stackDemoStorage +render_plugin = "myst" +``` + +### Cleanup + +```{autodoc2-object} plone.app.testing.helpers.tearDownMultiPluginRegistration +render_plugin = "myst" +``` + +```{autodoc2-object} plone.app.testing.cleanup.cleanUpMultiPlugins +render_plugin = "myst" +``` + +## Constants + +Well-known values, safe to depend on in tests and fixtures. +All come from `plone.app.testing`. + +| Constant | Value | Meaning | +| --- | --- | --- | +| `TEST_USER_ID` | `'test_user_1_'` | The default test user's id. | +| `TEST_USER_NAME` | `'test-user'` | The default test user's login name. | +| `TEST_USER_PASSWORD` | `'correct horse battery staple'` | The default test user's password. | +| `TEST_USER_ROLES` | `['Member']` | The default test user's roles. | +| `SITE_OWNER_NAME` | `'admin'` | The site owner (Manager) login name. | +| `SITE_OWNER_PASSWORD` | `'secret'` | The site owner's password. | +| `PLONE_SITE_ID` | `'plone'` | The id of the test Plone site. | +| `PLONE_SITE_TITLE` | `'Plone site'` | The title of the test Plone site. | +| `DEFAULT_LANGUAGE` | `'en'` | The default language of the test site. | +| `ROBOT_TEST_LEVEL` | `5` | The test level at which Robot Framework tests are registered. | + +```{seealso} +- {doc}`write-a-testing-layer` — how to use these to build your `testing.py`. +- {doc}`how-testing-layers-work` — the model beneath these classes. +``` diff --git a/requirements.txt b/requirements.txt index 994eea9cf..75ec0c941 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ linkify-it-py plone-sphinx-theme<2 sphinx<9 # See https://github.com/plone/documentation/issues/2037 sphinx-autobuild +sphinx-autodoc2 sphinx-copybutton sphinx-design # Documentation only sphinx-examples diff --git a/styles/config/vocabularies/Plone/accept.txt b/styles/config/vocabularies/Plone/accept.txt index 88fad9015..20b4cedfc 100644 --- a/styles/config/vocabularies/Plone/accept.txt +++ b/styles/config/vocabularies/Plone/accept.txt @@ -9,6 +9,7 @@ APIs backport(ed|ing) Barceloneta [Bb]oolean +Blicca bugfix [Bb]uildout cacheable @@ -45,6 +46,11 @@ Plone plone.app.testing plone.meta plone.testing +plone.testing.layer +plone.testing.security +plone.testing.zca +plone.testing.zodb +plone.testing.zope plonecli pluggab(le|ility) pnpm @@ -60,9 +66,11 @@ Razzle RichText Sass Schuko +[Ss]andbox(ed|ing)? subfolder testbrowser toggler +traceback [Tt]owncrier transpilation transpile[drs]{0,1} @@ -81,4 +89,6 @@ webpack wireframe xkcd Zope +zope.testbrowser zope.testrunner +ZServer diff --git a/submodules/plone.app.testing b/submodules/plone.app.testing new file mode 160000 index 000000000..b9febd454 --- /dev/null +++ b/submodules/plone.app.testing @@ -0,0 +1 @@ +Subproject commit b9febd454cac94d8de84bb418d6ddaacba15889f diff --git a/submodules/plone.testing b/submodules/plone.testing new file mode 160000 index 000000000..51e369f76 --- /dev/null +++ b/submodules/plone.testing @@ -0,0 +1 @@ +Subproject commit 51e369f763a6c53e7996c04dc8ee482e73a30b79