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 browser Page and Locator subjects;
  • Playwright\Assertions\Expect for 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.

php
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.

php
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:

php
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:

php
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.

php
$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.

php
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:

php
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:

php
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