Skip to content

Fixture First: Writing the Login Helper Before Any Spec

Adityo Guni Waluyo

One login helper and one evidence helper written before the first spec that needs them, and why getByLabel needs exact: true on this admin form.

TL;DR

Task 0 required building reusable Playwright helpers before any specs, enforcing short behavior-focused tests. loginAsAdmin uses real user-facing selectors like getByLabel with exact true and getByRole instead of test ids to avoid mismatches. A simple screenshot helper centralizes evidence and the team deferred faster storageState auth to keep tests isolated for now.

I opened docs/superpowers/plans/2026-10-09-qa-backfill-fase0-routing-admin.md expecting the three workstreams from the day before and found a fourth entry sitting on top of them: Task 0, "Fondasi test reusable (Playwright fixtures)". The rule around it was blunt. No new spec would be written until the helpers existed.

My first guess was housekeeping. Write two or three specs, extract the common login, move on. That is usually how helpers happen, and for selectors the plan draft still assumed placeholder test ids. Both guesses were wrong. The committed file frontend/e2e/support/fixtures.ts did the opposite: one file, two functions, written before any of the specs that would consume them [1].

The fixture before the spec

Playwright describes fixtures as a way to establish the environment for each test, giving the test everything it needs and nothing else [1]. The same page notes that fixtures let you group tests by meaning instead of by their common setup [1].

The plan revision made that ordering literal. Task 0 opened with Step 1: read the real login component and record the actual label of the email field, the password field and the submit button, because selectors must match what exists rather than what someone remembers. The draft snippet in the plan still called getByTestId with placeholder names; the real form has no test ids at all, so the placeholders could never have worked.

Ordering is the whole point. A helper written after the fifth spec is a refactor with a new name. A helper written before the first spec forces every later spec to stay short and to read as user-visible behavior.

The evidence half followed the same rule. The screenshot API captures a full page as if the screen were tall enough to fit it entirely [4], and routing every call through evidence(page, name) into one directory keeps proof findable while the spec body stays free of path logic.

No test ids on the form

The selectors in loginAsAdmin were read from the actual LoginScreen in frontend/src/app/admincms/layout.tsx instead of guessed. The form uses field labels and a role, not test hooks: getByLabel(..., { exact: true }) for both inputs, then getByRole("button", { name: "Masuk" }) for submit [2]. That matches the documentation's advice to prioritize user-facing attributes and explicit contracts, and its definition of getByLabel() as the way to locate a form control by its label text [2].

The exact: true option is not decoration. getByLabel accepts input text from the associated label element, from aria-labelledby, or from the aria-label attribute itself [6], and exact matching defaults to off, meaning the match is neither case-sensitive nor whole-string [6]. On this form the password field is labeled "Kata sandi" while the show-password toggle carries its own aria-label, "Tampilkan kata sandi", which contains the field label as a substring. Without exact: true the toggle joins the candidate set and the test can fill the wrong element.

import { expect, type Page } from "@playwright/test";

// Reusable admin login for every new spec. Selectors verified
// against the real login component, not guessed.
export async function loginAsAdmin(page: Page) {
  await page.goto("/admincms");
  await page.getByLabel("Email", { exact: true }).fill("<ADMIN_DEV_EMAIL>");
  await page.getByLabel("Kata sandi", { exact: true }).fill("<ADMIN_DEV_PASSWORD>");
  await page.getByRole("button", { name: "Masuk" }).click();
  await expect(page).toHaveURL(/admincms/);
}

// QA screenshot evidence, always to one directory.
export async function evidence(page: Page, name: string) {
  await page.screenshot({
    path: `../scripts/unit_testing/screenshots/qa-backfill/${name}.png`,
    fullPage: true,
  });
}

That is the entire implementation: fill the two fields found by label, click the button found by role, wait for the navigation that follows, then take one full-page screenshot. Both helpers are plain functions. No fixture class, no extra config, nothing a new contributor has to learn before writing the first spec.

A helper today, saved state later

There is an official alternative, left on the table on purpose. Tests can load existing authenticated state, which removes authentication from every individual test and speeds up execution [3]. The state normally lives in a directory that should be gitignored, and the documentation warns that the file may contain sensitive cookies and headers, so it must never be committed to a private or public repository [3].

The plain helper was kept because these specs mutate server-side state under one shared account, and a shared storage state fights exactly that condition. A fresh login per test is slower but independent. Upstream itself accepts a little duplication when it keeps tests clear and maintainable [5], alongside the rule that tests should target user-visible behavior [5].

The cost is visible in the code: every spec pays for its own login. If that stops being acceptable, the next step is auth.setup.ts with storageState, one authenticated project feeding state to the rest [3]. Until then the helper keeps failures local and avoids a sensitive artifact entirely.

The decision stands until measurement says otherwise: keep the plain helper, keep every screenshot in one directory, and let each spec spend its words on behavior instead of setup.

Sources

[1] Playwright docs: Fixtures
[2] Playwright docs: Locators
[3] Playwright docs: Authentication
[4] Playwright docs: Screenshots
[5] Playwright docs: Best Practices
[6] Playwright docs source: locator getByLabel / exact option

Related articles