Home · A blog about testing ·

Playwright for testers: UI and API automation from scratch

A useful first automated test does more than click a button: it creates its own data, verifies a business outcome, and leaves a readable trace on failure. This guide turns a manual scenario into a maintainable Playwright suite.

A QA engineer reviews a Playwright test, API requests, a trace, and CI results
A maintainable automated-test lifecycle
  1. Choose the risk and oracle
  2. Arrange state through an API
  3. Perform the user journey
  4. Retain evidence and clean up
Place each risk at the most useful layer
Risk Best layer Why
Discount formulaUnit/APICovers many values quickly
Access to another owner’s objectAPI + one UI pathTests server boundary and UX
Customer checkoutUI end-to-endTests critical-path integration
Response schemaAPI contractPrecise and fast diagnosis
Layout and accessibilityUI + visual/a11yRequires a rendered interface

1. Choose the first scenario by risk, not convenience

Start with a short stable path and a clear oracle: sign-in, customer creation, or adding an item to cart. Do not begin with a long order flow containing ten integrations. Write down the manual scenario, preconditions, data, expected business effect, and cleanup.

Distribute checks by layer. Calculations and rules are cheaper below the UI; keep browsers for behavior that requires a page. One end-to-end test cannot replace API, permission, and persistence tests, while dozens of UI variants make a suite slow and brittle.

  • The scenario carries business risk.
  • The oracle is observable by another person.
  • Data can be created and removed independently.
  • No test depends on another test result.
  • Failure is localized by steps and evidence.
Practice task

For a CRM, choose customer creation, bulk import, or a complex report. Justify the choice by risk, data, stability, and diagnostics.

2. Build a minimal Arrange, Act, Assert test

Playwright Test provides the runner, assertions, fixtures, parallelism, and reports. Structure a scenario as Arrange, Act, Assert: prepare the user and data, perform one business action, and verify the observable outcome. Name a test after the rule, not the click sequence.

Use web-first assertions that wait for the required state and report the actual one. Do not substitute waitForTimeout: a fixed sleep slows fast tests and remains too short on a slow CI worker.

A first test with business meaning
import { test, expect } from '@playwright/test';

test('manager converts a new lead into one customer', async ({ page }) => {
  await page.goto('/leads/42');
  await page.getByRole('button', { name: 'Convert lead' }).click();
  await expect(page.getByRole('heading', { name: 'Customer Alex' })).toBeVisible();
  await expect(page.getByText('Converted')).toBeVisible();
});
Practice task

Rewrite a manual login scenario as Arrange, Act, Assert. Define one oracle for success and another for an invalid password.

3. Select locators by the user-facing contract

Prefer getByRole, getByLabel, getByText, and an agreed getByTestId fallback. Roles and accessible names reflect what users and assistive technologies perceive. A long div:nth-child selector breaks after cosmetic markup changes.

A locator must be unique. If several Save buttons exist, narrow the region or give each a distinct name; do not call first() merely to make the test pass. A missing accessible name may be a product defect rather than a selector inconvenience.

Maintainable and brittle locators
// Maintainable: user-facing meaning
await page.getByRole('button', { name: 'Save customer' }).click();
await page.getByLabel('Email').fill('alex@example.test');

// Brittle: DOM structure
await page.locator('.card:nth-child(2) > div > button').click();
Practice task

Choose locators for customer name, email, owner, and Save. Explain which accessibility defect each choice could reveal.

4. Isolate data with fixtures and API setup

Tests must not depend on order. Each test gets its own user or business context and creates only required data. Creating a lead through an API is faster and more reliable than repeating a UI form before every check. Keep UI for the behavior under test.

A fixture packages setup and teardown but should not hide half the business flow. Make data unique for parallel workers and remove it through a supported API or TTL. A shared production-like account quickly creates races and false failures.

API setup followed by a UI assertion
test('owner sees the assigned customer', async ({ page, request }) => {
  const created = await request.post('/api/test/customers', {
    data: { name: 'Alex', owner: 'manager-a' }
  });
  const { id } = await created.json();
  await page.goto(`/customers/${id}`);
  await expect(page.getByText('Owner: manager-a')).toBeVisible();
});
Practice task

Design a fixture for two managers and customer A. Define unique data, authentication state, cleanup, and parallel execution.

5. Use APIRequestContext for setup and standalone API tests

Playwright can send HTTP requests in the same runner. Use it to arrange state, verify server contracts, or confirm a UI action through another channel. Status 200 is not business success: assert schema, values, persistence, permission, and side effects.

API tests explicitly own baseURL, authentication, and data. Avoid chains where one test produces state for the next. For negative cases verify no mutation after 4xx; for retries assert one final effect by business key.

API test that checks stored state
test('duplicate conversion creates one customer', async ({ request }) => {
  const first = await request.post('/api/leads/42/convert');
  const second = await request.post('/api/leads/42/convert');
  expect([200, 201]).toContain(first.status());
  expect([200, 409]).toContain(second.status());
  const customers = await request.get('/api/customers?source_lead_id=42');
  expect((await customers.json()).data).toHaveLength(1);
});
Practice task

Add response, follow-up GET, stock, and duplicate-retry checks to POST /orders. Choose which assertions belong in API and one UI smoke.

6. Add Page Objects only for meaningful repetition

A Page Object stores locators and page actions, but it should not become a giant class containing every assertion. Tests should expose business intent; components hide stable mechanics. A reusable table or modal component is often more precise than a whole-page object.

Avoid premature abstraction. Three clear lines can beat a universal method with ten flags. Separate data, actions, and assertions, use TypeScript types, and do not swallow errors or retry inside helpers that hide defects.

A small object with a user action
class LeadPage {
  constructor(private page: Page) {}
  async convert() {
    await this.page.getByRole('button', { name: 'Convert lead' }).click();
  }
  customerHeading(name: string) {
    return this.page.getByRole('heading', { name });
  }
}
Practice task

Review three CRM scenarios and identify reusable components. Do not create one generic method when parameters change the meaning of the action.

7. Use network control, visual checks, and accessibility deliberately

Route interception can reproduce a 500, timeout, or rare dependency response, but a mocked happy path does not prove integration. Separate real API contract tests from UI behavior under a controlled failure. Assert loading, retry, no duplicate action, and a useful user message.

Visual comparison detects unexpected pixel changes but requires stable fonts, viewports, and data. Accessibility scans find classes of violations but do not replace keyboard navigation and screen-reader semantics. These techniques complement functional assertions rather than replacing them.

A controlled API failure in a UI test
await page.route('**/api/customers/42', route =>
  route.fulfill({ status: 503, body: JSON.stringify({ error: 'unavailable' }) })
);
await page.goto('/customers/42');
await expect(page.getByRole('alert')).toContainText('Try again');
await expect(page.getByRole('button', { name: 'Retry' })).toBeEnabled();
Practice task

Design UI tests for a 200, slow response, and 503. Define the mock, expected UX, and where the real contract is tested separately.

8. Diagnose flaky tests with traces and run them in CI

A flaky test alternates between pass and fail for the same code and expected state. Common causes are shared data, wrong waits, races, external dependencies, brittle locators, and leaked state. Retry collects another signal but does not fix the cause or turn a red suite into a healthy one.

In CI retain trace on first retry, screenshots and video by policy, HTML/JUnit reports, and sanitized network evidence. Split fast pull-request smoke from scheduled or pre-release regression. A failure should identify commit, browser, environment, test-data id, and the first useful stack frame.

A diagnostic baseline configuration
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  use: { baseURL: process.env.BASE_URL, trace: 'on-first-retry' },
  reporter: [['html'], ['junit', { outputFile: 'results.xml' }]],
});
Practice task

A test fails 2 of 20 CI runs. Plan evidence collection, reproduction, hypotheses, experiments, and the criterion for considering it fixed.

Frequently asked questions about Playwright

Which language should I use for Playwright?

TypeScript is common for web projects because of its ecosystem and types, but team language and maintainability matter more. Playwright also supports Python, Java, and .NET.

Should every manual test case be automated?

No. Automate stable repeatable checks with valuable signals. Exploratory, rapidly changing, and rare scenarios may remain manual.

Is waitForTimeout acceptable?

It is useful temporarily while debugging, but in a suite it usually signals poor synchronization. Wait for a specific UI state, response, or event.

What makes an automated test good?

It protects an important rule, is independent, reproducible, stable, easy to diagnose, and costs less to maintain than the regression signal is worth.

Playwright becomes a professional QA tool when a test expresses product risk, owns its data, and explains a failure without manually replaying the entire journey.

Get free access to QA practice Next write-up

← All write-ups