Writing One
A sign-in journey in Playwright, selectors based on roles and labels, and waiting on assertions instead of sleeping.
await page.getByLabel('Email').fill('[email protected]')A journey test should read like a sentence about a person: go to the sign-in page, fill the email, fill the password, click the button, land on the dashboard. Write that sentence first, then turn each clause into one line of code.
Selectors that survive a redesign
Query the page the way a user perceives it. getByRole('button', { name: 'Sign in' }) and getByLabel('Email') keep working when the markup is rearranged, and they fail loudly when a control loses its accessible name, which is a bug worth hearing about. getByLabel needs a real association: a <label for="email"> next to id="email", or the input nested inside the label. When an element has no visible name, add data-testid in the template and use getByTestId, which reads data-testid unless you set testIdAttribute in the config. Chains of utility classes are not selectors; the next restyle deletes them.
Wait for the app, not the clock
Playwright locators are lazy, and expect(locator).toHaveText(...) retries until it matches or hits the expect timeout, 5000ms by default, inside a test timeout of 30000ms. So an assertion is a wait. page.waitForTimeout is a sleep: too long on your laptop and too short on a loaded runner. When you need to wait for a navigation rather than for content, use page.waitForURL.
Example
// tests/e2e/sign-in.spec.ts
import { test, expect } from '@playwright/test'
test('a member signs in and reaches their dashboard', async ({ page }) => {
await page.goto('/sign-in')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('correct-horse')
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL('**/dashboard')
await expect(page.getByRole('heading', { name: 'Your projects' })).toBeVisible()
await expect(page.getByTestId('user-menu')).toHaveText('[email protected]')
})
test('a wrong password keeps the member on the form', async ({ page }) => {
await page.goto('/sign-in')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('wrong')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByRole('alert')).toHaveText('Email or password is wrong')
await expect(page).toHaveURL(/\/sign-in$/)
})When to use it
- Cover the sign-in form, including the wrong-password path, before touching the auth code.
- Replace a brittle CSS-class selector with a role and name that survive a restyle.
- Remove waitForTimeout calls from a suite that fails only on a busy CI runner.
- Click the right button in a table where every row has the same label.
More examples
The component the selectors depend on
Every selector in the test comes from something in this template: the two label/id pairs make getByLabel work, the submit button's text is its accessible name, and role="alert" is what getByRole('alert') finds. The error string arrives as a prop and is never written to, because props are read-only. Writing the component with these attributes in place is what makes the test short, and it improves the page for screen readers at the same time.
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ pending: boolean; error: string | null }>()
const emit = defineEmits<{
submit: [credentials: { email: string; password: string }]
}>()
const email = ref('')
const password = ref('')
function onSubmit() {
emit('submit', { email: email.value, password: password.value })
}
</script>
<template>
<!-- @submit.prevent is shorthand for v-on:submit.prevent; same directive -->
<form @submit.prevent="onSubmit">
<p v-if="props.error" role="alert">{{ props.error }}</p>
<label for="email">Email</label>
<input id="email" v-model="email" type="email" autocomplete="email" />
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
/>
<!-- :disabled is shorthand for v-bind:disabled; same directive -->
<button type="submit" :disabled="props.pending">Sign in</button>
</form>
</template>
<style scoped>
form {
display: grid;
gap: 0.5rem;
max-width: 20rem;
}
</style>Scoping a locator to one row
Every row carries an Archive button, so page.getByRole('button', { name: 'Archive' }) matches many elements and Playwright refuses to guess. filter({ hasText }) narrows the rows first, and chaining from that locator searches only inside the matched row. Use this shape for tables, lists and cards: find the container by text the user can see, then find the control inside it.
// tests/e2e/project-row.spec.ts
import { test, expect } from '@playwright/test'
test('archiving one project leaves the others alone', async ({ page }) => {
await page.goto('/projects')
const row = page.getByRole('row').filter({ hasText: 'Harbour survey' })
await row.getByRole('button', { name: 'Archive' }).click()
await expect(row.getByText('Archived')).toBeVisible()
await expect(
page.getByRole('row').filter({ hasText: 'Tide tables' }).getByText('Archived'),
).toHaveCount(0)
})A page object so a form change is one edit
Once more than two or three specs sign in, the selectors should live in one file. A test then reads await new SignInPage(page).signIn('[email protected]', 'correct-horse'), and renaming the button is a single edit. Keep assertions out of the page object: the test says what should be true, the page object only says how to operate the screen.
// tests/e2e/pages/sign-in-page.ts
import type { Page } from '@playwright/test'
export class SignInPage {
constructor(private readonly page: Page) {}
async open() {
await this.page.goto('/sign-in')
}
async signIn(email: string, password: string) {
await this.page.getByLabel('Email').fill(email)
await this.page.getByLabel('Password').fill(password)
await this.page.getByRole('button', { name: 'Sign in' }).click()
await this.page.waitForURL('**/dashboard')
}
}
Discussion