Frontend Labs
⋯

76. Writing tests that survive refactors

Locate like a user

Prefer locators that describe what the user perceives. They also double as accessibility checks:

Priority Locator Example
1 getByRole(role, { name }) getByRole("button", { name: "Send" })
2 getByLabel getByLabel("Password")
3 getByText, getByPlaceholder getByText("Invalid email or password")
4 getByTestId getByTestId("order-A-1001") (when there is no good accessible name)
last CSS / XPath locator('[data-order="A-1001"]')

If getByRole("log", { name: "Conversation" }) can't find your chat transcript, a screen reader can't either. Fix the markup, not the test.

Page objects: one place that knows the DOM

// tests/fixtures.js
export class ChatPage {
  constructor(page) {
    this.page = page;
    this.input = page.getByRole("textbox", { name: "Message" });
    this.send = page.getByRole("button", { name: "Send" });
    this.stop = page.getByRole("button", { name: "Stop" });
    this.log = page.getByRole("log", { name: "Conversation" });
    this.orders = page.getByRole("list", { name: "Your orders" });
  }
  async goto() {
    await this.page.goto("/");
    await expect(this.input).toBeVisible();
  }
  async ask(text) {
    await this.input.fill(text);
    await this.send.click();
  }
  lastReply() {
    return this.log.locator('[data-role="assistant"]').last();
  }
  async waitForReply() {
    // aria-busy flips back to false when the stream ends.
    await expect(this.log).toHaveAttribute("aria-busy", "false");
  }
}

waitForReply waits on aria-busy, a state the app exposes for screen readers anyway, rather than sleeping or polling text. A good test hook is an accessibility feature too.

Fixtures: dependency injection for tests

test.extend declares fixtures. A test asks for them by name, and Playwright builds them on demand, runs the code before use() as setup and the code after it as teardown:

export const test = base.extend({
  chat: async ({ page }, use) => {               // depends on the built-in `page` fixture
    const chat = new ChatPage(page);
    await chat.goto();
    await use(chat);                              // ← the test runs here
  },
  // seedOrder({ item, total }) -> a fresh order owned by the signed-in user.
  seedOrder: async ({ page }, use) => {
    await use(async (order = {}) => {
      const res = await page.request.post("/__test__/orders", { data: order });
      expect(res.status()).toBe(201);
      return res.json();
    });
  },
});

Test data: every test brings its own

The server is shared by all workers and all retries. If one test refunds A-1002, every later test that expects A-1002 to be "in transit" breaks, and which tests break depends on scheduling. The fix is a test data factory: an endpoint that exists only in test mode, used by tests that change state.

// server.mjs
let nextTestOrder = 3000;
if (TEST) {
  routes["POST /__test__/orders"] = async (req, res) => {
    const s = requireSession(req);
    const { item = "Test item", total = 10, status = "delivered" } = await readJSON(req);
    const order = { id: `T-${nextTestOrder++}`, owner: s.userId, item, total, status };
    orders.set(order.id, order);
    send(res, 201, publicOrder(order));
  };
}
// tests/chat.spec.js
test("refund needs a human approval, then updates the order list", async ({ chat, seedOrder, page }) => {
  const order = await seedOrder({ item: "Desk lamp", total: 35 });
  await page.reload();                                   // pick up the new order in the sidebar
  await chat.ask(`please refund ${order.id}`);
  await chat.waitForReply();
  const card = chat.lastReply().getByRole("article", { name: `Approve refund for ${order.id}` });
  await expect(card).toBeVisible();
  await card.getByRole("button", { name: "Approve refund of $35.00" }).click();
  await expect(card.getByRole("status")).toHaveText(`Refund requested for ${order.id}.`);
  await expect(chat.orders.locator(`[data-order="${order.id}"]`)).toContainText("refund requested");
});

The test reads like the user story: ask, see a card, approve, see the outcome in the card and in the sidebar. Read-only tests (like "declining a refund changes nothing") can safely use the shared fixtures A-1001 and A-1002.

ARIA snapshots: structure in one assertion

test("looking up an order renders an order card @mobile", async ({ chat }) => {
  await chat.ask("where is A-1002?");
  await chat.waitForReply();
  await expect(chat.lastReply().getByRole("article")).toMatchAriaSnapshot(`
    - article "Order A-1002":
      - strong: A-1002
      - text: /Mechanical keyboard/
  `);
});

toMatchAriaSnapshot compares the accessibility tree (roles, names, nesting) against a YAML template, with regexes for the parts that vary. It is less brittle than a screenshot and stricter than toContainText. The @mobile tag in the title also runs this test in the Pixel 7 project.

Generated from real-life-JS-guide.md · 8 Oct 2026 16:36