Skip to content

Three Interactions Tested Without Writing a Single Row

Adityo Guni Waluyo

Testing empty-form validation, the back button, and the filter without writing a single row, plus two Playwright locator traps and the Radix portal.

TL;DR

An editor form shared by five CMS modules had just a smoke test, yet its three flakiest interactions, validation, back navigation, and filtering, need zero data. Two locator traps surfaced: an icon-only back button without a button role, and a Radix Select whose portaled options break selectOption. These non-mutating L2 tests run anywhere and catch bugs early.

The test suite for a CMS editor form used by five modules only had one level: a smoke test that merely confirms the page opens. I stopped at that empty editor screen with a question that turned out to be wrong: what data do I have to create first before I can test the interactions?

My first guess was that testing interactions always means preparing data. Clicking save to surface a validation message requires existing input first. Canceling an edit requires a row to edit. Changing a filter requires data to filter. The logic sounded tidy until I tried them one by one and found that the three interactions that break most often can be tested on a completely empty form, without touching the database at all.

Those three: the validation message when an empty form is submitted, the back button that must return to the list, and the filter change that alters the visible list. I call this level L2 in my suite, above the smoke test and below the vertical test (L3) that actually writes and cleans up data. Because this CMS editor module is shared by five modules, I picked the smallest module as the representative: if the interaction pattern passes there, the other modules just copy the test structure.

Two traps in locators and the DOM portal

Two test failures happened not because the app was broken, but because my assumptions about the DOM were wrong.

The first one: the back button in that form is icon-only, has aria-label="Kembali ke daftar", but has no button role. Playwright still rejects the standard locator.

// this finds nothing
await page.getByRole('button', { name: 'Kembali ke daftar' }).click();

getByRole matches explicit and implicit ARIA roles, not text that happens to sit in an attribute [5]. If the element has no button role, the getByRole('button', ...) request finds nothing and the test stops at a timeout. The fix: look for the attribute itself.

await page.locator('[aria-label="Kembali ke daftar"]').click();
await expect(page).toHaveURL('/admin/artikel');
await expect(page.getByTestId('tambah-entri')).toBeAttached();

That last line keeps the test honest: the add button on the list page must still exist after coming back, meaning the navigation really finished, not halfway.

The second trap is more annoying. The filter on the list page uses a shadcn-style Select component, a list of options that opens from a trigger button [7]. Its options do not live inside a plain <select>; they render through a portal into the document body [4]. So selectOption() is guaranteed to fail, that method only works on native <select> elements and throws if the target is not one [8].

// gagal: elemennya bukan <select> native
await page.getByTestId('filter-kategori').selectOption('Tutorial');

The working pattern: click the trigger, wait for the portaled content, then click the option by its role.

await page.getByRole('combobox', { name: 'Kategori' }).click();
await page.getByRole('option', { name: 'Tutorial' }).click();
await expect(page.getByRole('option')).toHaveCount(0);
await expect(page.getByTestId('baris-daftar')).toHaveCount(2);

Once I understood that Radix moves its subtree into the document body, the failures stopped feeling random. What was wrong was not the test timing, but the DOM model in my head.

Tests that touch zero rows

The remaining two interactions ran without surprises once the locators were right. Submitting the empty form only needs expect(...).toBeVisible() to confirm the validation message appears, never creating a record. The filter just compares the count of data-testid list rows before and after clicking an option, again without inserting anything.

This is what makes L2 worth separating as its own level. Tests that do not mutate can run against any environment, local, staging, even production if absolutely necessary, without the risk of leaving traces and without a cleanup script. L3 tests that write data are stronger, but they have a price: fixtures, teardown, and the trust that the cleanup actually runs. If the three most fragile interactions are already validated at L2, the L3 track can focus on flows that genuinely need data.

I used to think this suite was not "real" because nothing gets written. After those two locator failures showed up at L2 first, and not in the slow L3, that doubt went away. Tests that read had already found plenty of problems before anything touched the database.

Sources

4. Select - Radix UI Primitives 5. Locators - Playwright 7. Select - shadcn/ui 8. Locator - Playwright

Related articles