One test database per test file, and a ready-made backend session, for TYPO3.
A TYPO3 extension. It creates one test database per test file and provides a ready-made backend session, so tests never fill in the login form.
Developed in the
typo3-playwright-toolkit monorepo;
plan2net/playwright-toolkit is a read-only mirror that Packagist reads. Open issues
and pull requests on the monorepo. A commit pushed to the mirror is overwritten by the
next release.
It needs the npm package, which runs the tests, and the DDEV add-on, which provides the database service.
Important
Setting this up for the first time? Follow SETUP.md instead. It covers all three packages in order. This README documents the extension on its own.
Requirements · Install · Configure · Using the package · Reference · Troubleshooting · Related packages
- TYPO3 11.5, 12.4, 13.4 or 14.3
- PHP 8.1 or newer
- PostgreSQL, MySQL, MariaDB or SQLite
TYPO3 11.5 and 12.4 are both ELTS. CI verifies each against its last public release, 11.5.41 and 12.4.45, because ELTS releases sit behind credentials and cannot be tested here.
composer require --dev plan2net/playwright-toolkitEvery part of the extension first checks whether TYPO3 runs in the Testing context,
so it does nothing in Production and Development. Install it as a --dev dependency
anyway: that check is not a reason to ship it to production.
Then let the wizard do the rest:
ddev exec 'TYPO3_CONTEXT=Testing vendor/bin/typo3 playwright:setup' # once the add-on is installed: ddev playwright setupIt runs every check of
SETUP.md,
writes the files that are missing and builds the template database. That guide
explains the command, its --no-interaction mode, and why it has to run in the
Testing context. It needs DDEV; without it, follow
Without DDEV.
The wizard reads your files, versions and settings. Once the project runs, use
ddev playwright doctor to see whether a test run still works: it starts a browser,
has this extension build a test database, uses the backend session in it and drops
the database again.
Give the project a second host name that runs in the Testing context. Your normal host keeps running in Development, and only the second one serves the test API.
ddev config --additional-hostnames=example-testingTYPO3 reads the context from an environment variable, so your web server sets it.
For apache-fpm, create .ddev/apache/context.conf:
SetEnvIf Host "." TYPO3_CONTEXT=Development/Docker
SetEnvIf Host "-testing\.ddev\.site$" TYPO3_CONTEXT=TestingFor nginx-fpm, edit .ddev/nginx_full/nginx-site.conf. Delete the #ddev-generated
line at the top first, or DDEV overwrites your changes on the next start:
map $http_host $typo3_context {
default "Development/Docker";
~*-testing\.ddev\.site$ "Testing";
}
# inside location ~ \.php$
fastcgi_param TYPO3_CONTEXT $typo3_context;Important
A .ddev/nginx/*.conf file does not work here. DDEV includes those after the PHP
location block, and nginx then ignores the value.
A complete file is checked in at
tests/e2e/consumer/.ddev/nginx_full/nginx-site.conf.
Copy from there.
Do not write that marker anywhere else in the file, not even in a comment: DDEV
searches the whole file for it. Then run ddev restart.
Point TYPO3 at the test database of the current request from
config/system/additional.php. TYPO3 auto-loads that file and no context-suffixed
variant, so this is the place:
<?php
if (\TYPO3\CMS\Core\Core\Environment::getContext()->isTesting()) {
\Plan2net\PlaywrightToolkit\TestContext::configureCurrentRequest();
}Without those lines the overrides never run and every test uses your ordinary
database. Keep the context check: configureCurrentRequest() acts on the test ID
alone, so outside the Testing context a request carrying that header would switch the
connection on your ordinary hostname too.
Important
Under DDEV this file already exists and carries #ddev-generated. Delete that line
first, as with the nginx file, or the next ddev restart writes the file again
without your call, and your tests then pass against your ordinary database.
Put the call at the end of the file. It reads the Default connection, which DDEV
sets in the block above it.
If your project already keeps a separate file per context, put the call in the
Testing one and require that from additional.php behind the same check.
Note
config/system/additional.php is the Composer-mode path, which is where TYPO3 12.4,
13.4 and 14.3 look. Two older layouts differ, and only the file name changes; the
contents above are the same:
- TYPO3 11.5 loads
typo3conf/AdditionalConfiguration.php, in Composer mode too. - Classic (non-Composer) 12.4 and 13.4 load
typo3conf/system/additional.php.
ConfigurationManager::getAdditionalConfigurationFileLocation() is the authority if
you need to check a version not listed here.
It reads your Default connection and writes the per-test one back. If a request
carries no test ID, nothing changes and nothing is created: the site uses its normal
database.
There are two, and the only question they answer is who writes $GLOBALS:
Your additional.php |
Call |
|---|---|
writes $GLOBALS itself |
configureCurrentRequest(), as above |
| collects settings in an array and applies them at the end | resolveCurrentRequestSettings(), merged in last |
They do the same work otherwise. Both pick the test database, create it as part of
answering, and switch on the error capture behind typo3-errors.json.
If your project collects its settings in an array that is applied afterwards, the one-line call above is overwritten again. Merge the settings into that array instead, last:
$configurationSettings = array_merge(
$configurationSettings,
TestContext::resolveCurrentRequestSettings($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default'] ?? [])
);Note
If that array also carries your database credentials, which is common when they
come from environment variables rather than settings.php, then $GLOBALS does
not name a driver yet at this point, and the call throws
The Default database connection names no driver. Fold the pending values in
first:
$defaultConnection = $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default'] ?? [];
foreach ($configurationSettings as $path => $value) {
if (str_starts_with($path, 'DB/Connections/Default/')) {
$defaultConnection[substr($path, strlen('DB/Connections/Default/'))] = $value;
}
}
$configurationSettings = array_merge(
$configurationSettings,
TestContext::resolveCurrentRequestSettings($defaultConnection)
);configureCurrentRequest($defaultConnection) takes the same argument, for
projects that write to $GLOBALS directly.
Whichever call you use:
SYS/encryptionKeymust already hold the key your test databases were prepared with.- The returned
DB/Connections/Default/*values are paths, not array keys, and have to land last.
On the first: the toolkit hashes the pre-seeded session id with that key to tell an
already-seeded database from a new one, and it does so before TYPO3 boots. Two setups
get it wrong: a Testing configuration using a different key than the rest of the
site, and one that collects its settings in an array and writes them to $GLOBALS
only afterwards. In both, assign the key before the call:
$GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = '…the key playwright:prepare uses…';Otherwise the lookup finds nothing, every request treats its database as unseeded and clones it again, and the content the test just built is gone.
On the second: a returned key looks like DB/Connections/Default/dbname. Write it
with ArrayUtility::setValueByPath, or the helper your project already uses for its
other settings, and it lands correctly. A plain array_merge into
$GLOBALS['TYPO3_CONF_VARS'] creates one key with that literal name, and your tests
then run against your real database.
Put SQL files in a folder and list them in the extension settings:
fixturesPath = fixtures
fixtureManifest = pages.sql,sys_template.sql
The files load in the order you list them, so put parent records first. A fixture is
plain SQL against the schema TYPO3 just built, so fixtures/pages.sql can be as
small as a site root and one page under it:
INSERT INTO pages (uid, pid, doktype, title, slug, is_siteroot) VALUES
(1, 0, 1, 'Home', '/', 1),
(2, 1, 1, 'Products', '/products', 0);Your fixtures may set their own uid values, as this one does. You do not have to reset any sequences afterwards; the extension does that for you, so the first record a test writes does not collide with uid 2.
Keep the manifest to what every test needs: a site root, a TypoScript template, the storages your content references. Everything else is faster and clearer built through the builders in the test that needs it.
A test that needs an image names it. Commit the files and point the
mediaPath extension setting at the folder:
mediaPath = tests/playwright/fixtures/media
tests/playwright/fixtures/media/
├── media.json (optional)
├── hero.png
└── gallery/
├── lawn-01.jpg
└── lawn-02.jpg
playwright:prepare copies them into a FAL storage of its own and indexes each one
through TYPO3, then writes var/playwright/media.json so the npm package can turn a
name into a sys_file uid. Your fixture SQL never mentions a file:
element.withFile('hero.png')
element.withFileReferences('gallery', ['gallery/lawn-01.jpg', 'gallery/lawn-02.jpg'])The name is the path inside the folder, so subfolders are part of it. Change an image and the next prepare rebuilds the template. Delete one from the published folder and it rebuilds too.
The three do not have the same lifetime. The rows travel with the test's database, the manifest is read once per worker, and the files are written once and shared by every test. Only the derivatives TYPO3 processes from them belong to one test.
A storage rewrites the names it stores: spaces become underscores, accented characters are normalised, and a storage marked case-insensitive lowercases everything. Prepare refuses a fixture whose name would come out different and prints both names, so you rename the file yourself rather than reference a name that does not exist.
Titles, alternative texts and online media go in media.json:
{
"hero.png": { "title": "Hero", "alternative": "Rolling green lawn" },
"gallery/": { "alternative": "Campus lawn in summer" },
"gallery/lawn-07.jpg": { "alternative": "The one with the bench" },
"campus-tour.youtube": { "onlineMediaId": "dQw4w9WgXcQ", "title": "Campus tour" }
}title and alternative are written to sys_file_metadata. Set alternative for
anything a test renders: an image without alternative text fails an axe scan.
A key ending in / gives defaults to everything under it. An exact entry overrides it
field by field, and "alternative": "" is an answer too, so it wins over an inherited
value.
An entry with onlineMediaId has no committed file. It becomes the pseudo-file TYPO3
writes for a YouTube or Vimeo reference, built from the ID alone, so prepare makes no
network request. Give the ID, not a URL.
By default the files go into a storage the extension provisions at uid 900, pointing
at fileadmin/playwright-media/, so your own storages are untouched. To use a storage
you already declare, name it and a folder inside it in the mediaStorage setting:
mediaStorage = 1:/playwright-media/
That folder is emptied on every prepare, so it has to be a folder of its own rather
than a storage root, and the storage has to use the Local driver.
Build the template database once, before the tests run:
ddev playwright prepareThis loads the schema through TYPO3's schema migrator, applies your fixtures, writes
the prepared backend session, and stores a fingerprint of all three. A later run
whose fingerprint still matches skips the rebuild and answers in a moment;
--force rebuilds anyway. It also stores the API secret in
var/playwright/api-secret. Every test database is a copy of this template.
ddev playwright runs this step for you, so you rarely call it directly.
Images are kept apart per test as well. Each test database gets its own folder for
processed images — fileadmin/_processed_<test id> for a configured storage,
typo3temp/assets/_processed_<test id> for files that belong to none of them, such
as an extension's own — and every conversion gets a scratch name of its own in
typo3temp/assets/images/, where TYPO3 works before moving the result into that
folder. All of them carry the test ID, so nothing is shared between tests and all of
it goes when the test database does. A GIFBUILDER image, which TYPO3 would otherwise
encode straight into its final path, is encoded beside it and moved into place in
one step, so a test arriving while another is still encoding it never reads a
truncated file.
To check that a project is set up correctly, ask the health endpoint:
ddev exec 'curl -sS -H "X-Playwright-Toolkit-Secret: $(cat var/playwright/api-secret)" \
-H "X-Playwright-Test-Id: HEALTHCHECK00001" \
https://example-testing.ddev.site/typo3/test-api/health'It answers {"ok":true,…} and names the test database it just created from your
template. Send the test ID: without it the request uses the project's own database,
and the check fails with a 503 saying so. A 404 means the request never reached the
Testing context, a 401 means the secret does not match.
| Name | Default | Purpose |
|---|---|---|
fixturesPath |
none | Folder with your SQL fixture files, relative to the project root |
fixtureManifest |
none | Fixture file names, separated by commas, loaded in this order |
preseededSessionId |
playwright_test_session |
Backend session ID stored in the template database |
sessionUserId |
1 |
Backend user this session belongs to |
cleanupMinimumAgeMs |
3600000 |
How old a test database must be before cleanup may delete it |
mediaPath |
none | Folder with your committed media fixtures, relative to the project root. Empty switches media seeding off |
mediaStorage |
none | FAL storage and folder to seed into, as 1:/playwright-media/. Empty provisions a storage of its own |
If you change fixturesPath, fixtureManifest, the media settings or the session
settings, the next run rebuilds the template database.
sessionUserId decides who saves the content your tests build, so page permissions,
table access and mounts all apply to it. Fixtures are applied before the session is
seeded, and the seeded user is written with INSERT IGNORE, so a be_users row of
your own at that uid wins. Without one, the toolkit writes an admin there.
All endpoints start with /typo3/test-api/, need the
X-Playwright-Toolkit-Secret header, and answer 404 outside the Testing context.
CONTRACT.md
describes them.
The one exception is inspect, which a browser opens. It takes a signed link
instead of the header, because a browser cannot send one. See
Looking at a kept database.
A save through record/edit is also checked: a posted column that TCA does not have
is answered with 422 and the column name, instead of being dropped in silence. Only
a request carrying the secret is checked, so an editor saving a form is untouched.
When a test fails its database is kept, and the test run prints a link for it:
tests/checkout.spec.ts → dbABCD1234EFGH5678
https://example-testing.ddev.site/typo3/test-api/inspect?id=…&t=…
Open it and you are in the TYPO3 backend of that test's database, logged in, with the frontend reachable from there. You need no browser extension and no header.
The backend shows the scenario and its test ID next to the site name in the top left corner, so two open tabs are never confused:
PlaywrightDemo [checkout · EF70E3DDD33D3571]
The scenario is the spec file's name, without its directory or .spec.ts suffix.
There is nothing to configure; the marker appears whenever a request carries a test
ID.
The link is signed with the API secret and lives 15 minutes. It sets two session cookies, so closing the browser ends the visit.
While a test runs, TYPO3 writes its own errors into that test's database, and the endpoint hands them back:
GET /typo3/test-api/errors?id=<testId>
Records DataHandler refused, uncaught exceptions, and anything logged at error level or worse, with the message already filled in. Repeats are counted rather than listed again.
Some problems never reach the log, so they cannot show up here:
- PHP fatal errors, such as running out of memory or hitting the time limit.
- The few exceptions TYPO3 skips on purpose, like a wrong host header or a blocked login attempt.
- Anything that goes wrong while the database itself is broken.
- A relation that ended up empty. TYPO3 reports records it rejected, not records it saved with a link pointing nowhere.
typo3 playwright:replay-prepare rebuilds the replay database from the template. It
is the plain db on the db-test container, reached through the fixed test ID
REPLAY0000000000, and ddev playwright replay calls it before running every
scenario's setup into that one database. The
npm README
describes replay mode.
Every endpoint answers 404. The request did not reach the Testing context. Open the testing host name, not the normal one, and check your web server configuration.
Tests write to your normal database. This has three causes. Your project merges
the override paths as array keys; DDEV rewrote additional.php because its
#ddev-generated marker is still there (see Database selection); or something strips
the test ID header on the way to PHP: fastcgi_pass_request_headers off in nginx, a
RequestHeader unset or a mod_security rule in Apache, or a proxy in front of the
testing hostname. TYPO3 then sees an ordinary request and answers it from the site's
own database, so the tests pass against the wrong content.
The run stops with "run ddev playwright prepare". The template database is
missing or was built with different settings. Run ddev playwright prepare again.
Every endpoint answers 401. The npm package and PHP do not share the secret
file. If they run in different containers, set PLAYWRIGHT_TOOLKIT_SECRET to the
same value on both sides.
@plan2net/typo3-playwright-toolkit, the npm package- DDEV add-on, the database service and commands