Testing
Decide what deserves a browser test, write reliable assertions, and keep the suite useful in local runs and CI.
Browser tests are expensive because they run your application in a real browser. That cost is also the point. They prove the parts of the product that lower-level tests cannot see: rendered HTML, accessibility names, JavaScript behavior, redirects, forms, downloads, browser storage, network behavior, and differences between engines.
The strategic question is not "can Playwright PHP test this?" It usually can. The useful question is "what confidence do I need from a browser, and what should stay closer to the code?"
A good strategy keeps browser tests focused on user risk. It also keeps failures readable, because the test suite is a diagnostic tool, not just a pass/fail gate.
Start from product promises
Write browser tests for behavior a user, customer, editor, or operator would actually notice:
- a visitor can sign in;
- a customer can complete checkout;
- an admin can upload a file;
- a form validates and explains errors;
- a dashboard updates after JavaScript runs;
- a download contains the expected file;
- a page works on the browser engines you support.
Those are product promises. If one breaks, you need to know.
Avoid using browser tests to cover every branch of PHP logic. Domain rules, parsers, services, permissions, and edge cases are usually clearer in unit or integration tests. A browser test that only rechecks a service branch is slower, harder to debug, and often less precise.
Use the right layer for the question
Each layer should answer a different kind of question:
| Layer | Best question | Example |
|---|---|---|
| Unit | Does this PHP rule work? | Price calculation, value object validation, parser edge cases. |
| Integration | Does this subsystem work with real wiring? | Database query, message handler, Symfony service, HTTP client. |
| Browser | Does the user flow work in a real browser? | Login, checkout, file upload, JavaScript widget, responsive layout. |
When a browser test fails, the failure should reveal something that a lower-level test could not prove. If it does not, move the coverage down the stack.
Shape tests around one user goal
A browser test can cross several screens, but it should answer one product question. This keeps names, setup, assertions, and artifacts meaningful.
public function testUserCanRequestPasswordReset(): void
{
$page = $this->page;
$page->goto('https://app.example.test/forgot-password');
$page->getByLabel('Email')->fill('ada@example.com');
$page->getByRole('button', ['name' => 'Send reset link'])->click();
$this->expect($page->getByText('Check your email'))->toBeVisible();
}
That test is not "test the account area". It is one behavior: requesting a password reset produces a visible confirmation.
If a scenario needs many unrelated assertions, split it. A large tour fails for many possible reasons and rarely tells you which promise broke.
Arrange state outside the browser when possible
Use the browser for the behavior under test. Use fixtures, factories, APIs, or database helpers to create preconditions.
Good browser responsibility:
// Fixture: user already exists and is allowed to manage invoices.
$page->goto('https://app.example.test/login');
$page->getByLabel('Email')->fill('billing-admin@example.com');
$page->getByLabel('Password')->fill('correct-password');
$page->getByRole('button', ['name' => 'Sign in'])->click();
$this->expect($page)->toHaveURL('https://app.example.test/invoices');
Poor default:
// Slow and coupled when every test recreates the full account lifecycle.
$page->goto('https://app.example.test/register');
Create state through the UI only when that UI is the thing you are testing. Registration deserves a browser test; every invoice test does not need to register a new account first.
Assert outcomes, not mechanics
The click, route, or CSS class is rarely the outcome. The outcome is what the user can see or do next.
$page->getByRole('button', ['name' => 'Submit'])->click();
$this->expect($page->getByText('Your request was sent'))->toBeVisible();
$this->expect($page)->toHaveURL('https://app.example.test/requests/thank-you');
Prefer roles, labels, text, and URLs over implementation details. They encode product meaning and survive refactors better than deep CSS selectors.
// Brittle: the class name can change without changing the product.
$this->expect($page->locator('.modal .success-flag'))->toBeVisible();
CSS selectors are valid when the DOM is itself the contract, such as a component library or generated markup. They should not be the default for product flows.
Design for waiting, not sleeping
Browser tests fail most often around time: navigation, animations, network responses, delayed rendering, and server work. Do not add fixed sleeps. They make fast runs slow and slow runs flaky.
Use actions and assertions that already wait:
$page->getByRole('button', ['name' => 'Export'])->click();
$this->expect($page->getByText('Export complete'))
->withTimeout(15_000)
->toBeVisible();
The timeout belongs to the expected outcome. A slow export may need more time; the rest of the suite does not.
Decide artifact policy before CI hurts
A red browser test without evidence wastes time. Define a default artifact policy early:
- screenshot on failure;
- trace when a test fails or when a lane is flaky;
- console output for JavaScript-heavy pages;
- network logs for flows that depend on API calls;
- saved downloads when file generation is under test.
With PlaywrightTestCaseTrait, failed tests already save a screenshot to test-failures/. If PW_TRACE is enabled for the PHPUnit run, the trait also records a trace and writes it on failure.
PW_TRACE=1 vendor/bin/phpunit --group browser
Artifacts are part of the test design. If the team cannot understand a CI failure from the report, screenshot, trace, or logs, the test is not finished.
Split local and CI lanes
Keep a fast local path and a complete confidence path. A practical setup often looks like this:
- unit tests on every save or pre-commit;
- integration tests on every branch;
- browser smoke tests on every pull request;
- broader browser coverage on main or before release;
- cross-browser coverage for the flows where browser differences matter.
Do not run every browser permutation for every tiny change unless the product risk justifies it. Coverage should match risk, not symmetry.
Common pitfalls
- Testing all business rules through the browser. Move rule coverage down.
- Writing "happy path plus everything" tests. Split by user promise.
- Depending on previous tests. Each browser test should arrange its own state.
- Asserting implementation details. Prefer visible outcomes.
- Adding sleeps to hide timing bugs. Assert the state you need.
- Skipping artifacts. Debuggability is a quality requirement.
Go next
- Testing with PHPUnit: put browser scenarios in your PHP test suite.
- Assertions: choose retrying assertions.
- Timeouts and retries: handle slow behavior deliberately.
- Debugging and logging: collect evidence when tests fail.
- Continuous integration: run the suite safely in CI.