Skip to main content
CakeDC Blog

Automating E2E Tests in CakePHP 5 with Playwright and Claude Code

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/999999 and 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. getByRole and getByPlaceholder are the right default and the generator uses them on its own. The CakeDC/Admin scaffold has no data-testid attributes anywhere, so the row and action helpers fall back to table.table-bordered and a[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.ts has 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
Back to all articles
We Bake with CakePHP