Skip to content
This is the documentation for pieceofcake2/e2e-test, a CakePHP 2.x plugin for pieceofcake2/cakephp. About this fork.

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 ​

A small test toolkit, installed as a dev dependency:

  • Server starts 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.
  • Environment holds the server and one headless Chrome per device (pc and mobile by default) for the whole test run.
  • E2EExtension is a PHPUnit extension that starts the server before the first test and stops everything after the last one.
  • E2ETestCase is a CakeTestCase with 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)
RunsIn the PHPUnit process, no HTTPA real HTTP server and a real Chrome
SpeedMilliseconds per requestSeconds per test (the browser renders pages)
JavaScript, CSS, layoutNoYes
Cookies, sessions, redirectsSimulatedReal
SecurityComponent / CSRF tokensUsually disabled or mockedReal tokens from the real form
Response status, headers, view variables, session valuesYesNo (only what a user sees)
MailEmailTrait in the same processLogged or captured by the server process
SetupNoneChrome, 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 ◄──┘
  1. E2EExtension starts the application server before the first test.
  2. The first helper that needs a browser starts chromedriver and Chrome for that device.
  3. Before each test, the started browsers are reset (cookies, storage, extra windows).
  4. CakeFixtureInjector loads $fixtures into the test database; the server reads the same database.
  5. After the last test, the browsers and the server stop and the temporary directory is removed.

Requirements ​

PHP8.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)
CakePHPpieceofcake2/cakephp 2.12+
PHPUnit9.6 (the version pieceofcake2/cakephp supports)
BrowserGoogle Chrome or Chromium, and the chromedriver of the same major version
OSmacOS and Linux. Windows should work but is not tested in CI
PHP extensionsdom (Panther), plus whatever your application needs

Installation ​

composer require --dev pieceofcake2/e2e-test

Chrome 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 drivers

Run it again after Chrome updates itself. Add /drivers to .gitignore.

  • macOS: install Google Chrome as usual. Panther finds drivers/chromedriver; or set PANTHER_CHROME_DRIVER_BINARY. If macOS blocks the downloaded driver, run xattr -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's ubuntu-latest image also has Chrome and a matching chromedriver in $CHROMEWEBDRIVER.

Chrome and chromedriver are looked up in this order:

  1. PANTHER_CHROME_DRIVER_BINARY / PANTHER_CHROME_BINARY, or the browser.chromeDriverBinary / browser.chromeBinary options
  2. Panther: drivers/chromedriver, vendor/bin/chromedriver, then PATH; 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.php

1. 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
<?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 properties

  • The 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
<?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:

PurposeExample
Database of the server = test databaseDB_DATABASE, DB_HOST ... or DATABASE_URL
No debug output, production-like cachingDEBUG=0 (use E2E_DEBUG=1 to investigate errors)
Fixed salts, so encrypted cookies are stableSECURITY_SALT, SECURITY_CIPHER_SEED
No real mailyour 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
<?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:

  1. serves existing files in the document root (CSS, JavaScript, images) as they are;
  2. defines TMP and LOGS from E2E_TMP and E2E_LOGS, so the server writes its cache, logs and sessions into its own temporary directory. Your development tmp/ and logs/ are not touched, and the tests work where they are not writable;
  3. sets SCRIPT_NAME / PHP_SELF to /index.php, so CakePHP detects the base path correctly;
  4. 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
<?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
<?php

use Pieceofcake2\E2ETest\Environment;

Environment::configure(['server' => ['router' => __DIR__ . DS . 'router.php']]);

The server passes these variables to the router and the application:

VariableValue
E2E_TMPThe temporary directory of the server, with a trailing separator
E2E_LOGSE2E_TMP + logs/
E2E_FRONT_CONTROLLERAbsolute path of the front controller
E2E_BASE_URLe.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:

LayoutDocument rootConfigurationDetected 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
<?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.php must not hard-code TMP or LOGS before 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 is app/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

  1. 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 as DB_*, so the server's default datasource is the test database.

  2. Declare $fixtures in 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.

  3. 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');
  4. 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 test datasource's prefix. 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=0 is 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=Debug read in config/email.php. CakePHP 2's DebugTransport returns the message without sending it.

  • Check the content by logging it: with 'log' => true (or ['level' => 'debug', 'scope' => 'email']) in the email configuration, CakeEmail writes 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 / EmailTrait from the IntegrationTestTrait backport 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 browser

Environment variables ​

Environment variables take precedence over Environment::configure(), so that CI and developers can change ports and binaries without editing files.

VariableOptionDefaultDescription
E2E_HOSTserver.host127.0.0.1Host the application server listens on
E2E_PORTserver.porta free portPort of the application server
E2E_KEEP_TMPserver.keepTmpoffKeep the server's temporary directory (logs, sessions) after the run
E2E_CHROMEDRIVER_PORTbrowser.chromeDriverPortfree portsPort of the first chromedriver; the next devices use the next ports
PANTHER_CHROME_DRIVER_BINARYbrowser.chromeDriverBinaryPanther's lookupPath to chromedriver
PANTHER_CHROME_BINARYbrowser.chromeBinarychromedriver's lookupPath to Chrome
PANTHER_NO_HEADLESSbrowser.headless (inverted)headless1 opens visible browser windows
PANTHER_NO_SANDBOXbrowser.noSandboxoff1 adds --no-sandbox (containers running as root, some CI images)
E2E_ARTIFACTS_DIRartifactsDirTMP/e2e-artifactsWhere failure screenshots, HTML and logs are saved
E2E_WAIT_TIMEOUTwaitTimeout10Seconds the helpers wait for pages and elements
E2E_WEBDRIVER_CONNECTION_TIMEOUTbrowser.connectionTimeout10Seconds to wait for the connection to chromedriver
E2E_WEBDRIVER_REQUEST_TIMEOUTbrowser.requestTimeout60Seconds 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
<?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()). Never sleep()
  • 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
<?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
<?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
<?php
$this->fillField('data[Entry][name]', 'Jane Doe', within: '#EntryAddForm');
$this->attachFile('data[Entry][file]', $path, null, '#EntryAddForm'); // positional, the same

fillField(), 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
<?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
<?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:

DeviceViewportEmulation
pc (default)1280x1024Desktop Chrome
mobile390x844, pixel ratio 3, touchChrome'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
<?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 screen

assertNoHorizontalOverflow() 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
<?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
<?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
<?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
<?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.ico by default) if it is not on the application's origin, because cookies can only be deleted there,
  • deletes all cookies, localStorage and sessionStorage.

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:

FileContent
<Class>-<test>-pc.pngScreenshot of the viewport
<Class>-<test>-pc.htmlThe HTML (DOM), with the URL in the first line
<Class>-<test>-mobile.png / .htmlThe same for the mobile browser, if it was started
<Class>-<test>-logs.txtThe 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.

Screenshot saved for a failed test of a contact form

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
<?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.

OptionTypeDefaultDescription
serverarray[]Options of the application server, see below
browserarray[]Options of Chrome, see below
devicesarraypc, mobileDevice presets by name; null removes one
defaultDevicestring'pc'Device of helpers called without a client
waitTimeoutint10Seconds helpers wait for pages and elements
artifactsDirstring|nullTMP/e2e-artifactsDirectory for failure screenshots, HTML and logs
resetPathstring'/favicon.ico'Static file opened before cookies are deleted

server (Server options)

OptionTypeDefaultDescription
hoststring'127.0.0.1'Host to listen on
portint|nullnull (a free port)Port to listen on
docrootstring|nulldetectedDocument root (webroot/, app/webroot/, or WWW_ROOT)
frontControllerstring|null<docroot>/index.phpScript that handles requests for non-existing files
routerstring|nullbundled resources/router.phpRouter script
workingDirstring|nullROOT, else the current directoryWorking directory; also used to detect the layout
envarray[]Environment variables of the server; null removes an inherited one. {baseUrl}, {host} and {port} in values are replaced when the server starts
inheritEnvbooltruePass the environment of the test process to the server (otherwise only PATH and temp variables)
iniarray[]php.ini settings (-d), e.g. ['upload_max_filesize' => '20M', 'post_max_size' => '20M']
tmpDirstring|nullnull (new directory in the system temp directory)Directory for cache, logs and sessions; a configured one is never removed
tmpSubdirsarraycache/models, cache/persistent, cache/views, logs, sessions, testsDirectories created in tmpDir
keepTmpboolfalseKeep the created temporary directory after the run
startupTimeoutfloat15.0Seconds to wait until the server accepts connections
phpstringPHP_BINARYPHP binary of the server

browser (ChromeClientFactory options)

OptionTypeDefaultDescription
chromeDriverBinarystring|nullPanther's lookupPath to chromedriver
chromeBinarystring|nullchromedriver's lookupPath to Chrome
chromeDriverPortint|nullfree portsPort of the first chromedriver; further devices use the next ports
chromeDriverArgumentsarray[]Additional chromedriver arguments, e.g. ['--log-path=/tmp/chromedriver.log']
headlessbooltrueRun without windows
noSandboxboolfalseAdd --no-sandbox
langstring|nullnullBrowser language (--lang and the Accept-Language preference), e.g. 'en-US'
blockExternalHostsbooltrueResolve only the application's host: analytics, ads, fonts and CDNs are not loaded
allowedHostsarray[]Hosts that stay reachable when blockExternalHosts is on, e.g. a CDN your page needs
argumentsarray[]Additional Chrome arguments for every device
capabilitiesarray[]Additional WebDriver capabilities, e.g. ['acceptInsecureCerts' => true]
connectionTimeoutfloat|null10Seconds to wait for the connection to chromedriver; null keeps php-webdriver's 30
requestTimeoutfloat|null60Seconds 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>)

KeyTypeDefaultDescription
width, heightintViewport size in CSS pixels (window size for desktop devices)
mobileboolfalseUse Chrome's mobile emulation (mobileEmulation)
pixelRatiofloat3Device pixel ratio of the mobile emulation
touchbooltrueTouch events in the mobile emulation
userAgentstring|nullChrome'sUser-Agent
argumentsarray[]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

MethodReturnsDescription
client(?string $device = null)ClientThe browser of a device; starts and resets it on first use
pc()Clientclient('pc')
mobile()Clientclient('mobile')

Navigation

MethodReturnsDescription
visit(string $path, ?Client $client = null)CrawlerOpens a path (/articles?page=2) or an absolute URL
get(string $url, ?Client $client = null)CrawlerAlias of visit(), named like IntegrationTestTrait::get()
currentUrl(?Client $client = null)stringURL of the current page
currentPath(?Client $client = null)stringPath and query, e.g. /articles?page=2
waitForNavigation(callable $action, ?Client $client = null)CrawlerRuns $action and waits for the next page
waitForNewWindow(callable $action, ?Client $client = null)stringRuns $action, switches to the new window; returns the previous window's handle
closeOtherWindows(?Client $client = null, ?string $keep = null)voidCloses all windows except $keep (default: the first) and switches to it
resetBrowser(?Client $client = null)voidCloses other windows, deletes cookies and storage

Reading the page

MethodReturnsDescription
page(?Client $client = null)CrawlerThe current page (filter(), filterXPath(), text(), attr() ...)
bodyText(?Client $client = null)stringVisible text (document.body.innerText)
pageSource(?Client $client = null)stringHTML of the current DOM
fieldValue(string $field, ?Client $client = null)stringValue of a field (name, id or label)
cookie(string $name, ?Client $client = null)?stringValue of a cookie, null when not set
deleteCookie(string $name, ?Client $client = null)voidDeletes a cookie
executeScript(string $script, array $arguments = [], ?Client $client = null)mixedRuns JavaScript (return ...;); arguments are arguments[0] ...

Interacting

MethodReturnsDescription
clickLink(string $text, ?Client $client = null, bool $waitForNavigation = true)CrawlerClicks a visible link by text, image alt or title
pressButton(string $text, ?Client $client = null, bool $waitForNavigation = true)CrawlerPresses a visible <button> or <input type="submit|button|image"> by text/value
click(WebDriverElement|string $element, ?Client $client = null, bool $waitForNavigation = false)CrawlerClicks an element or the first visible match of a CSS selector
submitForm(string $button, array $values = [], ?Client $client = null)CrawlerSets fields (by name, id or label) and presses the button
fillField(string $field, string $value, ?Client $client = null, ?string $within = null)voidClears a text field and types
selectOption(string $field, string $option, ?Client $client = null, ?string $within = null)voidSelects by option text, else by value
check(string $field, ?Client $client = null, ?string $within = null)voidChecks a checkbox or radio button
uncheck(string $field, ?Client $client = null, ?string $within = null)voidUnchecks a checkbox
checkLabel(string $label, ?Client $client = null)voidClicks the label of an unchecked input and asserts it is checked
uncheckLabel(string $label, ?Client $client = null)voidClicks the label of a checked input and asserts it is unchecked
attachFile(string $field, string $path, ?Client $client = null, ?string $within = null)voidChooses a file (the file must exist)

Waiting (timeouts in seconds, default waitTimeout)

MethodReturnsDescription
waitUntil(callable $condition, ?Client $client = null, ?int $timeout = null, string $message = '')mixedWaits until $condition($client) is truthy and returns its value
waitForText(string $text, ?Client $client = null, ?int $timeout = null)voidWaits until the page shows the text
waitForElement(string $selector, ?Client $client = null, ?int $timeout = null)WebDriverElementWaits until a matching element is visible
waitForElementToContain(string $selector, string $text, ?Client $client = null, ?int $timeout = null)WebDriverElementWaits until a matching element shows the text (its visible text) and returns it
waitForElementToDisappear(string $selector, ?Client $client = null, ?int $timeout = null)voidWaits until no matching element is visible

Finding elements

MethodReturnsDescription
findVisible(WebDriverBy|string $locator, ?Client $client = null)WebDriverElementFirst visible element; a string is a CSS selector
findField(string $field, ?Client $client = null, ?string $within = null)WebDriverElementField by name, id or label text (prefers visible ones)
findButton(string $text, ?Client $client = null)WebDriverElementVisible button by text (whitespace, including U+3000, trimmed)
xpathLiteral(string $text)stringXPath::literal()

Assertions

MethodDescription
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

MethodDescription
setUpBrowser(): void (public, @before)Starts the server and resets the started browsers
onNotSuccessfulTest(Throwable $t): voidSaves failure artifacts, then rethrows via the parent
saveFailureArtifacts(): arraySaves 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:

IntegrationTestTraitE2ENotes
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 namesThe 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 namesCookies 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
EmailTraitserver log, see MailMail is sent by the server process

Environment ​

final class Pieceofcake2\E2ETest\Environment (static)

MethodReturnsDescription
configure(array $config)voidSets options (see Configuration options); throws LogicException once started, InvalidArgumentException on unknown options
config(?string $key = null)mixedThe configuration with environment variables applied
reset()voidStops everything and restores the defaults
start()voidStarts the application server (no-op when running)
isRunning()boolWhether the server is running
server()ServerThe server instance
baseUrl()stringe.g. http://127.0.0.1:8080; starts the server
url(string $path)stringAbsolute URL of a path; absolute URLs are returned as they are
client(?string $device = null)ClientThe browser of a device (starts it)
hasClient(string $device)boolWhether that browser has started
clients()array<string, Client>Started browsers by device
quitClients(?string $device = null)voidQuits one or all browsers
shutdown()voidQuits the browsers and stops the server
artifactsDir()stringDirectory for failure artifacts (trailing separator)
waitTimeout()intSeconds

Constants: DEFAULTS (the default configuration), IPHONE_USER_AGENT.

php
<?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
<?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();
MethodReturnsDescription
__construct(array $options = [])See the server options; unknown options throw InvalidArgumentException
start()voidStarts 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)voidStops the server; removes the created temporary directory unless keepTmp (or always with $removeTmp)
isRunning()boolWhether the process is running
host(), port()string, intWhere it listens (port() throws before start when no port is configured)
baseUrl()stringhttp://host:port
docroot(), frontController(), router(), workingDir()stringResolved absolute paths
tmpDir(), logsDir(), logFile()?stringTemporary 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)mixedAn option
static isListening(string $host, int $port)boolWhether something accepts connections
static findFreePort(string $host = '127.0.0.1')intA free port

ChromeClientFactory ​

class Pieceofcake2\E2ETest\ChromeClientFactory: creates Panther clients; used by Environment.

MethodReturnsDescription
__construct(array $options = [])The browser options
create(array $device, string $baseUrl, ?int $port = null)ClientStarts 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

MethodDescription
__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 ​

MethodReturnsDescription
AppLayout::detect(string $root)stringAppLayout::MODERN (webroot/) or AppLayout::LEGACY (app/webroot/); throws when there is none
AppLayout::webroot(string $root)stringThe document root of that layout
XPath::literal(string $text)stringAn XPath string literal for any text, including quotes
php
<?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:

yaml
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 and PANTHER_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()), never sleep()
  • Use pressButton() / clickLink() for anything that loads a page; pass false only for JavaScript actions
  • Animations are disabled by --force-prefers-reduced-motion; respect prefers-reduced-motion in 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 allowedHosts or, 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, a Secure cookie 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.fullBaseUrl or a cookie domain that differs from 127.0.0.1:<port>: pass the server's URL to your configuration ('APP_FULL_BASE_URL' => '{baseUrl}' in server.env, or read E2E_BASE_URL) or leave the domain empty
  • Session defaults = cache with a cache engine the server cannot reach: use php or cake in 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.

See LICENSE for copyright and license information. Not affiliated with the Cake Software Foundation.