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.