Most CakePHP applications I've worked on have a decent PHPUnit suite and almost no end-to-end tests. Not because nobody wants them, but because writing them is tedious: you open the page, inspect the DOM, guess a selector, run the test, fix the selector, add an assertion, run it again. By the time the login flow works, the sprint is over and the E2E folder has one file in it.
That cost is the part that changed. Since Playwright 1.56, Claude Code can explore your running application in a real browser, write a test plan, turn it into a spec with verified selectors, and run it. You still review the result, but you don't start from a blank file. This article walks through setting that up on a CakePHP 5 app running on ddev, from zero to a passing suite.
Assumes CakePHP 5.x on ddev, Node.js 18+, Playwright 1.56+ and Claude Code installed. Everything below was run against a real application.
Step 1: Playwright in a CakePHP project
Playwright runs on your host machine, not inside the ddev container. It only needs a URL to hit, and ddev already
gives you one. If you use Docker Compose, Laravel Valet, Herd or bin/cake server instead, nothing else in this
article changes: point baseURL at whatever serves your app and drop ignoreHTTPSErrors if it isn't HTTPS with
a local certificate.
npm init playwright@latest
Accept the defaults (TypeScript, tests/ folder, no GitHub Actions for now). Then point baseURL at your ddev
site and remove the webServer block, since ddev is already running:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'https://myapp.ddev.site',
ignoreHTTPSErrors: true,
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
ignoreHTTPSErrors is there because ddev serves HTTPS with a local mkcert certificate that Playwright's bundled
Chromium doesn't trust by default. Without it every test fails with net::ERR_CERT_AUTHORITY_INVALID before
reaching your app, which is confusing the first time you see it.
Install the browsers and confirm the example test runs against your app:
npx playwright install chromium
npx playwright test
Step 2: Log in once, reuse the session
The agents will open your app dozens of times while exploring. If every one of those runs fills in the login form, the suite gets slow and, worse, flaky. The pattern that works is to log in once through the real UI, save the browser session to disk, and have every other test start already authenticated.
Playwright supports this with a setup project: a project that runs before the others and whose output the others
depend on. Two additions to playwright.config.ts:
// playwright.config.ts
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'https://myapp.ddev.site',
ignoreHTTPSErrors: true,
storageState: '.auth/admin.json',
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
],
});
The setup project runs tests/auth.setup.ts, which does the one real login and saves the session:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
setup('authenticate as admin', async ({ page }) => {
await page.goto('/users/login');
await page.getByTestId('login-email').fill('[email protected]');
await page.getByTestId('login-password').fill('secret');
await page.getByTestId('login-submit').click();
await expect(page).toHaveURL(/\/dashboard/);
await page.context().storageState({ path: '.auth/admin.json' });
});
Every test in the chromium project now loads .auth/admin.json and starts on the dashboard, already logged in.
Add .auth/ to .gitignore. A test that needs to exercise the login page itself opts out with
test.use({ storageState: { cookies: [], origins: [] } }).
Not your development database. The agents create, edit and delete records while they explore. Don't point
them at the database you develop against. The simplest option is a second ddev project
(ddev config --project-name=myapp-e2e) restored from a known SQL dump before each run; a more integrated one is
a separate e2e_test datasource that CakePHP switches to when a request carries a test header. Either way, the
test user must exist and the data must be the same every run: the agents will replay the same steps many times,
and a database that drifts between runs shows up as flaky tests that are impossible to debug.
Step 3: Add the Playwright agents to Claude Code
This is the piece that didn't exist a year ago. Playwright ships three agent definitions that plug into Claude Code as sub-agents, and one command installs them:
npx playwright init-agents --loop=claude
Run it from the folder that holds playwright.config.ts. It creates:
.claude/agents/playwright-test-planner.md
.claude/agents/playwright-test-generator.md
.claude/agents/playwright-test-healer.md
.mcp.json
specs/README.md
tests/seed.spec.ts
The three .md files are the sub-agents, each with its own instructions and tool access. .mcp.json is a
project-scoped MCP config that registers Playwright's own test MCP server, so Claude Code can drive a browser and
run tests:
{
"mcpServers": {
"playwright-test": {
"command": "npx",
"args": ["playwright", "run-test-mcp-server"]
}
}
}
init-agents also picks a primary project from your config: the one with dependencies: ['setup'] and the
saved storageState, which is exactly what you want the agents to run as.
What each agent does:
- Planner opens your app in a browser, explores the feature you ask about, and writes a Markdown test plan into
specs/. No code yet, just scenarios and expected outcomes you can read and edit. - Generator takes a plan and produces a
.spec.ts, verifying each locator against the live page while it writes. - Healer runs a failing test, inspects the current UI, patches the test and re-runs it. If the feature itself is
broken rather than the test, it marks the test with
test.fixme()instead of forcing it green.
tests/seed.spec.ts comes out as a stub:
import { test, expect } from '@playwright/test';
test.describe('Test group', () => {
test('seed', async ({ page }) => {
// generate code here.
});
});
The agents copy it into every test they generate, so it's where login would go if you didn't have one. With the setup project from Step 2 the session is already cached, so it can stay empty.
Restart Claude Code from that folder so it picks up .mcp.json. It will ask you to approve the new project MCP
server; accept, then check /mcp shows playwright-test as connected. Commit .claude/agents/, .mcp.json and
tests/seed.spec.ts; the whole team gets the same setup on checkout.
Step 4: From prompt to passing suite
The example is an Articles module (title and body) built with the generic CakeDC/Admin scaffold: no custom
templates, the same index/add/edit/delete pages you get on any admin CRUD.
In Claude Code, ask the planner for a plan:
> Use the playwright-test-planner agent to create a test plan for the Articles admin CRUD
at /admin/articles: create, edit and delete. Save it as specs/articles.md and show it to me
before generating anything.
The planner took about five minutes. It logged in through the saved session, clicked through every page, and
wrote specs/articles.md: 12 scenarios across four groups (index, create, edit, delete). Here is the overview
and one scenario, trimmed:
# Articles Admin CRUD Test Plan
## Application Overview
Covers the Articles admin CRUD at /admin/articles, a CakeDC/Admin-scaffolded resource
with two fields: `title` (required, max length 191) and `body` (optional textarea).
Confirmed via manual exploration:
- Index renders a "Type to search..." box that filters by title/body, a paginated
`table.table-bordered`, and an "Actions" dropdown containing "New Article".
Each row has View/Edit/Delete icon-only links (`a[title="view|edit|delete"]`).
- Successful add/edit flashes "The Article has been saved." and redirects to
`/admin/articles?back=1`.
- Delete is a `postLink()` with a native `window.confirm("Are you sure you want to
delete this record?")`; dismissing it must leave the record untouched (not yet
verified live — flag if it differs).
### 2. Create Article
#### 2.1. Admin can create a new article with a title and body
**Steps:**
1. Generate a unique title, e.g. `E2E Article ${Date.now()}`, and confirm no row
with that title exists yet.
2. Navigate to /admin/articles/add via the Actions dropdown > 'New Article'.
- expect: The Add form is shown with 'Title *' and 'Body' fields and a Submit button.
3. Fill both fields and click Submit.
- expect: The page redirects to /admin/articles?back=1.
- expect: A success alert containing 'The Article has been saved.' is visible.
- expect: A new row with the unique title is visible in the index table.
None of that is guessed. The maxlength, the ?back=1 redirect, the exact confirm() text and the "not yet
verified live" note all come from clicking through the running app. The plan is plain Markdown: read it, delete
the scenarios you don't want, then hand it to the generator:
> Use the playwright-test-generator agent to generate tests/admin-articles-crud.spec.ts
from specs/articles.md, then run it and show me the full output.
The generated file has all 12 tests. These are the three that matter for a CRUD, exactly as written:
// tests/admin-articles-crud.spec.ts (excerpt)
import { test, expect } from '@playwright/test';
import { expectSavedFlash, expectDeletedFlash, rowWithText, editAction, deleteAction } from '../helpers/admin';
test.describe('Create Article', () => {
test('Admin can create a new article with a title and body', async ({ page }) => {
const title = `E2E Article ${Date.now()}`;
await page.goto('/admin/articles', { waitUntil: 'domcontentloaded' });
await page.getByPlaceholder('Type to search...').fill(title);
await page.getByPlaceholder('Type to search...').press('Enter');
await expect(rowWithText(page, title)).toHaveCount(0);
await page.getByRole('button', { name: 'Actions' }).click();
await page.getByRole('link', { name: 'New Article' }).click();
await expect(page).toHaveURL(/\/admin\/articles\/add$/);
await page.getByRole('textbox', { name: 'Title *' }).fill(title);
await page.getByRole('textbox', { name: 'Body' }).fill('Created by an e2e test.');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page).toHaveURL(/\/admin\/articles\?back=1$/);
await expectSavedFlash(page);
const newRow = rowWithText(page, title);
await expect(newRow).toBeVisible();
await expect(newRow).toContainText('Created by an e2e test.');
});
});
test.describe('Edit Article', () => {
test("Admin can edit an existing article's title and body", async ({ page }) => {
const originalTitle = `E2E Edit Source ${Date.now()}`;
const editedTitle = `${originalTitle} Edited`;
await page.goto('/admin/articles/add', { waitUntil: 'domcontentloaded' });
await page.getByRole('textbox', { name: 'Title *' }).fill(originalTitle);
await page.getByRole('textbox', { name: 'Body' }).fill('Original body.');
await page.getByRole('button', { name: 'Submit' }).click();
await expectSavedFlash(page);
const originalRow = rowWithText(page, originalTitle);
await editAction(originalRow).click();
await expect(page).toHaveURL(/\/admin\/articles\/edit\/\d+$/);
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(originalTitle);
await page.getByRole('textbox', { name: 'Title *' }).fill(editedTitle);
await page.getByRole('textbox', { name: 'Body' }).fill('Updated body text.');
await page.getByRole('button', { name: 'Submit' }).click();
await expectSavedFlash(page);
await expect(rowWithText(page, editedTitle)).toContainText('Updated body text.');
// Exact match (not substring) so this doesn't false-match the edited row,
// whose title is `${originalTitle} Edited`.
await expect(page.getByRole('cell', { name: originalTitle, exact: true })).toHaveCount(0);
});
});
test.describe('Delete Article', () => {
test('Admin can delete an article after confirming the dialog', async ({ page }) => {
const title = `E2E Delete Confirm ${Date.now()}`;
await page.goto('/admin/articles/add', { waitUntil: 'domcontentloaded' });
await page.getByRole('textbox', { name: 'Title *' }).fill(title);
await page.getByRole('button', { name: 'Submit' }).click();
await expectSavedFlash(page);
const row = rowWithText(page, title);
page.once('dialog', (dialog) => {
expect(dialog.message()).toBe('Are you sure you want to delete this record?');
void dialog.accept();
});
await deleteAction(row).click();
await expectDeletedFlash(page);
await expect(rowWithText(page, title)).toHaveCount(0);
});
});
Two things in there are worth pointing out. Each test creates the record it needs and cleans up after itself, so
tests don't depend on each other's order. And the exact: true on the edit assertion is there because the edited
title contains the original one; a substring match would have passed even if the edit had silently failed. The
generator worked that out on its own and left the comment.
The expectSavedFlash, rowWithText, editAction and deleteAction calls come from a helper file that already
existed in the project. The generator found it and reused it instead of inventing its own. They are short:
// helpers/admin.ts
import { type Page, type Locator, expect } from '@playwright/test';
export async function expectSavedFlash(page: Page): Promise<void> {
await expect(page.getByText(/has been saved/i)).toBeVisible();
}
export async function expectDeletedFlash(page: Page): Promise<void> {
await expect(page.getByText(/has been deleted/i)).toBeVisible();
}
// Every CakeDC/Admin index page renders the same table markup.
export function rowWithText(page: Page, text: string): Locator {
return page.locator('table.table-bordered tbody tr', { hasText: text });
}
// Table actions are icon-only links; `title` is the only identifying attribute.
export function editAction(row: Locator): Locator {
return row.locator('a[title="edit"]');
}
export function deleteAction(row: Locator): Locator {
return row.locator('a[title="delete"]');
}
And the run:
npx playwright test tests/admin-articles-crud.spec.ts
Running 13 tests using 1 worker
✓ 1 [setup] › tests/auth.setup.ts:20:6 › authenticate as admin (3.6s)
✓ 2 [chromium] › tests/admin-articles-crud.spec.ts:8:7 › Articles Index (Read) › Seeded articles are listed on the index page (2.6s)
✓ 3 [chromium] › tests/admin-articles-crud.spec.ts:42:7 › Articles Index (Read) › Search box filters articles by title/body text (3.0s)
✓ 4 [chromium] › tests/admin-articles-crud.spec.ts:73:7 › Create Article › Admin can create a new article with a title and body (3.0s)
...
✓ 11 [chromium] › tests/admin-articles-crud.spec.ts:250:7 › Delete Article › Admin can delete an article after confirming the dialog (3.0s)
✓ 12 [chromium] › tests/admin-articles-crud.spec.ts:274:7 › Delete Article › Dismissing the delete confirmation dialog keeps the article (2.5s)
✓ 13 [chromium] › tests/admin-articles-crud.spec.ts:293:7 › Delete Article › Deleting a non-existent article id does not affect other records (1.9s)
13 passed (44.2s)
All 12 passed on the first run. That's not the norm for hand-written E2E tests, and the reason is not that the model is clever: the generator verifies every locator against the live page while it writes the test, so the usual first-run failures (a link that exists but sits in a collapsed dropdown, a label that doesn't match) get caught before the file is saved rather than after.
Step 5: Review before you commit
The generated suite is a starting point, not a finished one. Things to check on every plan and spec the agents produce:
- Prune the plan before generating. Scenario 4.3 in the plan above, "deleting a non-existent id", turned into
a test that does a GET on
/admin/articles/view/999999and asserts a 404. It passes, but it's not a delete test, and it's the kind of scenario that pads a suite without protecting anything. The plan is Markdown for a reason: cut it there, before it becomes code. - Read the "not verified" notes. The planner flagged the dismiss-confirm behaviour as assumed, not observed. In this case the generator's live run confirmed it, but a note like that in the plan is a prompt for you to check, not to skip.
- Locators.
getByRoleandgetByPlaceholderare the right default and the generator uses them on its own. The CakeDC/Admin scaffold has nodata-testidattributes anywhere, so the row and action helpers fall back totable.table-borderedanda[title="edit"]; on forms you own and change often, add test ids and say so in the prompt. - Test data. Generated tests use
${Date.now()}in titles and clean up after themselves. Keep it that way: a test that leaves a row behind will collide with the next run's assertions on a paginated list. - Credentials.
auth.setup.tshas a test user's password in it. That's fine for a dedicated E2E user on an isolated database and not fine for anything else.
What you get
One command to install the agents, one setup file for login, and the tedious half of E2E testing (opening pages, finding selectors, writing the first draft) is done by an agent you can watch and correct. The judgement half, deciding what's worth testing and whether the assertion means anything, is still yours. That split is what makes E2E tests actually get written.
If your CakePHP application has a PHPUnit suite and an empty E2E folder, CakeDC can help you set up a testing strategy that fits how your team works. Get in touch and let's talk about your project.
Further reading
Need help with this in your own CakePHP app?
Talk to a CakePHP expert