We write e2e tests to protect behavior.

But many of them do not actually encode behavior. They encode the current representation of behavior: a spinner disappearing, a checkbox being checked, a banner becoming visible.

Useful signals, yes. But signals are not guarantees.

An e2e test should assert the promise it makes, not merely observe the signals that happen to represent that promise today.

That distinction matters more than it first appears.

What the Test Says vs What It Checks

A lot of hidden e2e test pain is not just brittleness. It is mismatch.

The test reads like it guarantees one thing, but what it actually checks is a bundle of lower-level UI details.

A test might read, in our heads, like this:

the import completed, 2 rows failed, and the user can download the error report

But the assertions underneath might actually be checking:

  • a success badge is visible

  • a summary text contains a number

  • a button is enabled

  • a spinner is hidden

All of those are valid things to observe. But they are still only signals. They are not yet the promise.

That is where a lot of entropy enters e2e tests.

The reader has to reconstruct the meaning from scattered assertions. The test changes when the representation changes, even if the promise did not. And over time, the code starts speaking more in terms of the markup than the language of the product.

I find it useful to name three layers:

  • Signals are the concrete things the UI emits: visible, enabled, checked, hidden, text present.

  • State is a named, meaningful fact derived from one or more of those signals.

  • Promise is what the test is actually guaranteeing about the system.

A Test Built from Signals

Here is a perfectly respectable Playwright test snippet:

test('reports a completed import with 2 failed entries', async ({ page }) => {
  await expect(page.getByTestId('import-success')).toBeVisible();
  await expect(page.getByTestId('failed-rows-summary')).toHaveText(/2/);
  await expect(page.getByRole('button', { name: /download error report/i })).toBeEnabled();
});

There is nothing inherently wrong with this.

But notice what this test is asking the reader to do.

It does not directly state the facts it cares about. It asks the reader to infer those facts from three volatile details. The reader has to translate from signals back into meaning.

That translation work is easy in a toy example. It becomes less easy once the UI is larger, the same fact is represented by more signals, or the same representation details are repeated in many tests.

It is also asking the test itself to stay coupled to the current representation of those facts. If one of those signals changes — the summary text, the button state, the visibility rule, the spinner behavior — the test must to change as well, even if the promise did not.

Making the Promise Explicit

The alternative I have been leaning toward is to define a few async queries in domain terms and assert those together.

Using the same example, the test can read like this:

test('reports a completed import with 2 failed entries', async ({ importPage }) => {
  await expect(importPage).toHaveState({
    currentStatus: 'completed',
    failedCount: 2,
    errorReportAvailable: true,
  });
});

This is not better because it is shorter.

It is better because the assertion now speaks in the same terms as the promise the test is making.

The reader no longer has to reconstruct the meaning from several lower-level checks. The translation from signals to state happens once, in one place, and the test asserts the resulting fact directly.

One Place to Name the Facts

One way to host those queries can be an object like this:

class ImportPage {
  constructor(readonly page: Page) {}

  async currentStatus() {
    if (await this.page.getByTestId('import-error').isVisible()) return 'failed';
    if (await this.page.getByTestId('import-success').isVisible()) return 'completed';
    if (await this.page.getByTestId('import-spinner').isVisible()) return 'processing';
    return 'idle';
  }

  async failedCount() {
    const text = await this.page.getByTestId('failed-rows-summary').innerText();
    const match = text.match(/\d+/);
    return match ? Number.parseInt(match[0], 10) : 0;
  }

  async errorReportAvailable() {
    return this.page.getByRole('button', { name: /download error report/i }).isEnabled();
  }
}

I do not think of state as a framework concept.

I think of it as a translation layer.

Signals are often transient, noisy, and tied to a specific representation. State gives those signals a stable name that means something in the language of the product.

That is the real job.

Not hiding Playwright. Not pretending locators do not exist. Not forcing everything into a Page Object Model.

Just naming the thing the test actually cares about.

This is also why I do not think the idea is POM-specific.

A Page Object is just one place where these async queries can live. Any object that can answer meaningful questions about the system can play that role. The interesting part is not the class shape. The interesting part is the boundary between signals and facts.

What Should Be Allowed to Change

The DOM is allowed to change.

Locator strategy is allowed to change.

Helper objects and page objects are allowed to change.

That entire layer should be free to move as the UI evolves.

The test itself should change only when the thing it guarantees changes.

That, to me, is the point.

If the same promise is now represented through different markup, different roles, different internal composition, or a different set of transient signals, then the translation layer should absorb that change.

The test should not need to move just because the UI found a new way to say the same thing.

The Smallest Thing That Worked

In practice, I found myself repeating this pattern often enough that I wrapped it in a tiny helper.

All it really does is evaluate a set of async facts together and wait until all expectations match.

I am linking the gist for anyone curious about the mechanics, but the implementation is not really the point. The point is what the assertion expresses.

What Does an E2E Test Promise?

When we say an e2e test protects behavior, what exactly is the behavior?

Which of our assertions are actually encoding a promise, and which are only encoding the current signals we happen to rely on to infer it?

Signals are useful. They are often unavoidable.

But signals are not guarantees.

And e2e tests get a little stronger, a little quieter, and a little easier to trust once they stop asserting signals directly and start naming the promise they intend to keep.

Reply

Avatar

or to participate

Keep Reading