Skip to content

Writing E2E Tests for a Page That Postpones Its Fetch

Adityo Guni Waluyo

A 52-line E2E spec for a listing page that debounces 350 ms: zero waitForTimeout, assertions that retry on their own, and five data-testid hooks.

TL;DR

The listing page debounces client fetch by 350 milliseconds. Playwright's automatic retry replaces waitForTimeout. The test uses a 10-second assertion timeout.

The E2E specification for the listing page on a city portal had just been written: 52 lines, four tests, and a header comment that says it plainly — the page debounces its client fetch by 350 milliseconds, so the assertions use Playwright's automatic retry instead of waitForTimeout. Commit 85c067f, +53 lines across two files. Not a single sleep in it.

The Reflex That Moves the Race

The list fetches its data in the browser, and every search keystroke is held for 350 milliseconds before the request actually leaves. The race between the test and the network looks obvious, and the fastest way to close it is to put the test to sleep for half a second before reading the result. That works on an idle machine, then turns red on a busy one, because a fixed delay and a network that will not stay fixed never agree.

This specification took the other path. Playwright re-tests an element over and over until the condition is met or the timeout is reached [1], so the 350-millisecond delay never has to appear in the test at all. The default assertion timeout is 5 seconds; this spec raises it explicitly to 10 seconds on the assertions that wait for data, rather than adding a delay.

The doubt at the time was simple enough: with no delay, how does the test know the element is there. The answer sits with the locator. Locators come with auto waiting and retry-ability, and Playwright performs actionability checks such as whether the element is visible and enabled before it acts [2]. An assertion does not read the DOM once and give up; it reads, waits, and reads again.

Five Hooks, Four Behaviours

The spec anchors five hooks through data-testid: the sports-branch card, the search field, the functional-body tab, the detail block, and the members table. Testing by test id is the most resilient way of testing because the test survives changes in text or role [3]. For a listing page whose cards carry titles that can change at any moment, that choice reads as the least fragile one.

Four tests cover four behaviours. The first confirms the page opens and the first card is visible, then counts the cards and asserts the number is greater than zero. The second fills the search field with ABTI, then asserts exactly one card remains and that it carries the same text. The third clicks the functional-body tab and asserts the cards add up to two. The fourth clicks the first card and asserts the detail block along with the members table appears.

The counts of one and two are facts of seeded test data, not promised product behaviour. The test reads the fixture, and when the fixture changes the number changes with it; what is guarded is the order of behaviours, not the numbers.

Two details are easy to miss. The first test does not merely confirm that a card exists, it counts and asserts the result is greater than zero, so a page that opens empty still fails even when its heading is right. The last test does not check for a new URL after the card is clicked; it checks two elements, the detail block and the members table, which means the detail view appears on the same page rather than on a separate route this test may not cover.

A Budget, Not a Delay

The 10-second assertion timeout is a budget for the page, not a delay for the test. It leaves room for a slow network without slowing the test down when the page is fast, because the counter stops as soon as the condition holds. This is the pattern the whole file uses:

test("filters the listing by search", async ({ page }) => {
  await page.goto("/informasi/olahraga");
  const cards = page.locator('[data-testid="cabor-card"]');

  await expect(cards.first()).toBeVisible({ timeout: 10_000 });

  await page.locator('[data-testid="cabor-search"]').fill("ABTI");
  await expect(cards).toHaveCount(1, { timeout: 10_000 });
});

Nothing in it waits for time. Every wait becomes an assertion with a condition: the card is visible, the count is one, the text matches. If the page takes 400 milliseconds, the test finishes 400 milliseconds later; if the page takes 6 seconds, the test fails with a message naming the condition that never held, not with a wrong snapshot.

An Index That Changes Too

The commit does not stop at the spec file. The QA index gains one row documenting which module is tested, which spec file covers it, the command that runs it, and the expectations baked in: fetch limit 100, 350-millisecond debounce, one card for ABTI, two cards for the functional-body tab, and the members table appearing after a card is clicked. That row is part of the test, not a note beside it. Without it, the next person sees green without knowing what green means.

The decision that survives this commit: waiting was not moved into the test, it was removed from the question. The question is no longer whether enough time has passed, but whether the state is correct, and the second question can be asked repeatedly at no cost.

Sources:

Related articles