All posts

Playwright - your first end-to-end tests
Playwright - your first end-to-end tests

Polski

Playwright - your first end-to-end tests

A friendly on-ramp to Playwright for automating a real browser: install the official test runner, open pages, find elements in a maintainable way, interact with forms, and assert what you see. Uses the same sample app as the Cypress series so you can compare the two tools fairly.

Playwright

Introduction

Playwright is Microsoft’s cross-browser automation library (Chromium, Firefox, WebKit, Edge). Unlike Cypress, you do not get a single bundled “test IDE” - but the official @playwright/test runner gives you reports, retries, multi-browser projects, and UI mode without wiring up Mocha and Chai by hand.

This post mirrors the first Cypress article: same application, same learning goal (a credible first E2E test). I assume the stack from Environment preparation is already running.

Repositories:

https://github.com/12masta/react-redux-realworld-example-app

https://github.com/12masta/aspnetcore-realworld-example-app

Installation

Inside the frontend repo (react-redux-realworld-example-app), keep tests in their own folder so dependencies stay isolated. The quickest path:

npm create playwright@latest

The wizard asks for a directory (for example playwright-tests), language (TypeScript or JavaScript), and browsers. Then:

npx playwright install

downloads browser binaries (this can take a minute).

Manual equivalent:

npm i -D @playwright/test
npx playwright install

baseURL configuration

Avoid repeating http://localhost:4100 in every test by setting baseURL in playwright.config.ts:

import { defineConfig } from "@playwright/test"

export default defineConfig({
  use: {
    baseURL: "http://localhost:4100",
  },
  projects: [
    { name: "chromium", use: { browserName: "chromium" } },
    { name: "firefox", use: { browserName: "firefox" } },
    { name: "webkit", use: { browserName: "webkit" } },
  ],
})

Now page.goto("/") opens the app root, and projects runs the same specs on three engines - no forEach anti-pattern inside the tests themselves.

First test - open the app

tests/smoke.spec.ts (or .js):

import { test, expect } from "@playwright/test"

test("visits the home page", async ({ page }) => {
  await page.goto("/")
  await expect(page).toHaveTitle(/conduit/i)
})

Run:

npx playwright test

Interactive trace and step-through:

npx playwright test --ui

Finding elements

Instead of brittle CSS such as :nth-child(3) > .nav-link, prefer accessibility-oriented locators - for example the “Sign up” link in the RealWorld navbar:

test("sees the brand in the navbar", async ({ page }) => {
  await page.goto("/")
  await expect(page.getByRole("link", { name: "conduit" })).toBeVisible()
})

When you truly need raw CSS (as in the original Cypress walkthrough), page.locator(".navbar-brand") works. Playwright auto-waits before actions; if nothing matches within the timeout, the test fails - same comfort as Cypress’s command retries, expressed directly in the API.

Clicking

locator.click() scrolls the element into view and clicks its center by default:

test("clicks Sign up and lands on register", async ({ page }) => {
  await page.goto("/")
  await page.getByRole("link", { name: "Sign up" }).click()
  await expect(page).toHaveURL(/\/register/)
})

This is the intentional, more stable counterpart to the old .nav-link example.

Asserting the URL

expect(page).toHaveURL() accepts a string, regular expression, or predicate:

await expect(page).toHaveURL("http://localhost:4100/register")
// or
await expect(page).toHaveURL(/\/register/)

Filling inputs

Prefer fill() over legacy page.type() - it clears the field first, then types the value:

test("types a username on /register", async ({ page }) => {
  await page.goto("/register")
  const userField = page.locator(".form-control").first()
  await userField.fill("exampleusername")
  await expect(userField).toHaveValue("exampleusername")
})

Wrapping up

You can keep the entire “first steps” suite in one spec, just like the Cypress article:

import { test, expect } from "@playwright/test"

test.describe("My first tests", () => {
  test("visits the app", async ({ page }) => {
    await page.goto("/")
  })

  test("finds the brand", async ({ page }) => {
    await page.goto("/")
    await expect(page.locator(".navbar-brand")).toBeVisible()
  })

  test("clicks Sign up", async ({ page }) => {
    await page.goto("/")
    await page.getByRole("link", { name: "Sign up" }).click()
  })

  test("asserts after click", async ({ page }) => {
    await page.goto("/")
    await page.getByRole("link", { name: "Sign up" }).click()
    await expect(page).toHaveURL(/\/register/)
  })

  test("fills an input", async ({ page }) => {
    await page.goto("/register")
    const first = page.locator(".form-control").first()
    await first.fill("exampleusername")
    await expect(first).toHaveValue("exampleusername")
  })
})

An older revision of this article (separate playwrighttests folder with Mocha) still lives in the Git branch below for historical comparison. The text you are reading reflects the recommended @playwright/test workflow today.

Reference code:

https://github.com/12masta/react-redux-realworld-example-app/tree/1-Playwright

Historical changeset:

https://github.com/12masta/react-redux-realworld-example-app/pull/8/files

Next: login tests with Playwright - API setup like Cypress. Full parallel track: /en/playwright-1//en/playwright-9/ (Cypress posts stay unchanged).