Testing with PHPUnit
Drive a real browser from a PHPUnit test case, isolate state, and make failures readable.
Use Playwright PHP in PHPUnit when browser behavior should become part of the project test suite. That includes navigation, JavaScript, forms, redirects, accessibility names, downloads, browser storage, console errors, and the visible result of a real user flow.
The goal is not to turn every test into a browser test. The goal is to put the few flows that need a real browser under the same runner, reporting, grouping, and CI discipline as the rest of your PHP tests.
Pick the PHPUnit shape
Playwright PHP exposes two PHPUnit-friendly options.
Use PlaywrightTestCase when a test class can extend the provided base class:
<?php
declare(strict_types=1);
use Playwright\Testing\PlaywrightTestCase;
final class HomepageTest extends PlaywrightTestCase
{
public function testExamplePageHasHeading(): void
{
$this->page->goto('https://example.com');
$this->expect($this->page)->toHaveTitle('Example Domain');
$this->expect($this->page->getByRole('heading'))->toHaveText('Example Domain');
}
}
Use PlaywrightTestCaseTrait when your application already has its own base TestCase, which is common in Symfony, Laravel, or internal test frameworks. In that case, call the trait lifecycle methods explicitly.
<?php
declare(strict_types=1);
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use Playwright\Testing\PlaywrightTestCaseTrait;
#[Group('browser')]
final class LoginTest extends TestCase
{
use PlaywrightTestCaseTrait;
protected function setUp(): void
{
parent::setUp();
$this->setUpPlaywright();
}
protected function tearDown(): void
{
$this->tearDownPlaywright();
parent::tearDown();
}
public function testUserCanLogIn(): void
{
$this->page->goto('https://app.example.test/login');
$this->page->getByLabel('Email')->fill('user@example.com');
$this->page->getByLabel('Password')->fill('correct-password');
$this->page->getByRole('button', ['name' => 'Sign in'])->click();
$this->expect($this->page)->toHaveURL('https://app.example.test/dashboard');
$this->expect($this->page->getByRole('heading', ['name' => 'Dashboard']))->toBeVisible();
}
}
The trait provides $this->playwright, $this->browser, $this->context, $this->page, and $this->expect(). By default it shares the browser process across tests, then creates a fresh browser context and page for each test. That is the useful compromise: startup cost is reduced, but cookies, local storage, permissions, and pages remain isolated per test.
Group browser tests
Browser tests should be easy to include or exclude. Mark them with a PHPUnit group and keep the group name boring.
# Fast lane: no browser.
vendor/bin/phpunit --exclude-group browser
# Browser lane only.
vendor/bin/phpunit --group browser
# Full suite.
vendor/bin/phpunit
Use the group for tests that launch a browser or require a running application. Do not hide pure PHP tests in the browser group. The group is a scheduling tool: local development, CI jobs, and release gates can choose the right cost.
Write the test around one visible outcome
A PHPUnit browser test should still read like a product promise. Arrange the precondition, perform the user action, assert the visible outcome.
public function testValidationErrorsAreExplained(): void
{
$this->page->goto('https://app.example.test/profile');
$this->page->getByLabel('Display name')->fill('');
$this->page->getByRole('button', ['name' => 'Save'])->click();
$this->expect($this->page->getByText('Display name is required'))->toBeVisible();
}
Avoid making one method verify the whole product area. Long browser tests are harder to name and harder to diagnose. If a test has several unrelated "then" sections, split it.
Use retrying assertions
$this->expect() accepts a PageInterface or LocatorInterface. It retries until the condition passes or the timeout expires. That is why it should be preferred over reading a value once and asserting with PHPUnit directly.
$this->page->getByRole('button', ['name' => 'Save'])->click();
$this->expect($this->page->getByText('Settings saved'))->toBeVisible();
Avoid this pattern for dynamic UI:
// Reads once. If the page is still rendering, the test flakes.
self::assertSame('Settings saved', $this->page->getByRole('status')->textContent());
Plain PHPUnit assertions are still useful for stable PHP values, arrays returned by evaluate(), downloaded file metadata, or helper results. Use Playwright expectations for browser state that may need to settle.
Configure only what the test needs
The trait accepts an optional PlaywrightConfig in setUpPlaywright(). Use it when a class needs a different browser, timeout, trace directory, or runtime option.
use Playwright\Configuration\PlaywrightConfigBuilder;
protected function setUp(): void
{
parent::setUp();
$config = PlaywrightConfigBuilder::create()
->withHeadless(false)
->withTimeoutMs(45_000)
->build();
$this->setUpPlaywright(customConfig: $config);
}
Do this sparingly. A suite is easier to understand when most tests use the same default environment. Prefer per-assertion timeouts for one slow outcome instead of increasing the whole browser timeout for every action.
Keep setup explicit
Create test data outside the browser when the setup is not the behavior under test. Use the browser for the user action you care about.
public function testAdminCanSeeInvoiceList(): void
{
// Arrange the invoice with your application's fixtures before this step.
$this->page->goto('https://app.example.test/invoices');
$this->expect($this->page->getByText('customer@example.com'))->toBeVisible();
}
Do not rely on test order. The trait gives each test a new context and page; keep your application data equally deliberate.
Also keep URLs explicit. A browser test should make it obvious which application it drives: a local Symfony server, a preview environment, or a stable public page used for a minimal smoke test. Hidden base URLs make failures harder to read because the report no longer tells you whether the wrong page, the wrong environment, or the wrong assertion failed. If your project centralizes the base URL, keep that helper small and name it clearly.
Make failures readable
When a test fails, PlaywrightTestCaseTrait captures a screenshot in test-failures/. If PW_TRACE is enabled, it starts tracing for the test and writes a trace zip on failure.
PW_TRACE=1 vendor/bin/phpunit --group browser
Use traces for complex interactions, screenshots for visible state, and console or network logs when the failure is probably browser-side or API-side. If CI only reports "timeout", improve artifacts before adding waits.
Common pitfalls
- Forgetting to call
setUpPlaywright()ortearDownPlaywright()when using the trait. - Using a browser test for pure PHP logic.
- Sharing state through test order.
- Using fixed sleeps instead of retrying assertions.
- Raising global timeouts instead of waiting for one slow outcome.
- Debugging CI failures without screenshots or traces.
Go next
- Test strategy: decide what deserves browser coverage.
- Assertions: choose the right
expect()matcher. - Timeouts and retries: remove sleeps and tune waits.
- Debugging and logging: collect traces, screenshots, and logs.
- Continuous integration: run browser tests reliably in CI.