sandstorm / e2etesttools
Behat steps for testing Neos 9 sites: Fusion rendering, Playwright browser tests, Neos backend, content fixtures and their export, WireMock
Package info
github.com/sandstorm/Sandstorm.E2ETestTools
Type:neos-package
pkg:composer/sandstorm/e2etesttools
Requires
- php: ^8.2
- ext-curl: *
- ext-json: *
- behat/behat: ^3.16
- guzzlehttp/psr7: ^2.0
- neos/behat: ^9.1
- neos/contentgraph-doctrinedbaladapter: ^9.1
- neos/contentrepository-core: ^9.1
- neos/contentrepositoryregistry: ^9.1
- neos/flow: ^9.1
- neos/fusion: ^9.1
- neos/media: ^9.1
- neos/neos: ^9.1
- neos/neos-ui: ^9.1
- neos/utility-files: ^9.1
- phpunit/phpunit: ^10.5 || ^11.0
- symfony/css-selector: ^5.2 || ^6.0
- symfony/dom-crawler: ^5.2 || ^6.0
- symfony/yaml: ^6.4
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 9.1.0
- 8.3.2
- 8.3.1
- 8.3.0
- 8.2.0
- 1.2.0
- 1.1.0
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- dev-neos-9-support
- dev-24-bug-i-click-the-document-tree-entry-documenttitle-not-working-with-neos-83
- dev-bugfix/remove-policy-yaml-for-acl-add-readme
- dev-feature/12-persistent-resource-fixtures
- dev-feature/feedback-from-retreat-2022
- dev-bugfix/add-tags-to-be-ignored-during-reflection
- dev-feature/playwright-tracing
- dev-feature/add-more-steps-for-neos-be-and-general-requests
This package is auto-updated.
Last update: 2026-10-06 12:02:33 UTC
README
Behat steps and glue code for testing Neos 9 sites at three levels: Fusion rendering in-process, full pages in a real browser via Playwright, and the Neos backend. Tests are written in Gherkin and run against a content repository that is reset and filled with fixtures for every scenario.
@flowEntities Feature: Headline integration Scenario: a headline node renders as h1 Given I have a site for Site Node "site" with name "My Site" And I have the following nodes in site "site": | NodeAggregateId | Parent | NodeType | Properties | DimensionSpacePoint | | homepage | | My.Site:Document.StartPage | {"uriPathSegment":"home","title":"Homepage"} | {"language":"de"} | | headline | homepage/main | My.Site:Content.Headline | {"title":"Hello"} | {"language":"de"} | And I get the node "headline" in dimension '{"language":"de"}' When I render the Fusion object "/testcase" with the current context node: """ testcase = My.Site:Content.Headline """ Then in the fusion output, the inner HTML of CSS selector "h1" matches "Hello"
What you get
- Fusion rendering in the Behat process - render a component or a NodeType's integration and assert on the HTML via CSS selectors, without web server or browser.
- Browser tests via Playwright - Behat sends scripts to a small bridge service; the browser hits your application on its own port, Flow context and database.
- Neos backend steps - create users, log in, use the menu, the dashboard and the document tree.
- Generic browser steps - texts, titles, buttons, links, form fields, cookies and web storage (e.g. a preset cookie consent).
- Content fixtures - nodes, references, hidden state and assets as Gherkin tables or YAML files, created through the content repository API.
- Fixture export - turn existing pages into fixtures (backend button or CLI) instead of writing tables by hand.
- Mocked third-party APIs - WireMock stubs per scenario and checks of the calls your site made.
- Debugging and CI - screenshots, traces and server logs of failed scenarios, a pause step and a CI example.
Requires Neos 9.1+ / Flow 9.1+. For Neos 8, use the 8.x releases (latest: 8.3.2). Developing the package itself: see CONTRIBUTING.md.
Setup
Setup is done by hand, once per project, in this order - web server, ports, Docker and CI differ too much between projects for a setup command. Steps 1–5 are required; without step 2, Behat empties your development database.
1. Install the package
composer require --dev sandstorm/e2etesttools
2. Two Flow Contexts, Two Ports
E2E tests delete and create content for every scenario, so they need their own database - and with it their own Flow context, and a web server port of their own for the browser:
╔╦══════════════════╦╗ 1 ┌────────────────────┐
║│Behat Test Runner ├╬──────▶ Playwright Bridge │
║└──────────────────┘║ │(Playwright Server -│
║ Application Docker ║ │ Chrome Browser) │
║ Container (SUT) ║◀─────┤ │
╚══════════╦═════════╝ 2 └────────────────────┘
│
3│
┌──────────▼─────────┐
│other services (DB, │
│ Redis, ...) │
└────────────────────┘
Behat runs where the application runs, with the same code, environment and services. (1) It sends Playwright scripts to the bridge via HTTP, (2) the browser calls the unmodified application - the system under test (SUT) - and (3) the application uses its services as usual.
| Context (example names) | Runs | Database | Reached via |
|---|---|---|---|
your dev context, e.g. Development/Docker |
your normal dev web server | dev database | e.g. port 8080 |
SUT context, e.g. Production/E2E-SUT |
the web server the browser tests hit | E2E database | e.g. port 9090 (SYSTEM_UNDER_TEST_URL_FOR_PLAYWRIGHT) |
Testing/Behat |
bin/behat: fixtures, Fusion rendering |
E2E database | - (CLI) |
In CI, only the last two exist. How the SUT context gets its own port depends on your web server - mind
Troubleshooting item 1. Neos.Behat only runs in Testing/Behat (or a sub context like
Testing/Behat/Docker), so Configuration/Testing/Behat/Settings.yaml gets the SUT's database, cache and resource
settings.
Both contexts point to the separate database, e.g. via DB_NEOS_DATABASE_E2ETEST:
Neos: Flow: persistence: backendOptions: dbname: '%env:DB_NEOS_DATABASE_E2ETEST%'
Create it once, then migrate it in the SUT context (again after pulling new migrations):
mysql -e 'CREATE DATABASE IF NOT EXISTS neos_e2etest' # or your DB's equivalent FLOW_CONTEXT=Production/E2E-SUT ./flow doctrine:migrate
Caches that Behat flushes between scenarios (e.g. the Fusion content cache) need the same storage in both contexts (e.g. the same Redis database) and the same cache prefix - Flow's default contains the context name:
Neos: Flow: cache: applicationIdentifier: 'app'
Asset fixtures (I have a textual persistent resource ..., I have the following images:) need the SUT's persistent
resource storage and target - Flow's Testing defaults use separate ones, and the SUT would answer 404. In
Configuration/Testing/Behat/Settings.yaml (values as in your SUT context):
Neos: Flow: resource: storages: defaultPersistentResourcesStorage: storageOptions: path: '%FLOW_PATH_DATA%Persistent/Resources/' targets: localWebDirectoryPersistentResourcesTarget: targetOptions: path: '%FLOW_PATH_WEB%_Resources/Persistent/' baseUri: '_Resources/Persistent/' subdivideHashPathSegment: true
3. behat.yml.dist
Create DistributionPackages/Your.SitePackageKey/Tests/Behavior/behat.yml.dist:
default: autoload: '': "%paths.base%/Features/Bootstrap" suites: behat: paths: - "%paths.base%/Features" contexts: - FeatureContext
4. FeatureContext.php
FeatureContext is the class Behat calls for every step. Copy the template, which wires up the traits:
cp Packages/Application/Sandstorm.E2ETestTools/Templates/FeatureContext.php.default \ DistributionPackages/Your.SitePackageKey/Tests/Behavior/Features/Bootstrap/FeatureContext.php
Then replace the placeholder passed to setupFusionRendering(...) with your site package key - it fails loudly
otherwise. The traits (Sandstorm\E2ETestTools\Behat) are autoloaded by Composer.
5. Playwright (playwright-bridge)
The bridge between Behat and Playwright is a small Node service you copy into your project and adjust there - we put it at the root of the Git repository, next to the Neos root directory:
cp -r Packages/Application/Sandstorm.E2ETestTools/Templates/playwright-bridge ./playwright-bridge cd playwright-bridge && npm ci && npx playwright install chromium && cd ..
It runs on your host (it drives a real browser), Behat usually in your app container. setupPlaywright() reads two
environment variables, e.g. from your docker-compose.yml:
environment: # where Behat reaches the bridge (from inside a container: the host) PLAYWRIGHT_API_URL: 'http://host.docker.internal:3000' # where the browser (on the host) reaches the SUT port from step 2 SYSTEM_UNDER_TEST_URL_FOR_PLAYWRIGHT: 'http://127.0.0.1:9090'
6. Project tasks (recommended)
Wrap the recurring commands in your task runner (mise, make, npm scripts, ...). The Neos-on-Docker kickstart, for
example, has mise run tests:e2e:start-bridge and mise run tests:e2e [path], which roughly does:
docker compose exec maria-db /createTestingDB.sh docker compose exec neos bash -c "FLOW_CONTEXT=Production/E2E-SUT ./flow doctrine:migrate" docker compose exec -e FLOW_CONTEXT=Testing/Behat neos bin/behat -c DistributionPackages/Your.SitePackageKey/Tests/Behavior/behat.yml.dist $1
7. CI Pipeline (optional)
Run the E2E job inside the image you deploy (build once, test that artifact), with a database, Redis and the
Playwright bridge as services (build the bridge's image from its Dockerfile). A CI job only serves the SUT context,
so set FLOW_CONTEXT to it as job variable - the routing problem of Troubleshooting item 1 doesn't
occur. Production images are usually lean, so the job may have to:
- install the dev dependencies (
composer install --dev) when the image is built with--no-dev; - copy in the web server config of the SUT port when only a local-dev image layer has it.
Then migrate and warm up the SUT, start the web server in the background, point the two Playwright variables to your
CI's hostnames and run Behat in Testing/Behat. A JUnit report (--format junit --out <dir>) shows results per
scenario. With GitLab CI:
e2e_test: stage: test image: name: $CI_REGISTRY_IMAGE/neos:$CI_COMMIT_REF_SLUG # the image you already build for deployment entrypoint: [ "" ] # skip its normal startup sequence variables: FLOW_CONTEXT: Production/E2E-SUT DB_NEOS_DATABASE_E2ETEST: ci_test REDIS_HOST: redis REDIS_PORT: 6379 services: - name: mariadb:11.8 - name: redis:7 - name: $CI_REGISTRY_IMAGE/playwright-bridge:$CI_COMMIT_REF_SLUG alias: playwright-bridge script: - composer install --dev - FLOW_CONTEXT=Production/E2E-SUT ./flow doctrine:migrate - FLOW_CONTEXT=Production/E2E-SUT ./flow cache:warmup - your-web-server-start-command & - export PLAYWRIGHT_API_URL=http://playwright-bridge:3000 - export SYSTEM_UNDER_TEST_URL_FOR_PLAYWRIGHT=http://$(hostname -i):9090 - FLOW_CONTEXT=Testing/Behat ./bin/behat --format junit --out e2e-results -c DistributionPackages/Your.SitePackageKey/Tests/Behavior/behat.yml.dist artifacts: reports: junit: e2e-results/*.xml
GitLab passes the job's variables to all its services too - the DB and Redis services start with the same credentials.
Point the results directory (Debugging) to the JUnit --out folder to keep one artifact folder.
Parallelising: split the feature files across several jobs (GitLab parallel:), ideally balanced by the durations
of the last JUnit report, each with its own services. Never run several Behat processes against one set of
services - database and mocks are reset per scenario, and runner and SUT share caches on purpose.
Writing Behat Tests
Feature files live in your site package (Tests/Behavior/Features/). Working, commented examples for everything
below are in Tests/E2E/Features/ - the package's own suite; copy one and adapt it. What to
test in a Neos project, where, and the pitfalls: Neos E2E Testing Guide.
Tags
@flowEntities- resets the content repository before the scenario (prunes it, creates the live workspace and the/sitesroot) via yourFeatureContext's hook. Needed for every scenario that creates nodes.@playwright- starts a browser context in the bridge. Needed for page visits, backend steps and screenshots.@wireMock- resets WireMock to its base stubs (Mocked third-party APIs).
Other tags in the guide, like @mailpit, are project tags with a hook of your own.
Fixtures
Every scenario creates its content: write the nodes as Gherkin table or YAML file, or export existing pages into the same format.
Writing fixtures by hand
Create the site, then its nodes - as table, or from a YAML file with the same rows:
Given I have a site for Site Node "site" with name "YourSiteName" And I have the following nodes in site "site": | NodeAggregateId | Parent | NodeType | Properties | DimensionSpacePoint | | homepage | | Your.SitePackageKey:Document.StartPage | {"uriPathSegment":"site","title":"Homepage"} | {"language":"de"} | | section | homepage/main | Your.SitePackageKey:Content.Section | {} | {"language":"de"} | | headline | section | Your.SitePackageKey:Content.Headline | {"title":"<h1>It works<\/h1>"} | {"language":"de"} |
Parent: empty is the site node (named as inin site "...");homepage/mainis the tethered childmainofhomepage(deeper paths work too); otherwise theNodeAggregateIdof a node created earlier.DimensionSpacePointmust match your content dimensions - with alanguagedimension, every row needs one, either in the column or as default. An empty cell without default is{}, which the content repository rejects.Propertiesis a JSON object; invalid JSON, unknown NodeTypes and undeclared properties fail the step. Gherkin unescapes\\to\in table cells, so a JSON-escaped backslash (e.g. in a PHP class name) is written as\\\\.- Optional
Hiddencolumn:truehides the node and its descendants. - Mind NodeType
constraints(Troubleshooting item 2). - Fixture steps bypass node and workspace permissions (
StaticAuthProviderFactoryinTesting/Behat); the SUT still applies yours, e.g. when you log in through the backend steps. - References:
And the following node references:with columnsNodeAggregateId | ReferenceName | Targets | DimensionSpacePoint(Targets comma-separated) and optionalProperties(JSON, for every target of the row) - see References.feature. - YAML:
I have the following nodes from file "homepage.yaml" in site "site", path relative to the feature file, same fields as the table plus an optionalreferences:list - see homepage.yaml. Invalid entries fail with their position in the file; the old Neos 8 export format is rejected. ... with overwrites:(nodeAggregateId | property | value) changes single properties of the file. Values that are valid JSON (true,42,null,"42",[...],{...}) are decoded, others used as text; an overwrite for a node that isn't in the file fails.- Assets:
I have a textual persistent resource ...andI have the following images:create file and image assets for node properties - see Download.feature. They're published right away (resource settings: Setup step 2).
Default dimension space point
Set it in the Background and leave out the DimensionSpacePoint column - every feature then shows which dimension its
fixtures are in:
Background: Given the default dimension space point is '{"language":"de"}'
It lasts until the end of the scenario and applies to rows without a dimension space point (node and reference tables,
YAML files) and to I get the node "..." without in dimension. Rows with their own keep it, so only the rows of
another variant need the column ("mixed mode"):
Given the default dimension space point is '{"language":"de"}' And I have the following nodes in site "site": | NodeAggregateId | Parent | NodeType | Properties | DimensionSpacePoint | | homepage | | Your.SitePackageKey:Document.StartPage | {"uriPathSegment":"site","title":"Home"} | | | section | homepage/main | Your.SitePackageKey:Content.Section | {} | | | swiss-teaser | section | Your.SitePackageKey:Content.Teaser | {"title":"Nur in der Schweiz"} | {"language":"ch"} |
A row of another dimension needs a parent that's visible there (e.g. ch specializing de) - the fixtures create no
variants. See DefaultDimension.feature.
Exporting existing content
Writing node tables by hand keeps most people from writing tests: every parent, tethered child, property format and constraint has to be right before the first assertion. Exporting a page editors built gives real NodeType combinations, texts and edge cases - and fits your current NodeTypes, since only declared properties are exported.
Button and CLI export the same tree in the format above: the node's closest document with all its ancestors and descendants, references between them, the hidden state and reference properties. Tethered nodes, unknown NodeTypes and undeclared properties are left out, and every row keeps its dimension space point. Assets are not exported - asset properties keep the asset id; create those assets in the scenario.
- Export Node button (inspector, tab with the gear icon, group "Export"): downloads the YAML for the selected node
from the current workspace and dimension. Administrators only - other users see it disabled, and the endpoint
answers 403. To allow other roles, grant them the privilege target
Sandstorm.E2ETestTools:NodeExportin yourPolicy.yaml. - CLI:
./flow e2efixture:export <nodeAggregateId> --dimension '{"language":"de"}'prints the YAML; run it in the Flow context with the content (e.g. inside your app container). Instead of the id,--uri-path about/teamselects the page by its URI path (without dimension prefix and suffix,/is the homepage;--source-site <siteNodeName>with several sites) - a wrong segment fails with the segments that exist at that level.--format gherkin --site-name siteprints the steps instead;--workspacedefaults tolive,--content-repositorytodefault.
Prefer the YAML file over inline steps - a page easily has hundreds of nodes - and set only what the scenario asserts
via with overwrites:. The exported ids are UUIDs; rename them only consistently (nodeAggregateId, parent,
targets and node:// links in properties) or leave them.
The CLI also suits coding agents: no backend login, plain stdout, and the import creates exactly what was exported - build an example page in the backend, export it by URI path into the feature's folder, add the assets it references.
Steps
All traits are in Sandstorm\E2ETestTools\Behat. FeatureContext.php.default uses all but WireMockTrait (it needs a
WireMock service); from PageAssertionsTrait on, the traits don't depend on each other - use the ones you need. Remove
project steps with the same wording before using them, otherwise Behat reports the steps as ambiguous. Buttons and
links are found by their accessible name, fields by their label, elements by data-testid.
FusionRenderingTrait
I have a site for Site Node :siteNodeName [with name :siteName]I have/create the following nodes in site :siteName:the following node references:the default dimension space point is :dimensionSpacePointI get the node :nodeAggregateId [in dimension :dimensionSpacePoint]I render the Fusion object :fusionPath:I render the Fusion object :fusionPath with the current context node:I render the pagethe Fusion output should equal to :expectedin the fusion output, the inner HTML of CSS selector :selector matches :expectedin the fusion output, the attributes of CSS selector :selector are:
NodeImportTrait
I have/create the following nodes from file :fileName in site :siteName [with overwrites:]
PersistentResourceTrait
Comes with FusionRenderingTrait.
I have a textual persistent resource :uuid named :filename with the following content:I have the following images:
PlaywrightTrait
I do a screenshot :filenameI debug the playwright script- prints the generated Playwright JS
NeosBackendControlTrait
I access the URI path :uriPaththe response status code should be :statusthere should be the text :expected on the pagethe URI path should be :uriPath- waits for the navigationI have a Neos backend user :username with password :password and role :roleI log into the backend using credentials :username :password [with username placeholder ... and password placeholder ...]I click the main menu item :menuItemI click the overview dashboard tile :tileTitleI click the document tree entry :documentTitle
PageAssertionsTrait
the page title should be :titlethere should not be the text :text on the pagethere should (not) be the text :text in :selectorthe element with test id :testId should be visible/hidden/focused/enabled/disabledthe element with test id :testId should (not) be in the viewport
FormInteractionTrait
I click the button :captionI click the link :captionI click the element with test id :testIdI fill :value into the field :labelthe field :label should have the value :valueI check/uncheck the checkbox :labelthe checkbox :label should (not) be checkedI choose the radio button :labelI select :option in the field :labelthe field :label should have :option selectedI upload the file :fileName to the field :label- relative to the feature fileI press the key :key
BrowserStateTrait
the cookie :name has the value :value- before the first visit, e.g. consentthe local/session storage key :key has the value :valuethe cookie :name should (not) be setthe cookie :name should have the value :valueI delete the cookie :namethe local/session storage key :key should have the value :valuethe local/session storage key :key should not be setI remove the local/session storage key :key
DebuggingTrait
I pause for debugging- see Debugging
WireMockTrait
the API :api path :path on :method serves response :file [with status :status]I load the stubs :tape of the API :apiI clear all API stubsthe API :api should have received :method :path [:count times]
Setup and details: Mocked third-party APIs.
Notes on the steps
the Fusion output should equal tocompares the whole HTML and breaks with every changed space or class - prefer the CSS selector steps.... matches ...compares for equality, not as regular expression.- Escaped quotes (
\") inside a"..."parameter don't match the step - put values with double quotes in single quotes ('{"all":true}'). - Screenshots document a result, they don't check it.
- Project-specific steps go into your
FeatureContextor a trait of your project; the shipped traits show how to drive the browser with$this->playwrightConnector->execute().
Examples by level
When to use which: testing guide.
- Component (a Fusion prototype, like a pure function):
I render the Fusion objectwithout nodes - FusionComponent/Button.feature. - Integration (node → Fusion wiring): create nodes,
I get the node,... with the current context node- FusionIntegration/Button.feature. - Page in the browser:
I access the URI path+ assertions/screenshot - PageRendering/Homepage.feature. - Page via Fusion (no request, no browser):
I render the page- PageRendering/FusionPage.feature.
Dynamic SUT URL
The SUT base URL comes from SYSTEM_UNDER_TEST_URL_FOR_PLAYWRIGHT. When it changes per scenario (e.g. dimensions or
sites resolved by host), call setSystemUnderTestUrlModifier() in a step of your own; it's reset before the next
scenario:
#[Given('my subdomain is :subdomain')] public function mySubdomainIs(string $subdomain): void { $this->setSystemUnderTestUrlModifier(fn (string $baseUrl) => sprintf('%s://%s.%s:%s%s', parse_url($baseUrl, PHP_URL_SCHEME), $subdomain, parse_url($baseUrl, PHP_URL_HOST), parse_url($baseUrl, PHP_URL_PORT), parse_url($baseUrl, PHP_URL_PATH), )); }
Mocked third-party APIs (WireMock)
When the site talks to an external API (shop backend, CRM, newsletter service), tests run against WireMock instead:
-
Run WireMock next to the app, locally and in CI (Docker image
wiremock/wiremock), and point the SUT context's API base URL to it (e.g.http://wiremock:8080/shop-api). -
Use
WireMockTraitand configure the APIs in the constructor of yourFeatureContext:$this->setupWireMock(getenv('WIREMOCK_ADMIN_URL') ?: 'http://wiremock:8080', [ 'shop' => ['fixtures' => __DIR__ . '/../WireMock/shop', 'pathPrefix' => '/shop-api'], ]);
-
Tag the features with
@wireMock- WireMock is reset before each of their scenarios.
Per API, the fixture directory holds _base/*.json (mapping files loaded before every scenario), response bodies and
one directory of mapping files per "tape":
the API :api path :path on :method serves response :file [with status :status]- answers one call with a body file (.jsonmay be omitted); wins over base stubs; a path with query string must match exactlyI load the stubs :tape of the API :api- imports all mapping files of the tape directoryI clear all API stubs- back to the base stubs, e.g. when the API's answer changes after an actionthe API :api should have received :method :path [:count times]- asserts an outgoing call, when the call is the feature
While writing a test, 'proxyBaseUrl' => 'https://real.api.example.com' in the API config forwards unmatched requests
to the real API, so you see which calls happen - never in CI. /__admin/mappings lists the active stubs.
Running Behat Tests
-
Start the bridge on your machine and keep it running (e.g. all day). It runs every script it receives and listens on all interfaces, so containers reach it - keep it in a trusted network:
cd playwright-bridge && node index.js # port 3000; HEADLESS=false node index.js shows the browser
-
Make sure your application runs and the E2E database is migrated (Setup step 2).
-
Run Behat where your application runs. Neos.Behat boots Flow in
FLOW_CONTEXTand accepts onlyTesting/Behator a sub context - set it when your container has another one. A folder, feature file orfile:lineafter the config runs only those scenarios:FLOW_CONTEXT=Testing/Behat bin/behat -c DistributionPackages/Your.SitePackageKey/Tests/Behavior/behat.yml.dist FLOW_CONTEXT=Testing/Behat bin/behat -c DistributionPackages/Your.SitePackageKey/Tests/Behavior/behat.yml.dist DistributionPackages/Your.SitePackageKey/Tests/Behavior/Features/Fusion/ FLOW_CONTEXT=Testing/Behat bin/behat -c DistributionPackages/Your.SitePackageKey/Tests/Behavior/behat.yml.dist DistributionPackages/Your.SitePackageKey/Tests/Behavior/Features/WebsiteRendering.feature:27
IDE "run" buttons usually don't work when Behat runs in a container - use the CLI or your project tasks.
Debugging
- Watch the tests: start the bridge with
HEADLESS=false node index.js- worth a project task next to the headless one. - Screenshots:
And I do a screenshot "name.png"in a@playwrightscenario; failed steps get anerror_*.pngautomatically. Both go to the results directory:e2e-results/where Behat runs, or what you pass tosetupPlaywright($resultsDir). - Traces: failed
@playwrightscenarios writereport_<feature>_<scenario>.zipto the results directory - open it withnpx playwright show-trace <file>. For traces of every scenario (or none), call$this->setPlaywrightTracingMode(self::PLAYWRIGHT_TRACING_MODE_ALWAYS)(or..._OFF; default..._ON_ERROR) in theFeatureContextconstructor. - Server logs: an exception in the SUT only shows up as error page, the stack trace is in the logs. The Flow logs
(
Data/Logs) are cleared before every scenario, and a failed scenario gets its log and exception files copied tologs_<feature>_<line>_<scenario>/in the results directory. If the SUT logs elsewhere, callsetFlowLogsDirectory('/path/as/seen/from/behat');setFlowLogsDirectory(null)switches clearing and copying off (e.g. when your development site sharesData/Logs). - Narrow it down:
--stop-on-failurestops at the first error, so you can inspect the E2E database;-vvvprints full stack traces; tag scenarios (e.g.@debug) and run them with--tags=debug. - Pause the browser:
And I pause for debuggingopens the Playwright inspector (page.pause()) on the bridge side. It needs a visible browser (HEADLESS=false) and Behat run withPAUSE_FOR_DEBUGGING=true, otherwise the step fails right away. The SUT and its database keep the scenario's state, so you can click around. - Let an agent analyse failures: the results directory holds screenshots, traces, server logs and - with
--format junit --out <dir>- the JUnit report. Tell a coding agent like Claude where it is.
Migrating tests from Neos 8
Start your FeatureContext from FeatureContext.php.default instead of
patching the old one - a FeatureContext of an older version fails with missing classes. Also new:
- The traits moved from
Sandstorm\E2ETestTools\Tests\Behavior\Bootstrap\toSandstorm\E2ETestTools\Behat\and are autoloaded - drop therequire_oncelines. - The templates are in
Templates/(FeatureContext.php.default,playwright-bridge/) - compare your copy of the bridge with the new one. Testing/Behatneeds the SUT's cache storage andapplicationIdentifierand its persistent resource storage/target (Setup step 2).- Behat must run with
FLOW_CONTEXT=Testing/Behat(or a sub context) - Neos.Behat 9.1 refuses other contexts, e.g. a container-wideDevelopment/...or the SUT context of a CI job. - Symfony projects are no longer supported - stay on 8.x there.
In the feature files (Fixtures shows the result):
| Neos 8 | Neos 9 |
|---|---|
tag @fixtures |
@flowEntities: Neos.Behat resets the database on this tag now (FlowEntitiesTrait; the old FlowContextTrait with @fixtures is deprecated) |
I have/create the following nodes: |
I have/create the following nodes in site "site": |
columns Identifier | Path | Node Type | Properties | Language |
NodeAggregateId | Parent | NodeType | Properties | DimensionSpacePoint, optional Hidden |
Path (/sites/site/main/foo), a /sites row |
Parent: empty = site node, homepage/main = tethered child, otherwise the parent's NodeAggregateId; no /sites row |
Language: de |
DimensionSpacePoint: {"language":"de"} |
HiddenInIndex column |
"hiddenInMenu": true in Properties |
reference properties with node identifiers in Properties |
And the following node references: |
I get a node by path "/sites/site" with the following context: + table |
I get the node "homepage" in dimension '{"language":"de"}' |
I have the following nodes from file "x.yaml" [with overwrites] |
... from file "x.yaml" in site "site" [with overwrites:] |
overwrite columns identifier | property | value |
nodeAggregateId | property | value |
YAML exported with the Neos 8 button (nodes keyed by identifier, nested children) |
rejected - export again (Exporting existing content) |
Troubleshooting
-
The SUT serves the wrong content or database, although the response status is 200.
Your web server sets the SUT's Flow context per vhost, but the container also has a process-wide
FLOW_CONTEXT(for./flow), and Flow checksgetenv()before$_SERVER- so the container-wide value silently wins. This happens with every web server that sets only per-request variables (e.g. Caddy/FrankenPHP'senvdirective). Check which context's cache directory the request filled (e.g.Data/Temporary/Production/SubContextE2E-SUT/Cache/...).Fix: copy
$_SERVERto the real environment in a file that runs before every request (e.g.auto_prepend_filein php.ini):if (isset($_SERVER['FLOW_CONTEXT'])) { putenv('FLOW_CONTEXT=' . $_SERVER['FLOW_CONTEXT']); $_ENV['FLOW_CONTEXT'] = $_SERVER['FLOW_CONTEXT']; }
-
NodeConstraintException: Node type "..." is not allowed below tethered child nodes "..."when creating fixtures.The NodeType's
constraints.nodeTypesallow only a specific wrapper type (often a "section" or "row") directly in that collection - content nodes go inside the wrapper. Check theconstraintsbefore picking a fixture NodeType. -
Content changes don't show up on the SUT after resetting the content repository.
setupContentRepository()flushes only the routing caches and the Fusion content cache - not e.g. a full-page cache or caches of your project - and a flush only reaches the SUT when both contexts share the cache storage andapplicationIdentifier(Setup step 2). Give the cache the same storage in both contexts'Caches.yaml(for a file backend:backendOptions.cacheDirectoryinTesting/Behatpointing to the SUT's directory) and flush it in a@BeforeScenariohook - in PHP, not with./flow:$this->getObject(CacheManager::class)->getCache('<YourCacheIdentifier>')->flush();
-
Files and images created by fixture steps return 404 on the SUT.
Testing/Behatstores and publishes them somewhere else than the SUT looks - use the SUT's resource storage and target there (Setup step 2).