Introduction
After 83 days of Playwright, you've probably built test suites, debugged flaky tests, and wondered what comes next. The Playwright roadmap: a practical guide for developers exists because we've seen teams spin their wheels—investing effort in advanced features before mastering the fundamentals, or missing critical optimizations that would save them weeks of work.
This guide maps out what to learn in order, based on production experience across e-commerce, trading, and SaaS platforms. We'll walk through the four pillars every team needs: solid locators and assertions, robust fixtures and page objects, mocking and API integration, and finally visual and performance-focused testing.
Locators, assertions and config
Start here. Everything else rests on this foundation. If your locators are fragile, your entire test suite becomes fragile.
A good locator strategy means understanding role-based selectors before falling back to CSS or XPath. Playwright's locator API encourages this—page.getByRole('button', { name: 'Submit' }) is more resilient than page.locator('#btn-123') because it doesn't break when a designer changes the ID.
Here's a practical example from an e-commerce checkout flow:
// Good: semantic, resilient to ID changes
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('button', { name: /complete purchase/i }).click();
// Avoid: brittle, breaks on any structural change
await page.locator('#email-input-456').fill('user@example.com');
await page.locator('.btn.btn-primary.purchase-final').click();
Assertions come next. Use expect() from @playwright/test for auto-waiting and better error messages. Playwright waits up to 5 seconds by default, which catches race conditions without explicit waits.
// This waits for the element AND the text—no race condition
await expect(page.getByRole('heading')).toContainText('Order confirmed');
// Configure your expectations at the test level
test.describe.configure({ timeout: 30000 });
Configuration matters more than you'd think. Set sensible defaults in playwright.config.ts:
export default defineConfig({
timeout: 30000, // per test
expect: {
timeout: 5000, // per assertion
},
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // capture traces only when tests fail
},
});
This foundation—good locators, solid assertions, and sensible config—prevents you from wasting time on false failures and brittle tests.
Fixtures, page objects and auth
Once your basic tests pass reliably, scale them. Fixtures and page objects reduce duplication and make tests readable. This is where you move from "it works" to "other people can maintain this."
Playwright fixtures are essentially reusable setup and teardown. You'll commonly need authenticated users, API clients, or database connections. Build a fixture once, use it across hundreds of tests.
import { test as base } from '@playwright/test';
type TestFixtures = {
authenticatedPage: Page;
};
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
// Log in once
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Provide it to the test
await use(page);
// Clean up after (optional)
},
});
Page objects separate concerns: test logic from page interaction. A page object is a class that wraps a page and exposes high-level methods.
export class CheckoutPage {
constructor(private page: Page) {}
async fillShippingAddress(address: Address) {
await this.page.getByRole('textbox', { name: 'Street' }).fill(address.street);
await this.page.getByRole('textbox', { name: 'City' }).fill(address.city);
await this.page.getByRole('textbox', { name: 'Zip' }).fill(address.zip);
}
async submitOrder() {
await this.page.getByRole('button', { name: 'Complete Purchase' }).click();
await this.page.waitForURL('/order-confirmation');
}
async getOrderId(): Promise {
return this.page.getByRole('heading', { level: 1 }).textContent() || '';
}
}
// In your test
test('user completes checkout', async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.fillShippingAddress({ street: '123 Main', city: 'NYC', zip: '10001' });
await checkout.submitOrder();
const orderId = await checkout.getOrderId();
expect(orderId).toBeTruthy();
});
Authentication is its own challenge. Session-based login in every test wastes time. Instead, reuse session state or authenticate via API:
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
// Authenticate via API (faster than UI automation)
const response = await page.request.post('/api/auth/login', {
data: { email: 'test@example.com', password: 'password123' },
});
const { token } = await response.json();
// Set the token in local storage or cookies
await page.goto('/');
await page.evaluate((token) => {
localStorage.setItem('auth_token', token);
}, token);
await use(page);
},
});
This approach is 10x faster than automating login UI and scales cleanly across your test suite.
Mocking, API tests and CI
By now, your tests are passing locally. Shipping them to CI is the next milestone—and that's where mocking becomes essential. External API calls in tests are slow, flaky, and can cost money (if you hit a billing API, for example).
Playwright's network interception lets you mock API responses without changing your application code:
test('displays product recommendations', async ({ page }) => {
// Intercept the API call
await page.route('**/api/recommendations', (route) => {
route.abort('blockedbyclient');
});
// Or mock it with a response
await page.route('**/api/recommendations', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
products: [
{ id: 1, name: 'Widget A', price: 29.99 },
{ id: 2, name: 'Widget B', price: 39.99 },
],
}),
});
});
await page.goto('/products');
await expect(page.getByText('Widget A')).toBeVisible();
});
For API-only tests (no UI), use Playwright's request fixture directly:
test('API returns orders for authenticated user', async ({ request }) => {
const response = await request.get('/api/orders', {
headers: { Authorization: 'Bearer test-token' },
});
expect(response.status()).toBe(200);
const orders = await response.json();
expect(orders.length).toBeGreaterThan(0);
});
In CI, ensure you're running tests in parallel and capturing artifacts on failure. Your GitHub Actions or GitLab CI pipeline should look something like this conceptually:
- Start your application (or use a staging environment)
- Run tests in parallel with
--workers=4or higher - Upload traces, videos, and screenshots on failure
- Fail the build if any test fails
A failing test in CI is a blocker—no ambiguity. This is different from local development, where you might retry. Set your CI timeout low (e.g., 60 seconds per test) to catch hanging tests early.
Visual tests, sharding and monitoring
You're now at the advanced tier. Your test suite is fast, reliable, and integrated into CI. Visual testing adds a new dimension: catching unintended UI changes.
Playwright's visual comparison is simple—call toHaveScreenshot() and it generates a baseline on first run, then compares subsequent runs:
test('checkout page renders correctly', async ({ page }) => {
await page.goto('/checkout');
await expect(page).toHaveScreenshot('checkout-page.png');
});
Visual tests catch CSS bugs, missing elements, and rendering issues that functional tests miss. They're particularly valuable in e-commerce and SaaS where design consistency matters.
Sharding splits your test suite across multiple machines, dramatically reducing total run time. With 1,000 tests running 4 tests per machine, you'd wait 250 test cycles sequentially—but only 1 cycle sharded across 4 workers:
// In playwright.config.ts
export default defineConfig({
workers: 4, // local parallelization
});
// In CI, you might run:
// playwright test --shard=1/4
// playwright test --shard=2/4
// playwright test --shard=3/4
// playwright test --shard=4/4
Finally, monitoring. A test that passes locally but fails in CI is a problem. Set up alerts for flaky tests—tests that fail intermittently. Most test runners (including Playwright) support retry logic, but retries mask instability rather than fixing it. Use APM tools or custom logging to track which tests are problematic:
test('user login', async ({ page }) => {
const startTime = Date.now();
try {
await page.goto('/login');
// ... test logic
} finally {
const duration = Date.now() - startTime;
console.log(`Test duration: ${duration}ms`);
}
});
Track test duration over time. If a test that usually runs in 2 seconds suddenly takes 8 seconds, that's a signal something's wrong—likely with your application or infrastructure, not the test itself.
Common mistakes
- Skipping locator strategy work: Don't rush to advanced features if your locators are brittle. Spend time on role-based selectors and getByTestId() for tricky elements. It pays dividends later.
- Over-mocking: Mock external APIs, not your own application's endpoints. If you mock your own backend endpoints, you're testing a lie—your mocks don't match reality.
- Long setup chains: If your fixture setup takes 30 seconds, your test suite will crawl. Keep fixtures lean. Authenticate via API, not UI. Use database seeding if you need specific data.
- Ignoring CI differences: Your tests pass locally but fail in CI? The difference is usually timing, environment variables, or browser version. Don't ignore these—fix them immediately.
- Visual tests without context: Taking a screenshot of your entire page will fail on minor font rendering differences across platforms. Use visual tests for critical UI components, not everything.
Key takeaways
- Master locators and assertions before moving to advanced patterns—a fragile foundation breaks everything above it.
- Build fixtures and page objects early to reduce duplication and make tests maintainable as your suite grows.
- Mock external APIs and authenticate via API in tests; UI automation for login wastes time and creates flakiness.
- Invest in CI integration and monitoring. A flaky test in CI is worse than no test—it creates false confidence.
- Visual testing and sharding are multipliers, not foundations. Add them once your core suite is solid and fast.
- Follow this roadmap in order: it reflects the learning path that scales to hundreds of tests without technical debt.






Comments
Loading comments…