All posts

Hands-On Testing · Part 1

UI testing explained: what it is, how it works, and Selenium vs Playwright vs Cypress

ProvaLab team · · 8 min read

Testing Basics covered the ideas. This new series, Hands-On Testing, covers the practice: UI testing, API testing, security testing and load testing, each with a concepts post and a post where you build something that runs.

We start with the kind of testing most people picture when they hear the word: a program that opens a browser and uses your app like a person would.

What is UI testing?

UI testing (user interface testing) checks an application through the same interface users see: pages, buttons, forms and screens, in a real browser or on a real device.

It sits at the top of the testing pyramid: the closest to a real user, and also the slowest and most expensive kind of test to write and maintain. That's why the pyramid says to have a few UI tests, for the journeys that matter most, not hundreds.

Two terms you'll hear alongside it:

  • End-to-end (E2E) testing: a UI test that follows a complete journey through the whole system, such as search → add to cart → pay → see confirmation. Most UI tests of any value are end-to-end tests.
  • Visual testing: comparing screenshots against a known-good version to catch layout and styling changes. A specialised branch of UI testing.

What UI tests are good at, and bad at

Good at Bad at
Proving a whole journey works, front to back Pinpointing why something failed
Catching integration problems between front end and back end Running fast (seconds per test, not milliseconds)
Checking what users actually see, in real browsers Being stable: they depend on timing, data and the environment
Cross-browser and cross-device checks Testing business logic (unit tests do that far more cheaply)

The rule of thumb: test the logic with unit tests, the joins with integration tests, and the journeys with UI tests. A UI test that checks fifty discount-code combinations is doing a unit test's job, fifty times slower.

How a UI test works

A UI test script talks to a driver or automation protocol, which controls a real browser, which loads your app. The test repeats three steps: locate an element (the Sign in button, the Email box), act on it (type, click), and assert what the page shows (the heading reads Your orders). Most flaky tests fail at the locate step; pass or fail is decided at assert.

Every UI test, in every tool, is the same three steps repeated:

  1. Locate an element on the page: the email field, the "Sign in" button.
  2. Act on it: type, click, select, scroll.
  3. Assert what the page shows afterwards: the heading, the URL, a message, the absence of an error.

Here is a complete sign-in test in Playwright for Python. Even if you've never seen Playwright, you can read it:

def test_customer_can_sign_in(page):
    page.goto("https://staging.example.com/sign-in")
    page.get_by_label("Email").fill("[email protected]")
    page.get_by_label("Password").fill("correct-horse-battery")
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_role("heading", name="Your orders")).to_be_visible()

Locate (get_by_label), act (fill, click), assert (expect … to_be_visible). We'll build and run this for real in Part 2.

Between the script and the browser sits a driver or automation protocol. Selenium uses the WebDriver standard, a W3C protocol every major browser supports. Playwright and Cypress talk to browsers more directly, which is a large part of why they feel faster.

Locators: the skill that decides whether your tests survive

The single biggest cause of broken UI tests is the way they find elements. A test that looks for div.col-md-6 > form > button.btn-primary breaks the day a designer changes a class name, even though the app works perfectly.

A ladder of locator strategies, best first. 1, by role and name: get_by_role button named Sign in; what users see, survives redesigns. 2, by test id: get_by_test_id checkout-total; stable, added for testing. 3, by visible text or label; readable but breaks if wording changes. 4, by CSS selector tied to class names; breaks when styling changes. 5, by XPath tied to position; breaks on any layout change.

The order of preference most teams settle on:

  1. Role and accessible name (get_by_role("button", name="Sign in")). This is how a screen reader sees the page, so it matches what users see and ignores styling. It also quietly pushes your app towards being accessible.
  2. Test IDs (data-testid="checkout-total"), attributes added to the HTML purely for tests. Very stable; the only cost is asking developers to add them.
  3. Visible text and labels. Readable, but tied to wording and language.
  4. CSS selectors. Fine for structure that won't change; fragile when tied to styling classes.
  5. XPath by position. Avoid. It breaks whenever anything moves.

A good test reads like a description of what a user does. If you can't tell what a locator points at without opening the page, it's the wrong locator.

Why UI tests go flaky, and what to do about it

A flaky test passes and fails on different runs with no code change. UI tests are the flakiest kind, for predictable reasons:

Cause What it looks like Fix
Timing The test clicks before the button exists, or asserts before data loads Use the tool's built-in waiting (Playwright and Cypress wait for elements automatically). Never use fixed sleeps
Test data Two tests use the same account and collide; yesterday's order changes today's counts Each test creates or picks its own data; reset state between tests
Environment The staging server is slow or restarting Run a smoke test first; retry only network-level failures
Order dependence Test B only passes if test A ran first Every test signs in and sets up what it needs itself
Animations and pop-ups A cookie banner covers the button; a fade-in isn't finished Dismiss banners in setup; assert on the final state, not the animation
Fragile locators The class name changed Climb the locator ladder above

Flaky tests are dangerous, not just annoying: once people learn to re-run red tests until they pass, real failures get re-run away too (see Part 17). Treat a flaky test as a bug in the test suite and fix it the same week.

Selenium vs Playwright vs Cypress

The three tools almost every team chooses between:

Selenium Playwright Cypress
First released 2004 2020 2017
Languages Java, Python, C#, JavaScript, Ruby, Kotlin JavaScript/TypeScript, Python, Java, C# JavaScript/TypeScript only
Browsers All major browsers, via WebDriver Chromium, Firefox, WebKit (Safari engine) Chrome-family, Firefox, WebKit (experimental)
How it drives the browser WebDriver protocol (a W3C standard) Direct protocol connections; one API for all three engines Runs inside the browser alongside your app
Automatic waiting No; you write explicit waits Yes Yes
Multiple tabs and origins Yes Yes Limited
Mobile Via Appium (real devices) Device emulation in desktop browsers Viewport emulation only
Built-in trace, video, screenshots Screenshots; the rest via add-ons Yes, all three, with a trace viewer Yes
Best for Large existing suites, language flexibility, the widest browser and device coverage New projects wanting speed and reliability across all engines Front-end teams working in JavaScript who want a fast developer experience

Some honest observations:

  • Selenium is the industry standard with the largest ecosystem and the most job listings. Its WebDriver protocol is why it works everywhere, including real mobile devices through Appium. Its main cost is that you manage waiting yourself, which is where many flaky Selenium suites come from.
  • Playwright was built by the team that originally created Puppeteer at Google, after they moved to Microsoft. It's fast, waits automatically, tests all three browser engines with one API, and its trace viewer (a recording of every step, with screenshots and network calls) is the best debugging tool of the three. It is the default choice for new projects today.
  • Cypress has the most pleasant developer experience for front-end engineers: tests run in the browser with a live, time-travelling view. The trade-offs are JavaScript-only, weaker multi-tab and cross-origin support, and a paid service for parallel runs and recordings.

If you're starting from nothing, pick Playwright. If your team already has a Selenium suite that works, there is no need to rewrite it. If your team is all front-end JavaScript developers, Cypress will make them happiest.

Where UI tests run

  • On your laptop while writing them.
  • In the CI pipeline on every change, usually headless (no visible browser window), as described in Part 23 of Testing Basics.
  • On a browser grid or device cloud when you need many browsers, operating systems and real phones at once, far more than one machine can hold. This is what services like ProvaLab's QA Infrastructure, BrowserStack and Sauce Labs provide.

Seven habits of good UI tests

  1. Test journeys, not pages. "A new customer can sign up and place an order" beats "the sign-up page has an email field".
  2. Keep them few and valuable. Push everything else down the pyramid.
  3. Use role-based locators or test IDs.
  4. Make every test independent: its own data, its own sign-in.
  5. Never sleep. Wait for a condition, not a number of seconds.
  6. Assert on what the user sees, not on internal state.
  7. Record on failure. A screenshot, video or trace turns a red test into a bug report in seconds.

What's next

In Part 2 we build real tests like the one above: install Playwright, write the test, run it headless and headed, watch it fail, read the trace, and add it to a pipeline.

ProvaLab's QA Infrastructure runs Playwright and Selenium tests on real browsers and devices in the cloud, records every session, and hands the recording and logs back to your coding agent when a test fails. See it in the two-minute product tour.

Prefer to read offline? The whole Hands-On Testing series is one free PDF: get the PDF.

See ProvaLab working on a real app

Your coding agent runs the tests, reads the failures and fixes them.