pieceofcake2/e2e-test
Browser end-to-end (E2E) tests for pieceofcake2/cakephp applications with Symfony Panther, headless Chrome and PHPUnit 9.6.
Contents
- What it is
- Requirements
- Installation
- Setup
- Writing tests
- API reference
- CI with GitHub Actions
- Tips
- Troubleshooting
- Credits
- License
What it is
A small test toolkit, installed as a dev dependency:
Serverstarts your application with PHP's built-in web server (php -S) and a router script. The server gets its own temporary directory for the cache, logs and sessions.Environmentholds the server and one headless Chrome per device (pcandmobileby default) for the whole test run.E2EExtensionis a PHPUnit extension that starts the server before the first test and stops everything after the last one.E2ETestCaseis aCakeTestCasewith browser helpers and assertions (visit(),fillField(),pressButton(),assertSeeText(),assertNoHorizontalOverflow()...). Fixtures work as usual.
It is a library (type: library, PSR-4 namespace Pieceofcake2\E2ETest\), not a CakePHP plugin: there is nothing to load with CakePlugin::load() and no installer-paths setting is needed.
E2E tests or integration tests?
pieceofcake2/cakephp is getting a backport of CakePHP 5's IntegrationTestTrait: it dispatches a request inside the PHPUnit process and checks the response. Use both; they answer different questions.
Integration tests (IntegrationTestTrait) | E2E tests (this package) | |
|---|---|---|
| Runs | In the PHPUnit process, no HTTP | A real HTTP server and a real Chrome |
| Speed | Milliseconds per request | Seconds per test (the browser renders pages) |
| JavaScript, CSS, layout | No | Yes |
| Cookies, sessions, redirects | Simulated | Real |
SecurityComponent / CSRF tokens | Usually disabled or mocked | Real tokens from the real form |
| Response status, headers, view variables, session values | Yes | No (only what a user sees) |
EmailTrait in the same process | Logged or captured by the server process | |
| Setup | None | Chrome, chromedriver, a test database for the server |
A short decision guide:
- Controller logic, status codes, redirects, flash messages, view variables, JSON APIs → integration tests
- A user flow across several pages (sign up, check out, a multi-step form) → E2E
- Anything that depends on JavaScript, the layout, responsive design or the browser → E2E
- Form tampering protection (
SecurityComponent), helpers used in templates, hidden fields → E2E: controller tests usually bypass the token validation, so mistakes there only show up in a browser - Keep E2E tests few and focused on the main flows; cover the edge cases with unit and integration tests
How it works
PHPUnit process php -S 127.0.0.1:<port> (application server)
┌───────────────────────────────┐ ┌────────────────────────────────────────┐
│ E2ETestCase │ HTTP │ router.php │
│ ├ $fixtures → test database ◄├────┐ │ ├ static files from webroot/ │
│ └ helpers ──► Panther Client │ │ │ ├ TMP/LOGS → its own temp directory │
│ │ │ │ │ └ webroot/index.php (your app) │
└───────────────────┼───────────┘ │ └───────────────▲──────────┬─────────────┘
│ WebDriver │ │ │
chromedriver ── headless Chrome ────────────┘ │
│ │
└──── the same test database ◄──┘E2EExtensionstarts the application server before the first test.- The first helper that needs a browser starts chromedriver and Chrome for that device.
- Before each test, the started browsers are reset (cookies, storage, extra windows).
CakeFixtureInjectorloads$fixturesinto the test database; the server reads the same database.- After the last test, the browsers and the server stop and the temporary directory is removed.
Requirements
| PHP | 8.0 or later. Composer installs Panther 2.1 on PHP 8.0 and Panther 2.4 on PHP 8.1+ (Panther 2.2+ needs PHP 8.1) |
| CakePHP | pieceofcake2/cakephp 2.12+ |
| PHPUnit | 9.6 (the version pieceofcake2/cakephp supports) |
| Browser | Google Chrome or Chromium, and the chromedriver of the same major version |
| OS | macOS and Linux. Windows should work but is not tested in CI |
| PHP extensions | dom (Panther), plus whatever your application needs |
Installation
composer require --dev pieceofcake2/e2e-testChrome and chromedriver
chromedriver must match the major version of the installed Chrome. The easiest way is bdi, which detects Chrome and downloads the matching driver to drivers/chromedriver, where Panther looks for it:
composer require --dev dbrekelmans/bdi
vendor/bin/bdi detect driversRun it again after Chrome updates itself. Add /drivers to .gitignore.
- macOS: install Google Chrome as usual. Panther finds
drivers/chromedriver; or setPANTHER_CHROME_DRIVER_BINARY. If macOS blocks the downloaded driver, runxattr -d com.apple.quarantine drivers/chromedriver. - Linux: install Chrome or Chromium from your distribution or Google, then bdi. In containers that run as root, set
PANTHER_NO_SANDBOX=1. - CI: use browser-actions/setup-chrome with
install-chromedriver: true(see CI with GitHub Actions). GitHub'subuntu-latestimage also has Chrome and a matching chromedriver in$CHROMEWEBDRIVER.
Chrome and chromedriver are looked up in this order:
PANTHER_CHROME_DRIVER_BINARY/PANTHER_CHROME_BINARY, or thebrowser.chromeDriverBinary/browser.chromeBinaryoptions- Panther:
drivers/chromedriver,vendor/bin/chromedriver, thenPATH; chromedriver finds Chrome itself
Setup
The examples use the modern layout of pieceofcake2/app (webroot/, config/, src/, tests/). The legacy layout differs only in a few paths.
your-app/
├── phpunit.xml.dist # your unit and integration tests (unchanged)
├── phpunit.e2e.xml.dist # E2E tests
└── tests/
├── bootstrap.php # your test bootstrap (unchanged)
└── E2E/
├── bootstrap.php # loads tests/bootstrap.php and configures the E2E environment
├── AppE2ETestCase.php # optional: your helpers (login ...)
└── ContactFormTest.php1. phpunit.e2e.xml.dist
Keep the E2E tests in their own PHPUnit configuration, so that vendor/bin/phpunit stays fast and does not need Chrome. docs/examples/phpunit.e2e.xml.dist:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/E2E/bootstrap.php"
backupGlobals="false"
stderr="true"
colors="true">
<php>
<ini name="memory_limit" value="-1"/>
<!-- Defaults for the test process; real environment variables win unless force="true" -->
<env name="DEBUG" value="0"/>
</php>
<!-- Starts the application server before the first test and stops it and the browsers after the last one -->
<extensions>
<extension class="Pieceofcake2\E2ETest\PHPUnit\E2EExtension"/>
</extensions>
<!-- Loads $fixtures into the test database, as in your other tests -->
<listeners>
<listener class="CakeFixtureInjector" file="./vendor/pieceofcake2/cakephp/src/Cake/TestSuite/Fixture/CakeFixtureInjector.php"/>
</listeners>
<testsuites>
<testsuite name="E2E">
<directory>tests/E2E/</directory>
</testsuite>
</testsuites>
</phpunit>backupGlobals="false": the environment keeps the server and browsers in static propertiesThe extension can also read the configuration from a PHP file that returns an array, instead of
Environment::configure()in the bootstrap file:xml<extension class="Pieceofcake2\E2ETest\PHPUnit\E2EExtension"> <arguments> <string>tests/E2E/config.php</string> </arguments> </extension>Without the extension, the server starts with the first test and stops when PHP exits (a shutdown function). The extension makes the start and stop explicit and reports startup errors before the first test.
2. The E2E bootstrap file
tests/E2E/bootstrap.php loads your usual test bootstrap and configures the application server. docs/examples/bootstrap.php:
<?php
/**
* Bootstrap of the browser end-to-end tests: tests/E2E/bootstrap.php
*/
use Pieceofcake2\E2ETest\Environment;
require dirname(__DIR__) . '/bootstrap.php';
// The application server reads its database from DB_* (config/database.php of pieceofcake2/app).
// Point it to the test database, where CakeTestCase loads the fixtures.
$database = [];
foreach (['DATASOURCE', 'HOST', 'PORT', 'USERNAME', 'PASSWORD', 'DATABASE'] as $name) {
$value = getenv('DB_TEST_' . $name);
$database['DB_' . $name] = $value === false ? null : $value;
}
Environment::configure([
'server' => [
'env' => $database + [
'DEBUG' => getenv('E2E_DEBUG') ?: '0',
// Never send real mail from tests
'EMAIL_TRANSPORT' => 'Debug',
],
],
'browser' => [
'lang' => 'en-US',
],
'artifactsDir' => dirname(__DIR__) . DS . 'E2E' . DS . 'artifacts',
]);The server process inherits the environment of the test process and then applies server.env (null removes an inherited variable, '' sets it empty). That is how you give the server its own settings without touching config/.env: real environment variables take precedence over .env in pieceofcake2/app, so a value set here wins.
Which variables you need depends on your config/ files. Typical ones:
| Purpose | Example |
|---|---|
| Database of the server = test database | DB_DATABASE, DB_HOST ... or DATABASE_URL |
| No debug output, production-like caching | DEBUG=0 (use E2E_DEBUG=1 to investigate errors) |
| Fixed salts, so encrypted cookies are stable | SECURITY_SALT, SECURITY_CIPHER_SEED |
| No real mail | your transport setting, see Mail |
| No external services (error tracking, storage, queues) | empty DSNs, bucket names ... |
| Full base URL of the application (links in mail, redirects) | APP_FULL_BASE_URL => '{baseUrl}', see below |
The server picks a free port when it starts, so its URL is not known when you write the configuration. Use the placeholders {baseUrl}, {host} and {port} in server.env values; they are replaced with the server's values when it starts:
<?php
use Pieceofcake2\E2ETest\Environment;
Environment::configure([
'server' => [
'env' => [
'APP_FULL_BASE_URL' => '{baseUrl}', // e.g. http://127.0.0.1:54321
],
],
]);Other text in the value is kept ('{baseUrl}/api'), and anything else in braces is left as it is. The server also sets E2E_BASE_URL itself (see The router script).
3. The router script
PHP's built-in server needs a router script to send every request that is not a file to the front controller. The package ships one (vendor/pieceofcake2/e2e-test/resources/router.php) and uses it by default. It:
- serves existing files in the document root (CSS, JavaScript, images) as they are;
- defines
TMPandLOGSfromE2E_TMPandE2E_LOGS, so the server writes its cache, logs and sessions into its own temporary directory. Your developmenttmp/andlogs/are not touched, and the tests work where they are not writable; - sets
SCRIPT_NAME/PHP_SELFto/index.php, so CakePHP detects the base path correctly; - requires the front controller (
E2E_FRONT_CONTROLLER, default<docroot>/index.php).
You need your own router only to do more before the application boots, e.g. define other constants. Copy docs/examples/router.php and set the router option:
<?php
/**
* Custom router script for the application server: tests/E2E/router.php
*/
// Serve existing files (CSS, JavaScript, images) as they are
$path = (string)parse_url((string)$_SERVER['REQUEST_URI'], PHP_URL_PATH);
$file = realpath($_SERVER['DOCUMENT_ROOT'] . rawurldecode($path));
if ($file !== false && is_file($file) && !str_ends_with($file, '.php')) {
return false;
}
// Isolate the cache, logs and sessions of the server (E2E_TMP and E2E_LOGS are set by the Server class)
define('TMP', getenv('E2E_TMP'));
define('LOGS', getenv('E2E_LOGS'));
// Your additions, e.g. a flag the application can check
define('E2E_TESTING', true);
// CakePHP detects the base path from the script name
$_SERVER['SCRIPT_NAME'] = $_SERVER['PHP_SELF'] = '/index.php';
$_SERVER['SCRIPT_FILENAME'] = getenv('E2E_FRONT_CONTROLLER');
return require getenv('E2E_FRONT_CONTROLLER');<?php
use Pieceofcake2\E2ETest\Environment;
Environment::configure(['server' => ['router' => __DIR__ . DS . 'router.php']]);The server passes these variables to the router and the application:
| Variable | Value |
|---|---|
E2E_TMP | The temporary directory of the server, with a trailing separator |
E2E_LOGS | E2E_TMP + logs/ |
E2E_FRONT_CONTROLLER | Absolute path of the front controller |
E2E_BASE_URL | e.g. http://127.0.0.1:8080 (useful for App.fullBaseUrl) |
The temporary directory contains cache/models, cache/persistent, cache/views, logs, sessions and tests (option tmpSubdirs), and session.save_path points to sessions.
4. Modern and legacy layouts
The document root is found automatically:
| Layout | Document root | Configuration | Detected when |
|---|---|---|---|
| Modern (pieceofcake2/app) | webroot/ | config/ | webroot/ exists in the project root |
| Legacy (classic CakePHP 2) | app/webroot/ | app/Config/ | app/webroot/ exists |
The project root is ROOT when it is defined (it is, after your test bootstrap), otherwise the current directory. When WWW_ROOT is defined and you did not set workingDir, it is used as the document root. You can always set docroot (and frontController) explicitly:
<?php
use Pieceofcake2\E2ETest\Environment;
// Legacy layout, explicitly
Environment::configure([
'server' => [
'workingDir' => ROOT,
'docroot' => ROOT . DS . 'app' . DS . 'webroot',
],
]);In the legacy layout:
- the E2E bootstrap file and tests usually live in
app/Test/E2E/and the PHPUnit configuration points there app/webroot/index.phpmust not hard-codeTMPorLOGSbefore the router defines them (the classic file does not); CakePHP's core then uses the router's values- the default artifacts directory is
TMP/e2e-artifacts, which isapp/tmp/e2e-artifacts
5. The test database
The browser can only see data that the server can read, so the server must use the database where CakeTestCase loads the fixtures: your test datasource.
Strategy
Use a separate database for tests (never your development database). pieceofcake2/app reads it from
DB_TEST_*; the bootstrap example above passes those values to the server asDB_*, so the server'sdefaultdatasource is the test database.Declare
$fixturesin each test (or in a base class, for tables every page needs). The fixtures are created before each test and dropped after it, as in your other tests. Missing tables cause errors in the server, so list every table that the visited pages use.Create the records a test needs inside the test, with models, before opening the page:
php<?php $article = ClassRegistry::init('Article'); $article->save(['title' => 'Draft', 'published' => false]); $this->visit('/articles'); $this->assertNotSeeText('Draft');Check the result of a form in the database, not only on the page: the test process and the server share the database.
Notes
- Fixtures use the
testdatasource'sprefix. If you use one, give the server the same prefix. - The server caches table schemas in its own
cache/models. The temporary directory is new for each run, so schema changes are picked up; within a run, fixtures must not change a table's columns between tests. debug> 0 in the server makes CakePHP check the schema on every request;DEBUG=0is faster and closer to production.- SQLite
:memory:cannot be shared between processes. Use a file database or a database server. - Do not run E2E tests in parallel against the same database.
6. Mail
The server is a separate process, so mail it sends cannot be captured in the test process.
Never send real mail. Configure the server's transport with an environment variable, e.g.
EMAIL_TRANSPORT=Debugread inconfig/email.php. CakePHP 2'sDebugTransportreturns the message without sending it.Check the content by logging it: with
'log' => true(or['level' => 'debug', 'scope' => 'email']) in the email configuration,CakeEmailwrites every message to the server's log, which you can read in the test:php<?php use Pieceofcake2\E2ETest\Environment; $log = (string)file_get_contents(Environment::server()->logsDir() . 'debug.log'); $this->assertStringContainsString('To: jane@example.com', $log);TestEmailTransport/EmailTraitfrom theIntegrationTestTraitbackport in pieceofcake2/cakephp (once released) capture mail in the same process. They are the right tool for integration tests; they do not see mail sent by the E2E application server.
7. Run
vendor/bin/phpunit -c phpunit.e2e.xml.dist
vendor/bin/phpunit -c phpunit.e2e.xml.dist --filter ContactFormTest
PANTHER_NO_HEADLESS=1 vendor/bin/phpunit -c phpunit.e2e.xml.dist --filter testSend # watch the browserEnvironment variables
Environment variables take precedence over Environment::configure(), so that CI and developers can change ports and binaries without editing files.
| Variable | Option | Default | Description |
|---|---|---|---|
E2E_HOST | server.host | 127.0.0.1 | Host the application server listens on |
E2E_PORT | server.port | a free port | Port of the application server |
E2E_KEEP_TMP | server.keepTmp | off | Keep the server's temporary directory (logs, sessions) after the run |
E2E_CHROMEDRIVER_PORT | browser.chromeDriverPort | free ports | Port of the first chromedriver; the next devices use the next ports |
PANTHER_CHROME_DRIVER_BINARY | browser.chromeDriverBinary | Panther's lookup | Path to chromedriver |
PANTHER_CHROME_BINARY | browser.chromeBinary | chromedriver's lookup | Path to Chrome |
PANTHER_NO_HEADLESS | browser.headless (inverted) | headless | 1 opens visible browser windows |
PANTHER_NO_SANDBOX | browser.noSandbox | off | 1 adds --no-sandbox (containers running as root, some CI images) |
E2E_ARTIFACTS_DIR | artifactsDir | TMP/e2e-artifacts | Where failure screenshots, HTML and logs are saved |
E2E_WAIT_TIMEOUT | waitTimeout | 10 | Seconds the helpers wait for pages and elements |
E2E_WEBDRIVER_CONNECTION_TIMEOUT | browser.connectionTimeout | 10 | Seconds to wait for the connection to chromedriver |
E2E_WEBDRIVER_REQUEST_TIMEOUT | browser.requestTimeout | 60 | Seconds one WebDriver command (a click, a page load) may take |
Boolean variables accept 1, true, on, yes. An empty variable is ignored. Your own variables (like E2E_DEBUG in the bootstrap example) are up to you.
Writing tests
A complete example
A contact form with validation errors and a successful submission. docs/examples/ContactFormTest.php:
<?php
/**
* Example test: tests/E2E/ContactFormTest.php
*/
use Pieceofcake2\E2ETest\TestSuite\E2ETestCase;
class ContactFormTest extends E2ETestCase
{
/**
* @var array<string>
*/
public $fixtures = ['app.contact', 'app.topic'];
/**
* Required fields are reported and what was entered is kept.
*
* @return void
*/
public function testValidationErrors(): void
{
$this->visit('/contacts/add');
$this->fillField('Email address', 'not an email');
$this->pressButton('Send');
$this->assertCurrentPath('/contacts/add');
$this->assertSeeText('Enter your name.');
$this->assertSeeText('Enter a valid email address.');
$this->assertFieldValue('not an email', 'Email address');
}
/**
* A valid message is saved and the thank-you page is shown.
*
* @return void
*/
public function testSend(): void
{
$this->visit('/contacts/add');
$this->fillField('Your name', 'Jane Doe');
$this->fillField('Email address', 'jane@example.com');
$this->selectOption('Topic', 'Billing');
$this->checkLabel('I agree to the terms');
$this->attachFile('Attachment', __DIR__ . '/files/invoice.pdf');
$this->pressButton('Send');
$this->assertCurrentPath('/contacts/thanks');
$this->assertSeeText('Thank you for your message.');
// The browser and the test share the test database
$contact = ClassRegistry::init('Contact')->find('first', ['order' => ['Contact.id' => 'DESC']]);
$this->assertSame('jane@example.com', $contact['Contact']['email']);
}
/**
* The form fits a phone screen.
*
* @return void
*/
public function testMobileLayout(): void
{
$mobile = $this->mobile();
$this->visit('/contacts/add', $mobile);
$this->assertSeeText('Contact us', $mobile);
$this->assertNoHorizontalOverflow($mobile);
}
}Principles that keep E2E tests reliable:
- Find things the way a user does: by link text, button text and labels, not by CSS classes
- Every action that loads a page waits for the next page (
clickLink(),pressButton(),submitForm()); wait explicitly for JavaScript results (waitForText(),waitForElement()). Neversleep() - Check the outcome in the database as well as on the page
- One flow per test; create the data each test needs
Opening pages and reading them
<?php
$this->visit('/articles?page=2'); // or $this->get('/articles?page=2')
$this->assertCurrentPath('/articles?page=2');
$this->assertPageTitle('Articles');
$this->assertSeeText('First article'); // visible text only
$this->assertNotSeeText('Draft');
$this->assertResponseContains('data-id="1"'); // the HTML (DOM after JavaScript)
$this->assertElementExists('table.articles');
$this->assertElementVisible('nav .pagination');
// A Symfony DomCrawler of the current page
$this->assertSame('Articles', $this->page()->filter('h1')->text());
// document.body.innerText, and the HTML
$this->assertStringContainsString('First article', $this->bodyText());
$this->assertStringContainsString('<table class="articles">', $this->pageSource());assertSeeText() checks what is visible (innerText): hidden elements and markup are ignored. assertResponseContains() checks the HTML, like its IntegrationTestTrait namesake. Prefer assertSeeText() for what users see.
Forms
Fields are found by their name, id or label text, in that order, so all of these work:
<?php
$this->fillField('Email address', 'jane@example.com'); // label text
$this->fillField('data[User][email]', 'jane@example.com'); // name
$this->fillField('UserEmail', 'jane@example.com'); // id
$this->selectOption('Country', 'Japan'); // option text, or value when no text matches
$this->check('Send me the newsletter'); // checkbox or radio (clicks the label when the input is hidden)
$this->uncheck('Send me the newsletter');
$this->checkLabel('I agree to the terms'); // clicks the label like a user, then asserts the state
$this->attachFile('Attachment', TESTS . 'Fixture' . DS . 'files' . DS . 'invoice.pdf');
$this->pressButton('Save'); // <button> text or <input type="submit"> value; waits for the next page
$this->assertFieldValue('jane@example.com', 'Email address');When a page has several forms with the same field names or labels (a search form in the header, one form per row), pass a CSS selector of the form or container as $within. The field is searched only inside the first visible match (its label may be anywhere on the page):
<?php
$this->fillField('data[Entry][name]', 'Jane Doe', within: '#EntryAddForm');
$this->attachFile('data[Entry][file]', $path, null, '#EntryAddForm'); // positional, the samefillField(), selectOption(), check(), uncheck(), attachFile() and findField() take $within.
submitForm() fills several fields and presses a button in one call. Text fields and selects take a string, checkboxes a bool, radio buttons their value, multiple selects an array, file fields a path:
<?php
$this->submitForm('Save', [
'Title' => 'Hello',
'data[Article][category_id]' => 'News',
'data[Article][visibility]' => 'public',
'Published' => true,
'Tags' => ['php', 'cakephp'],
]);Forms protected by SecurityComponent work without changes: the browser posts the real tokens. If JavaScript adds or changes fields, unlock them in the controller ($this->Security->unlockedFields) exactly as you must for real users; an E2E test is where such mistakes show up.
Waiting
Actions that load a new page wait for it: clickLink(), pressButton() and submitForm() return when the next page has loaded (document.readyState === 'complete'). For everything else, wait for the result:
<?php
$this->pressButton('Load more', null, false); // a JavaScript button: do not wait for a new page
$this->waitForElement('.spinner');
$this->waitForText('20 more articles'); // AJAX result
$this->waitForElementToContain('#cart-count', '3 items'); // ... in a specific element
$this->waitForElementToDisappear('.spinner');
$this->waitUntil(fn($client) => $client->executeScript('return window.appReady === true;'));
// Run your own action and wait for the next page
$this->waitForNavigation(fn() => $this->executeScript('document.forms[0].submit();'));All waits use waitTimeout (10 seconds, E2E_WAIT_TIMEOUT) unless you pass a timeout. A timeout fails the test with the current path in the message.
PC and mobile
Two device presets are configured:
| Device | Viewport | Emulation |
|---|---|---|
pc (default) | 1280x1024 | Desktop Chrome |
mobile | 390x844, pixel ratio 3, touch | Chrome's mobile emulation with an iPhone (iOS 17 Safari) User-Agent |
Each device is a separate browser with its own cookies. Helpers use pc unless you pass a client:
<?php
$mobile = $this->mobile();
$this->visit('/', $mobile);
$this->clickLink('Menu', $mobile, false);
$this->assertSeeText('Contact', $mobile);
$this->assertNoHorizontalOverflow($mobile); // the page is not wider than the screenassertNoHorizontalOverflow() compares the width of the document with the layout viewport, so it also catches pages that a mobile browser would zoom out to fit.
Add or change devices in the configuration (set one to null to remove it):
<?php
use Pieceofcake2\E2ETest\Environment;
Environment::configure([
'devices' => [
'pc' => ['width' => 1440, 'height' => 900],
'tablet' => ['width' => 820, 'height' => 1180, 'mobile' => true, 'pixelRatio' => 2, 'userAgent' => 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) ...'],
],
]);
// In a test
$tablet = $this->client('tablet');
$this->visit('/', $tablet);Multiple windows
<?php
$this->visit('/contacts/add');
$original = $this->waitForNewWindow(fn() => $this->click('#privacy-policy-link'));
$this->assertSeeText('Privacy policy'); // in the new window
$this->closeOtherWindows(null, $original); // close it and go back
$this->checkLabel('I agree to the privacy policy');Cookies and logging in
<?php
$this->assertCookieIsSet('CAKEPHP'); // the session cookie
$this->assertCookie('en', 'language');
$this->deleteCookie('remember_me');
$this->assertCookieNotSet('remember_me');
$this->assertNull($this->cookie('remember_me'));The browser stores cookies as the server sent them: values written by CookieComponent are encrypted.
Put application-specific helpers such as logging in into your own base class. docs/examples/AppE2ETestCase.php:
<?php
use Pieceofcake2\E2ETest\TestSuite\E2ETestCase;
use Symfony\Component\Panther\Client;
use Symfony\Component\Panther\DomCrawler\Crawler;
abstract class AppE2ETestCase extends E2ETestCase
{
/**
* Tables every page needs
*
* @var array<string>
*/
public $fixtures = ['app.user'];
/**
* Logs in through the login form, like a user does.
*
* @param string $email
* @param string $password
* @param \Symfony\Component\Panther\Client|null $client
* @return \Symfony\Component\Panther\DomCrawler\Crawler The page after logging in
*/
protected function login(string $email = 'admin@example.com', string $password = 'password', ?Client $client = null): Crawler
{
$this->visit('/users/login', $client);
return $this->submitForm('Log in', [
'data[User][email]' => $email,
'data[User][password]' => $password,
], $client);
}
}Logging in through the form in each test is slower than injecting a session, but it tests what users do and keeps the tests independent of how sessions are stored.
Each test starts clean
Before each test (@before setUpBrowser()), every started browser:
- closes extra windows and tabs,
- opens
resetPath(/favicon.icoby default) if it is not on the application's origin, because cookies can only be deleted there, - deletes all cookies,
localStorageandsessionStorage.
So each test starts logged out. The browsers themselves stay open for the whole run (starting Chrome takes a second or two). If your application has no favicon.ico, set resetPath to another static file.
Failure screenshots and HTML
When a test fails or errors, onNotSuccessfulTest() saves, for every started browser:
| File | Content |
|---|---|
<Class>-<test>-pc.png | Screenshot of the viewport |
<Class>-<test>-pc.html | The HTML (DOM), with the URL in the first line |
<Class>-<test>-mobile.png / .html | The same for the mobile browser, if it was started |
<Class>-<test>-logs.txt | The last 200 lines of each log of the server: server.log (requests, PHP errors), error.log, debug.log ... |
They are saved to artifactsDir: TMP/e2e-artifacts/ by default (tmp/e2e-artifacts/ in the modern layout), or E2E_ARTIFACTS_DIR. The original failure is always reported; errors while saving are ignored.

In CI, upload the directory when the job fails (see CI with GitHub Actions) and download it from the run's summary page. Open the .html file in a browser; it has no styles when they were loaded by relative URLs, but the content and form values are there.
If you override onNotSuccessfulTest(), call parent::onNotSuccessfulTest($t) or $this->saveFailureArtifacts().
Using the trait without CakeTestCase
E2ETestCase is CakeTestCase + BrowserTestTrait. For tests that need no fixtures, use the trait in any PHPUnit test case:
<?php
use PHPUnit\Framework\TestCase;
use Pieceofcake2\E2ETest\TestSuite\BrowserTestTrait;
class StaticPagesTest extends TestCase
{
use BrowserTestTrait;
/**
* @return void
*/
public function testAbout(): void
{
$this->visit('/pages/about');
$this->assertSeeText('About us');
}
}Do not combine BrowserTestTrait and IntegrationTestTrait in one class: both define get() and assertResponseContains(). Keep integration tests and E2E tests in separate classes and suites.
API reference
All classes are in the Pieceofcake2\E2ETest\ namespace.
Configuration options
Environment::configure(array $config), or the array returned by the file given to E2EExtension. server, browser and devices are merged with the current values.
| Option | Type | Default | Description |
|---|---|---|---|
server | array | [] | Options of the application server, see below |
browser | array | [] | Options of Chrome, see below |
devices | array | pc, mobile | Device presets by name; null removes one |
defaultDevice | string | 'pc' | Device of helpers called without a client |
waitTimeout | int | 10 | Seconds helpers wait for pages and elements |
artifactsDir | string|null | TMP/e2e-artifacts | Directory for failure screenshots, HTML and logs |
resetPath | string | '/favicon.ico' | Static file opened before cookies are deleted |
server (Server options)
| Option | Type | Default | Description |
|---|---|---|---|
host | string | '127.0.0.1' | Host to listen on |
port | int|null | null (a free port) | Port to listen on |
docroot | string|null | detected | Document root (webroot/, app/webroot/, or WWW_ROOT) |
frontController | string|null | <docroot>/index.php | Script that handles requests for non-existing files |
router | string|null | bundled resources/router.php | Router script |
workingDir | string|null | ROOT, else the current directory | Working directory; also used to detect the layout |
env | array | [] | Environment variables of the server; null removes an inherited one. {baseUrl}, {host} and {port} in values are replaced when the server starts |
inheritEnv | bool | true | Pass the environment of the test process to the server (otherwise only PATH and temp variables) |
ini | array | [] | php.ini settings (-d), e.g. ['upload_max_filesize' => '20M', 'post_max_size' => '20M'] |
tmpDir | string|null | null (new directory in the system temp directory) | Directory for cache, logs and sessions; a configured one is never removed |
tmpSubdirs | array | cache/models, cache/persistent, cache/views, logs, sessions, tests | Directories created in tmpDir |
keepTmp | bool | false | Keep the created temporary directory after the run |
startupTimeout | float | 15.0 | Seconds to wait until the server accepts connections |
php | string | PHP_BINARY | PHP binary of the server |
browser (ChromeClientFactory options)
| Option | Type | Default | Description |
|---|---|---|---|
chromeDriverBinary | string|null | Panther's lookup | Path to chromedriver |
chromeBinary | string|null | chromedriver's lookup | Path to Chrome |
chromeDriverPort | int|null | free ports | Port of the first chromedriver; further devices use the next ports |
chromeDriverArguments | array | [] | Additional chromedriver arguments, e.g. ['--log-path=/tmp/chromedriver.log'] |
headless | bool | true | Run without windows |
noSandbox | bool | false | Add --no-sandbox |
lang | string|null | null | Browser language (--lang and the Accept-Language preference), e.g. 'en-US' |
blockExternalHosts | bool | true | Resolve only the application's host: analytics, ads, fonts and CDNs are not loaded |
allowedHosts | array | [] | Hosts that stay reachable when blockExternalHosts is on, e.g. a CDN your page needs |
arguments | array | [] | Additional Chrome arguments for every device |
capabilities | array | [] | Additional WebDriver capabilities, e.g. ['acceptInsecureCerts' => true] |
connectionTimeout | float|null | 10 | Seconds to wait for the connection to chromedriver; null keeps php-webdriver's 30 |
requestTimeout | float|null | 60 | Seconds one WebDriver command may take before it fails; null keeps php-webdriver's 180 |
Chrome always gets --disable-dev-shm-usage, --force-prefers-reduced-motion (no animations), --disable-search-engine-choice-screen, --no-first-run and --no-default-browser-check, and in headless mode --headless=new and --disable-gpu.
Device presets (devices.<name>)
| Key | Type | Default | Description |
|---|---|---|---|
width, height | int | Viewport size in CSS pixels (window size for desktop devices) | |
mobile | bool | false | Use Chrome's mobile emulation (mobileEmulation) |
pixelRatio | float | 3 | Device pixel ratio of the mobile emulation |
touch | bool | true | Touch events in the mobile emulation |
userAgent | string|null | Chrome's | User-Agent |
arguments | array | [] | Additional Chrome arguments for this device |
E2ETestCase and BrowserTestTrait
abstract class Pieceofcake2\E2ETest\TestSuite\E2ETestCase extends CakeTestCase uses trait Pieceofcake2\E2ETest\TestSuite\BrowserTestTrait. All helpers are protected, and the parameter ?Client $client = null selects the browser (default: defaultDevice). Field helpers that take ?string $within = null after it search the field only inside the first visible element matching that CSS selector (a form or container). Client is Symfony\Component\Panther\Client, Crawler is Symfony\Component\Panther\DomCrawler\Crawler, WebDriverElement is Facebook\WebDriver\WebDriverElement.
Browsers
| Method | Returns | Description |
|---|---|---|
client(?string $device = null) | Client | The browser of a device; starts and resets it on first use |
pc() | Client | client('pc') |
mobile() | Client | client('mobile') |
Navigation
| Method | Returns | Description |
|---|---|---|
visit(string $path, ?Client $client = null) | Crawler | Opens a path (/articles?page=2) or an absolute URL |
get(string $url, ?Client $client = null) | Crawler | Alias of visit(), named like IntegrationTestTrait::get() |
currentUrl(?Client $client = null) | string | URL of the current page |
currentPath(?Client $client = null) | string | Path and query, e.g. /articles?page=2 |
waitForNavigation(callable $action, ?Client $client = null) | Crawler | Runs $action and waits for the next page |
waitForNewWindow(callable $action, ?Client $client = null) | string | Runs $action, switches to the new window; returns the previous window's handle |
closeOtherWindows(?Client $client = null, ?string $keep = null) | void | Closes all windows except $keep (default: the first) and switches to it |
resetBrowser(?Client $client = null) | void | Closes other windows, deletes cookies and storage |
Reading the page
| Method | Returns | Description |
|---|---|---|
page(?Client $client = null) | Crawler | The current page (filter(), filterXPath(), text(), attr() ...) |
bodyText(?Client $client = null) | string | Visible text (document.body.innerText) |
pageSource(?Client $client = null) | string | HTML of the current DOM |
fieldValue(string $field, ?Client $client = null) | string | Value of a field (name, id or label) |
cookie(string $name, ?Client $client = null) | ?string | Value of a cookie, null when not set |
deleteCookie(string $name, ?Client $client = null) | void | Deletes a cookie |
executeScript(string $script, array $arguments = [], ?Client $client = null) | mixed | Runs JavaScript (return ...;); arguments are arguments[0] ... |
Interacting
| Method | Returns | Description |
|---|---|---|
clickLink(string $text, ?Client $client = null, bool $waitForNavigation = true) | Crawler | Clicks a visible link by text, image alt or title |
pressButton(string $text, ?Client $client = null, bool $waitForNavigation = true) | Crawler | Presses a visible <button> or <input type="submit|button|image"> by text/value |
click(WebDriverElement|string $element, ?Client $client = null, bool $waitForNavigation = false) | Crawler | Clicks an element or the first visible match of a CSS selector |
submitForm(string $button, array $values = [], ?Client $client = null) | Crawler | Sets fields (by name, id or label) and presses the button |
fillField(string $field, string $value, ?Client $client = null, ?string $within = null) | void | Clears a text field and types |
selectOption(string $field, string $option, ?Client $client = null, ?string $within = null) | void | Selects by option text, else by value |
check(string $field, ?Client $client = null, ?string $within = null) | void | Checks a checkbox or radio button |
uncheck(string $field, ?Client $client = null, ?string $within = null) | void | Unchecks a checkbox |
checkLabel(string $label, ?Client $client = null) | void | Clicks the label of an unchecked input and asserts it is checked |
uncheckLabel(string $label, ?Client $client = null) | void | Clicks the label of a checked input and asserts it is unchecked |
attachFile(string $field, string $path, ?Client $client = null, ?string $within = null) | void | Chooses a file (the file must exist) |
Waiting (timeouts in seconds, default waitTimeout)
| Method | Returns | Description |
|---|---|---|
waitUntil(callable $condition, ?Client $client = null, ?int $timeout = null, string $message = '') | mixed | Waits until $condition($client) is truthy and returns its value |
waitForText(string $text, ?Client $client = null, ?int $timeout = null) | void | Waits until the page shows the text |
waitForElement(string $selector, ?Client $client = null, ?int $timeout = null) | WebDriverElement | Waits until a matching element is visible |
waitForElementToContain(string $selector, string $text, ?Client $client = null, ?int $timeout = null) | WebDriverElement | Waits until a matching element shows the text (its visible text) and returns it |
waitForElementToDisappear(string $selector, ?Client $client = null, ?int $timeout = null) | void | Waits until no matching element is visible |
Finding elements
| Method | Returns | Description |
|---|---|---|
findVisible(WebDriverBy|string $locator, ?Client $client = null) | WebDriverElement | First visible element; a string is a CSS selector |
findField(string $field, ?Client $client = null, ?string $within = null) | WebDriverElement | Field by name, id or label text (prefers visible ones) |
findButton(string $text, ?Client $client = null) | WebDriverElement | Visible button by text (whitespace, including U+3000, trimmed) |
xpathLiteral(string $text) | string | XPath::literal() |
Assertions
| Method | Description |
|---|---|
assertSeeText(string $text, ?Client $client = null) | The page shows the text |
assertNotSeeText(string $text, ?Client $client = null) | The page does not show the text |
assertResponseContains(string $content, string $message = '', bool $ignoreCase = false, ?Client $client = null) | The HTML contains $content |
assertResponseNotContains(string $content, string $message = '', bool $ignoreCase = false, ?Client $client = null) | The HTML does not contain $content |
assertResponseRegExp(string $pattern, string $message = '', ?Client $client = null) | The HTML matches |
assertResponseNotRegExp(string $pattern, string $message = '', ?Client $client = null) | The HTML does not match |
assertCurrentPath(string $expected, ?Client $client = null) | Path and query of the current page |
assertPageTitle(string $expected, ?Client $client = null) | <title> |
assertElementExists(string $selector, ?Client $client = null) | A matching element exists (visible or not) |
assertElementNotExists(string $selector, ?Client $client = null) | No element matches |
assertElementVisible(string $selector, ?Client $client = null) | A matching element is visible |
assertFieldValue(string $expected, string $field, ?Client $client = null) | Value of a field |
assertCookie(string $expected, string $name, string $message = '', ?Client $client = null) | Value of a cookie |
assertCookieIsSet(string $name, string $message = '', ?Client $client = null) | The cookie is set |
assertCookieNotSet(string $name, string $message = '', ?Client $client = null) | The cookie is not set |
assertNoHorizontalOverflow(?Client $client = null) | The page is not wider than the viewport |
assertStylesheetsLoaded(?Client $client = null) | At least one non-empty stylesheet is loaded (e.g. the asset build exists) |
Lifecycle
| Method | Description |
|---|---|
setUpBrowser(): void (public, @before) | Starts the server and resets the started browsers |
onNotSuccessfulTest(Throwable $t): void | Saves failure artifacts, then rethrows via the parent |
saveFailureArtifacts(): array | Saves screenshots, HTML and log tails; returns the file paths |
The Client is Panther's, so everything Panther and php-webdriver offer is available too: $this->pc()->getWebDriver(), ->takeScreenshot(), ->waitForVisibility(), ->back() ...
Mapping from IntegrationTestTrait
Names follow CakePHP 5's IntegrationTestTrait where a browser can do the same thing:
IntegrationTestTrait | E2E | Notes |
|---|---|---|
get($url) | get($url) / visit($path) | A real page load |
post($url, $data) | submitForm($button, $values) | A browser posts through forms, with the real tokens |
assertResponseContains() / NotContains() | same names | The current DOM instead of the response body; plus $client |
assertResponseRegExp() / NotRegExp() | same names | |
| — | assertSeeText() / assertNotSeeText() | Visible text only; prefer these |
assertRedirect($url) | assertCurrentPath($path) | The browser follows redirects; check where it ended up |
assertCookie(), assertCookieIsSet(), assertCookieNotSet() | same names | Cookies stored by the browser |
assertResponseOk(), assertResponseCode(), assertHeader() | — | WebDriver does not expose status codes or headers; use integration tests |
assertSession(), assertFlashMessage() | assertSeeText() | Check what the user sees; the session is in the server process |
assertTemplate(), assertLayout(), viewVariable() | — | Use integration tests |
EmailTrait | server log, see Mail | Mail is sent by the server process |
Environment
final class Pieceofcake2\E2ETest\Environment (static)
| Method | Returns | Description |
|---|---|---|
configure(array $config) | void | Sets options (see Configuration options); throws LogicException once started, InvalidArgumentException on unknown options |
config(?string $key = null) | mixed | The configuration with environment variables applied |
reset() | void | Stops everything and restores the defaults |
start() | void | Starts the application server (no-op when running) |
isRunning() | bool | Whether the server is running |
server() | Server | The server instance |
baseUrl() | string | e.g. http://127.0.0.1:8080; starts the server |
url(string $path) | string | Absolute URL of a path; absolute URLs are returned as they are |
client(?string $device = null) | Client | The browser of a device (starts it) |
hasClient(string $device) | bool | Whether that browser has started |
clients() | array<string, Client> | Started browsers by device |
quitClients(?string $device = null) | void | Quits one or all browsers |
shutdown() | void | Quits the browsers and stops the server |
artifactsDir() | string | Directory for failure artifacts (trailing separator) |
waitTimeout() | int | Seconds |
Constants: DEFAULTS (the default configuration), IPHONE_USER_AGENT.
<?php
use Pieceofcake2\E2ETest\Environment;
// e.g. read a log of the application server in a test
$errors = (string)file_get_contents(Environment::server()->logsDir() . 'error.log');
$this->assertStringNotContainsString('Warning', $errors);Server
class Pieceofcake2\E2ETest\Server. Usable on its own, e.g. for HTTP-level smoke tests without a browser.
<?php
use Pieceofcake2\E2ETest\Server;
$server = new Server(['workingDir' => ROOT, 'env' => ['DEBUG' => '0']]);
$server->start();
$this->assertStringContainsString('<h1>', (string)file_get_contents($server->baseUrl() . '/'));
$server->stop();| Method | Returns | Description |
|---|---|---|
__construct(array $options = []) | See the server options; unknown options throw InvalidArgumentException | |
start() | void | Starts the server and waits until it accepts connections; restarts it if the process has exited. Throws RuntimeException when the port is in use or the server does not start (the message includes the server output) |
stop(bool $removeTmp = false) | void | Stops the server; removes the created temporary directory unless keepTmp (or always with $removeTmp) |
isRunning() | bool | Whether the process is running |
host(), port() | string, int | Where it listens (port() throws before start when no port is configured) |
baseUrl() | string | http://host:port |
docroot(), frontController(), router(), workingDir() | string | Resolved absolute paths |
tmpDir(), logsDir(), logFile() | ?string | Temporary directory, its logs/, and logs/server.log (null before start unless tmpDir is set) |
environment() | array<string, string> | The environment variables passed to the server (placeholders replaced) |
placeholders() | array<string, string> | {baseUrl}, {host} and {port} and their values (throws like port() before start) |
getOption(string $name) | mixed | An option |
static isListening(string $host, int $port) | bool | Whether something accepts connections |
static findFreePort(string $host = '127.0.0.1') | int | A free port |
ChromeClientFactory
class Pieceofcake2\E2ETest\ChromeClientFactory: creates Panther clients; used by Environment.
| Method | Returns | Description |
|---|---|---|
__construct(array $options = []) | The browser options | |
create(array $device, string $baseUrl, ?int $port = null) | Client | Starts chromedriver and Chrome; quits chromedriver again if Chrome does not start |
arguments(array $device, string $baseUrl) | array<string> | Chrome arguments |
capabilities(array $device) | array<string, mixed> | WebDriver capabilities (goog:chromeOptions ...) |
managerOptions(array $device, int $port) | array<string, mixed> | Options of Panther's ChromeManager (port, capabilities, chromedriver arguments, timeouts) |
E2EExtension
final class Pieceofcake2\E2ETest\PHPUnit\E2EExtension implements BeforeFirstTestHook, AfterLastTestHook
| Method | Description |
|---|---|
__construct(?string $configFile = null) | Optional PHP file returning the configuration array |
executeBeforeFirstTest() | Applies the configuration file and starts the server |
executeAfterLastTest() | Environment::shutdown() |
AppLayout and XPath
| Method | Returns | Description |
|---|---|---|
AppLayout::detect(string $root) | string | AppLayout::MODERN (webroot/) or AppLayout::LEGACY (app/webroot/); throws when there is none |
AppLayout::webroot(string $root) | string | The document root of that layout |
XPath::literal(string $text) | string | An XPath string literal for any text, including quotes |
<?php
use Facebook\WebDriver\WebDriverBy;
use Pieceofcake2\E2ETest\XPath;
$row = $this->findVisible(WebDriverBy::xpath('//tr[td[normalize-space(.)=' . XPath::literal("O'Reilly") . ']]'));
$this->click($row);CI with GitHub Actions
A complete job: MySQL as the test database, Chrome for Testing with the matching chromedriver, and the failure artifacts uploaded. docs/examples/e2e.yml:
name: E2E
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
e2e:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: cakephp_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
env:
DB_TEST_DATASOURCE: Database/Mysql
DB_TEST_HOST: 127.0.0.1
DB_TEST_USERNAME: root
DB_TEST_PASSWORD: root
DB_TEST_DATABASE: cakephp_test
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: pdo_mysql
coverage: none
- name: Install Composer packages
run: composer install --no-interaction --no-progress
# Chrome for Testing and the matching chromedriver
- name: Setup Chrome
id: setup-chrome
uses: browser-actions/setup-chrome@v2
with:
chrome-version: stable
install-chromedriver: true
- name: Run E2E tests
env:
PANTHER_CHROME_BINARY: ${{ steps.setup-chrome.outputs.chrome-path }}
PANTHER_CHROME_DRIVER_BINARY: ${{ steps.setup-chrome.outputs.chromedriver-path }}
PANTHER_NO_SANDBOX: 1
run: vendor/bin/phpunit -c phpunit.e2e.xml.dist --colors=always
# Screenshots, HTML and server logs of failed tests
- name: Upload E2E artifacts
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: e2e-artifacts
path: tests/E2E/artifacts/
retention-days: 7
if-no-files-found: ignore- If your build creates CSS/JavaScript (Vite, webpack), build it before the E2E step: the server serves
webroot/as it is.assertStylesheetsLoaded()catches a missing build. - Without
setup-chrome, use the runner's Chrome andPANTHER_CHROME_DRIVER_BINARY: ${{ env.CHROMEWEBDRIVER }}/chromedriver. - Make the paths you upload match
artifactsDir. - The artifacts contain the pages and logs of your test data. Keep the test data fictional.
Tips
Headless or headed. Run with PANTHER_NO_HEADLESS=1 to watch the browsers; combine with --filter to run one test. To pause and inspect, put a breakpoint in the test (Xdebug) or temporarily fgets(STDIN); (remove it before committing). browser.arguments: ['--auto-open-devtools-for-tabs'] opens DevTools.
Debugging the server. The end of the server logs is saved with every failure (-logs.txt). Set E2E_KEEP_TMP=1 to keep the server's whole temporary directory (logs, sessions, cache) after the run; it is cakephp_e2e_<pid>_<random> in the system temporary directory (Environment::server()->tmpDir()). Pass a debug flag to the server (E2E_DEBUG=1 in the bootstrap example) to see CakePHP's error pages, e.g. why SecurityComponent black-holed a request.
Timeouts. Keep waitTimeout at 10 seconds; increase it (E2E_WAIT_TIMEOUT=30) on slow CI machines rather than adding waits to tests. server.startupTimeout covers slow first requests (e.g. opcache off). waitTimeout limits the helpers' waits; browser.requestTimeout (60 seconds, E2E_WEBDRIVER_REQUEST_TIMEOUT) limits each WebDriver command, so a hung Chrome fails the test after a minute instead of php-webdriver's three. Raise it only if a single page load legitimately takes longer.
Avoid flaky tests.
- Wait for a result (
waitForText(),waitForElement(),waitUntil()), neversleep() - Use
pressButton()/clickLink()for anything that loads a page; passfalseonly for JavaScript actions - Animations are disabled by
--force-prefers-reduced-motion; respectprefers-reduced-motionin your CSS - External hosts are blocked, so third-party scripts cannot slow tests down or fail them. If a page needs a CDN, add it to
allowedHostsor, better, serve the asset locally in tests - Create data per test; do not rely on the order of tests
Parallel runs and ports. By default the server and chromedriver use free ports, so several runs on one machine do not collide. Set E2E_PORT / E2E_CHROMEDRIVER_PORT only when you need fixed ports (e.g. to open the application in your own browser while a test is paused). Parallel runs need separate test databases.
Isolated tmp and logs. The test process writes to your usual tmp/ (fixtures, caches of the test process); the server writes to its own temporary directory. Both are separate from your development server.
Uploads. Files uploaded through the browser are saved by the server wherever your application saves them. Point that location to the temporary directory in tests (e.g. an environment variable read by your upload configuration), or delete new files in tearDown(). For large files, raise server.ini.upload_max_filesize and post_max_size.
Speed. Starting Chrome takes a second or two per device, once per run. Each page load runs the whole application, like in production. Group checks that need the same state into one test, and keep the number of E2E tests small compared to unit and integration tests.
Troubleshooting
"Port 8080 is already in use" — Another process (often a previous run that was killed) listens on the configured port. Unset E2E_PORT to use a free port, choose another, or stop the process (lsof -i :8080). For chromedriver, Panther reports The port 9515 is already in use: unset E2E_CHROMEDRIVER_PORT or kill the leftover chromedriver.
"The application server did not start" — The message includes the server output. Common causes: the docroot does not exist (set docroot), the PHP binary lacks an extension, or the host cannot be bound.
"This version of ChromeDriver only supports Chrome version N" — chromedriver and Chrome differ in their major version. Run vendor/bin/bdi detect drivers again after Chrome updates, or point PANTHER_CHROME_BINARY to the Chrome that matches the driver. In CI, setup-chrome with install-chromedriver: true installs a matching pair.
"chromedriver binary not found" — Install it with bdi (drivers/chromedriver), put it on PATH, or set PANTHER_CHROME_DRIVER_BINARY.
Chrome crashes at startup in Docker / CI (session not created, DevToolsActivePort file doesn't exist) — Set PANTHER_NO_SANDBOX=1. --disable-dev-shm-usage is already set.
Every page is an error page; "Missing database table" — The server does not use the test database or the fixtures are not loaded. Check server.env (see The test database) and list every table the page uses in $fixtures. Read <test>-logs.txt in the artifacts directory.
Permission errors for tmp or logs — The server writes to its own temporary directory, not to tmp/. If you see them anyway, your webroot/index.php or config/ defines TMP/LOGS before the router can; use the defined-check (if (!defined('TMP'))) or a custom router. The test process still needs a writable tmp/.
Logged out after every request; session or cookie missing —
- Secure-only session cookies (
session.cookie_secure, aSecurecookie flag): the server runs over plain HTTP, so the browser may drop them. Make the flag configurable and disable it for the E2E server (server.env) App.fullBaseUrlor a cookiedomainthat differs from127.0.0.1:<port>: pass the server's URL to your configuration ('APP_FULL_BASE_URL' => '{baseUrl}'inserver.env, or readE2E_BASE_URL) or leave the domain empty- Session
defaults=cachewith a cache engine the server cannot reach: usephporcakein tests
Forms are black-holed (SecurityComponent) — The browser posts what your form contains. A black hole means the form and the token disagree: fields added or changed by JavaScript must be unlocked ($this->Form->unlockField() / unlockedFields), a helper called with a different instance than $this->Form (e.g. lowercase $this->form) adds fields that are not in the token, or hidden values change. Run with debug enabled in the server to see the reason. Do not disable the component for E2E tests: catching these mistakes is one of the reasons to have them.
CSRF token expired / "The request has been black-holed" on the second submit — csrfUseOnce tokens are consumed by the first submit. Visit the form again instead of going back in history.
Clicks hit the wrong element or "element not interactable" — Another element (a cookie banner, a fixed header) covers it, or it is outside the viewport. Close banners in a helper, use checkLabel() for custom-styled inputs, or scroll with executeScript('arguments[0].scrollIntoView();', [$element]).
Text not found although it is on the page — assertSeeText() only sees visible text, after CSS text-transform. Compare with the screenshot, or use assertResponseContains() for markup.
Credits
The helpers grew out of browser tests of real CakePHP 2 applications and were generalized for this package. Built on Symfony Panther, php-webdriver and PHPUnit.
License
The MIT License. See LICENSE.
