Choosing assertions
Choose retrying browser assertions, PHPUnit assertions, and API response assertions so tests fail for the right reason.
Assertions decide what a test means. A weak assertion lets broken behavior pass. A noisy assertion fails for details the user does not care about. A good assertion describes the product guarantee and waits for it in the right place.
Playwright PHP has two useful assertion surfaces:
Playwright\Testing\expect()for browserPageandLocatorsubjects;Playwright\Assertions\Expectfor lower-level assertions such as API responses.
Use PHPUnit assertions for plain PHP values.
Mental model: assert the guarantee
After an action, ask what the product promised the user:
- did the page reach the right route?
- did the heading, message, row, or dialog appear?
- did the list contain the expected number of items?
- did an API setup call return the expected status?
- did a value read from the page match the scenario?
Do not assert only that the action happened. A click is input. The assertion should describe the output.
use function Playwright\Testing\expect;
$page->getByRole('button', ['name' => 'Save'])->click();
expect($page->getByText('Saved'))->toBeVisible();
This says what the user sees when save succeeds.
Use locator assertions for visible page state
Locator assertions retry until the expected state appears or the timeout expires. They are the normal choice for UI state.
use function Playwright\Testing\expect;
expect($page->getByRole('heading', ['name' => 'Dashboard']))->toBeVisible();
expect($page->getByRole('status'))->toHaveText('Saved');
expect($page->getByRole('row'))->toHaveCount(3);
Use them for content, visibility, and counts. They are clearer than reading text immediately after an action and then sleeping.
Prefer assertions on stable, meaningful elements. A heading, status region, row, or button name usually carries more product meaning than a nested span. If the element is hard to locate semantically, the UI may need a stronger accessible name or a deliberate test ID.
Use page assertions for route and title
Page assertions express browser-level state:
expect($page)->toHaveURL('https://app.example.test/dashboard');
expect($page)->toHaveTitle('Dashboard');
A URL assertion is useful after routing, redirects, authentication, or deep links. It is not enough when the user needs content on the page. Pair it with a visible assertion:
expect($page)->toHaveURL('https://app.example.test/dashboard');
expect($page->getByRole('heading', ['name' => 'Dashboard']))->toBeVisible();
Use PHPUnit for plain PHP values
Not every value should go through Playwright assertions. If you already have a PHP scalar, array, or object, use PHPUnit or normal PHP checks.
$email = $page->getByLabel('Email');
$email->fill('ada@example.com');
self::assertSame('ada@example.com', $email->inputValue());
Use this when the assertion subject is no longer a page or locator. Use browser assertions when you want retrying behavior against the page.
The boundary is timing. Browser assertions are useful because the page may still be changing. A value returned by inputValue(), json(), or your own PHP code has already been read; assert it as PHP data.
Use API response assertions for APIRequestContext responses
When you call an API directly through APIRequestContext, the subject is an API response, not rendered UI.
use Playwright\Assertions\Expect;
$response = $context->request()->post('https://app.example.test/api/test/orders', [
'data' => ['product' => 'keyboard'],
]);
Expect::response($response)->toHaveStatus(201);
Expect::response($response)->toBeOK();
Use API response assertions for setup and backend checks. If the feature has a UI, still assert the visible result after using the response.
Pass API responses to Expect::response(). Use Playwright\Testing\expect() for browser Page and Locator subjects.
Tune timeout locally
Retrying assertions have timeouts. Keep timeout changes close to the slow condition:
expect($page->getByText('Report ready'))
->withTimeout(15000)
->toBeVisible();
Do not raise the whole suite timeout because one report takes longer. A local timeout documents the exception and keeps the rest of the suite fast.
Positive and negative assertions
Negative assertions are useful, but they can be misleading during transitions. A spinner may be hidden for a moment before the real loading state starts. A message may disappear before the replacement appears.
Prefer pairing negative and positive checks:
expect($page->getByText('Loading'))->not()->toBeVisible();
expect($page->getByRole('heading', ['name' => 'Results']))->toBeVisible();
The first assertion says what is gone. The second says what replaced it.
Use negative assertions for cleanup and absence only when absence is the product promise: a deleted row, a dismissed dialog, or an error message that should not be shown. For loading flows, a positive assertion on the final state is usually more robust.
Assertion quality
Choose assertions that match stable product contracts:
| Need | Prefer | Avoid |
|---|---|---|
| page reached a route | toHaveURL() plus visible heading |
URL only for content-heavy pages |
| screen is ready | visible heading, status, landmark, or result | sleep() |
| list loaded | toHaveCount() or visible row text |
checking one arbitrary child |
| form submitted | success/error message | only checking the input still has value |
| API setup worked | API response status or JSON | assuming setup succeeded |
The best assertion fails at the point a user would describe: "the dashboard did not open", "the error did not appear", "the row was not added".
If an assertion is hard to name in user language, it may be too low-level for a browser test. Move that detail to a unit test, component test, API check, or generated API reference example.
Common mistakes
Reading too early. $locator->textContent() reads now. expect($locator)->toHaveText() waits.
Asserting implementation details. CSS classes, generated IDs, and internal markup often change without user impact.
Only asserting URL. Single-page apps can show stale or partial content at a correct URL.
Overusing not(). A negative state alone rarely proves the final state.
Maintaining hand-written exhaustive assertion lists. The API reference is better for exact method inventory. A Guide should teach how to choose.
Mixing assertion surfaces. Use Playwright\Testing\expect() for browser page and locator assertions. Use Playwright\Assertions\Expect::response() for API responses.
Verification checklist
- Does the assertion describe the product guarantee?
- Is the subject a locator, page, API response, or plain PHP value?
- Does the assertion retry when the browser needs time?
- Is any timeout change local and explained by the scenario?
- Does a negative assertion have a positive follow-up?
- Would the failure message help the next maintainer understand the broken behavior?
Go next
- Previous guide: Page and browser events
- Next guide: Testing with PHPUnit
- Subject map: Assertions, Waiting, Timeouts
- Copy tasks: Assert a list count, Wait for text to appear
- API reference: LocatorAssertions, PageAssertions, APIResponseAssertions