What web E2E testing is
Web end-to-end (E2E) testing drives a real browser the way a user would: a page is loaded, interactions are performed (clicks, form fills, navigations), and outcomes are asserted against what the user should see. Per playwright.dev/docs/intro (opens in new window): "Playwright Test is an end-to-end test framework for modern web apps. It bundles test runner, assertions, isolation, parallelization and rich tooling." That bundle is what distinguishes an E2E framework from a bare browser-automation library: a runner, an assertion library with retry, per-test isolation, and parallelism.
E2E sits above unit and integration tests, not instead of them. Unit tests call a function; integration tests call a module against a real dependency; E2E boots the whole stack (browser, app, API, database) and checks a user journey. It is the only layer that catches "the button is wired to the wrong handler", and the most expensive layer to own: per martinfowler.com/articles/practical-test-pyramid (opens in new window), E2E tests "are notoriously flaky and often fail for unexpected and unforeseeable reasons" (browser quirks, timing, animations, popups), so "you should aim to reduce the number of end-to-end tests to a bare minimum" and spend them on core journeys only: search a product, add to basket, check out.
Practical starting budget (a convention among practitioners, not a documented standard): five to fifteen E2E specs covering signup, login, the primary create / read / update path, checkout or submit, and one permissions case. Everything else belongs a layer down.
Choosing a framework
Read the repo first, then the table. Every row is keyed on something you can observe without asking anyone. Take the first row that matches, top to bottom.
# Run at the repo root before choosing anything.
ls playwright.config.* cypress.config.* wdio.conf.* .testcaferc.* nightwatch.conf.* 2>/dev/null
ls -d cypress e2e tests/e2e 2>/dev/null
grep -iE '"(@playwright/test|cypress|webdriverio|@wdio/cli|puppeteer|testcafe|nightwatch|selenium-webdriver)"' package.json
grep -riE 'selenium-(java|webdriver)|Selenium\.WebDriver' pom.xml build.gradle* requirements.txt *.csproj Gemfile 2>/dev/null| What you observe | Use | Why |
|---|---|---|
playwright.config.ts / .js, or @playwright/test in package.json | Playwright | Already chosen. Add specs, do not add a second runner. |
cypress.config.ts / .js, or a cypress/ directory | Cypress | Already chosen. Extend it; a migration is a separate project with its own budget. |
wdio.conf.ts / .js | WebdriverIO | Already chosen. |
.testcaferc.json / .testcaferc.js | TestCafe | Already chosen. |
nightwatch.conf.*, or nightwatch in package.json | Nightwatch (legacy) | Keep it running, but plan a migration to Playwright or WebdriverIO rather than growing the suite. |
selenium-java, selenium-webdriver, or Selenium.WebDriver in a build file | Selenium WebDriver | Already chosen, usually with years of page objects behind it. |
No E2E config anywhere, and the repo has a package.json | Playwright | Default for greenfield. One install covers Chromium, Firefox and WebKit [pwi (opens in new window)]. |
| No E2E config, and the team will not add Node (pure Java / C# / Ruby / Python shop) | Selenium WebDriver | The only option with first-class bindings in Java, Python, C#, Ruby, JavaScript and Kotlin [sellib (opens in new window)]. |
| Safari / WebKit coverage is a stated requirement | Playwright | Ships WebKit in the default install [pwi (opens in new window)]. Cypress lists WebKit as experimental and opt-in [cybr (opens in new window)]. |
| The same suite must also drive a native mobile or Electron app | WebdriverIO | Automates web, hybrid and native mobile via Appium, and native desktop such as Electron [wdiowhy (opens in new window)]. |
| The task is scraping, screenshotting or HTML-to-PDF, with no assertions | Puppeteer | A library, not a test framework: "a JavaScript library which provides a high-level API to control Chrome or Firefox" [ppt (opens in new window)]. No runner, no isolation, no parallelism. |
| A no-WebDriver architecture is a hard constraint and its wait model appeals | TestCafe | Runs on Node.js and waits automatically for selectors, actions, assertions, XHR and redirects [tcwait (opens in new window)]. |
| Real iOS / Android devices, old browser versions, or a large OS matrix are required | Keep the runner above, add a cloud grid | See "Real devices and browser matrices" below. This is an execution target, not a framework choice. |
Tie-break when two rows both look true: whatever is already in the repo wins. A second E2E framework doubles the CI cost, the selector conventions and the flake surface, and buys coverage you could get by adding one grid config to the framework you have. If the repo shows two competing frameworks (@playwright/test AND cypress both in devDependencies), stop and ask which is canonical - do not silently pick one. And never recommend a framework swap on an existing project without a stated reason: "modernity" alone burns years of page-object investment for marginal gain.
First runnable path: Playwright
For the default choice, per playwright.dev/docs/intro (opens in new window):
# 1. Scaffold: prompts for TS/JS, tests folder, CI workflow, browser binaries.
npm init playwright@latest
# 2. Run the generated example to confirm the install is healthy.
npx playwright testThe wizard writes playwright.config.ts and tests/example.spec.ts, and updates package.json [pwi (opens in new window)]. npx playwright test runs headless and in parallel by default across Chromium, Firefox and WebKit [pwi (opens in new window)]. Success looks like: every example spec passes on a clean machine, with no browser window appearing. If it fails here, the problem is the install, not your app.
Then open tests/example.spec.ts, delete it, and write your first real spec in that shape:
import { test, expect } from '@playwright/test';
test('user can reach the pricing page', async ({ page }) => {
await page.goto('https://example.com/');
await page.getByRole('link', { name: 'Pricing' }).click();
await expect(page.getByRole('heading', { name: 'Pricing' })).toBeVisible();
});Two things in that snippet are the whole discipline: locators named after what the user sees, and an await expect(...) assertion that retries. Both are covered below.
First runnable path for the other tools
| Tool | Install | First run |
|---|---|---|
| Cypress | npm install cypress --save-dev [cyins (opens in new window)] | npx cypress open, then pick end-to-end or component mode [cyins (opens in new window)] |
| Selenium (Python) | pip install selenium [sellib (opens in new window)] | run your test file with the project's runner (pytest, JUnit, NUnit) |
| Selenium (Java/Maven) | add the org.seleniumhq.selenium:selenium-java dependency to pom.xml [sellib (opens in new window)] | mvn test |
| WebdriverIO | npm init wdio@latest . [wdio (opens in new window)] | npx wdio run ./wdio.conf.js [wdio (opens in new window)] |
| Puppeteer | npm i puppeteer (downloads a compatible browser) [ppt (opens in new window)] | node your-script.js |
| TestCafe | npm install -g testcafe [tcgs (opens in new window)] | testcafe chrome getting-started.js [tcgs (opens in new window)] |
Selenium drives "a browser natively, as a user would, either locally or on a remote machine using the Selenium server" [selwd (opens in new window)], which is why it has no bundled runner: you bring JUnit, pytest, NUnit or RSpec yourself.
Real devices and browser matrices
A local runner covers the engines installed on the machine. When the requirement is real hardware, legacy browser versions, or dozens of OS/browser pairs, point the same suite at a hosted grid instead of rewriting it.
| Provider | Credentials | Best fit |
|---|---|---|
| BrowserStack Automate | username + access key from the account profile, set in browserstack.yml [bs (opens in new window)] | broadest device matrix - advertises "3000+ real devices and desktop browsers" [bs (opens in new window)] - and enterprise procurement |
| Sauce Labs | username + access key from User Settings [sl (opens in new window)] | Selenium-grid-centric parallel CI farms; region-specific hubs, e.g. https://ondemand.us-west-1.saucelabs.com/wd/hub, https://ondemand.eu-central-1.saucelabs.com/wd/hub [sl (opens in new window)] |
| LambdaTest | LT_USERNAME and LT_ACCESS_KEY environment variables [lt (opens in new window)] | cost-sensitive, smaller scale; grid endpoint and capabilities are generated from the dashboard [lt (opens in new window)] |
A grid is recommended in addition to the chosen framework, never as a substitute. Two pairing cautions: Cypress runs its own runner, not WebDriver, so it cannot target a WebDriver hub URL - use Cypress Cloud or the vendor's Cypress-specific runner instead. And Puppeteer stays Chromium-only no matter what grid sits behind it - for cross-browser coverage pick Playwright. Setup for all three vendors is in cloud-grid-e2e.
Decide the matrix from analytics, not from anxiety. Grid minutes are billed, and a matrix of twenty combinations turns a two-minute suite into a twenty-minute one.
The traps that bite first
1. Fixed sleeps instead of retrying assertions. This is the single largest source of flake. A hard-coded pause is either too short (fails on a slow CI box) or too long (a suite that takes an hour). Cypress states plainly that "waiting for arbitrary time periods using cy.wait(Number) is an anti-pattern" and directs you to "use route aliases or assertions to guard Cypress from proceeding until an explicit condition is met" [cybp (opens in new window)]. Playwright's equivalent: "By using web first assertions Playwright will wait until the expected condition is met" [pwbp (opens in new window)], and before every action it runs actionability checks for visibility, stability, event reception and enabled state [pwact (opens in new window)].
// Wrong: guesses at timing, fails on a slow runner.
await page.waitForTimeout(3000);
expect(await page.locator('.toast').isVisible()).toBe(true);
// Right: retries until the condition holds or the timeout expires.
await expect(page.getByRole('status')).toHaveText('Saved');Corollary: isVisible() and friends return a boolean once, immediately, with no waiting [pwbp (opens in new window)]. If you find yourself adding a sleep to make one pass, you wanted a retrying assertion instead.
2. CSS and XPath selectors instead of role and label locators. Selectors bound to class names, nth-child positions or generated IDs break on the next CSS refactor even though nothing a user can perceive has changed. Playwright's guidance is to "prefer user-facing attributes to XPath or CSS selectors" and reach for page.getByRole('button', { name: 'submit' }) [pwbp (opens in new window)]. Cypress, which has no role locator by default, instead tells you to "add data-* attributes to make it easier to target elements" and use [data-cy="submit"] selectors, "isolated from styling or behavioral changes" [cybp (opens in new window)].
Preference order that works in every framework: accessible role plus accessible name, then label or placeholder text, then visible text, then a dedicated test attribute (data-testid / data-cy), and only then CSS. Never XPath positions. The bonus is real: a test that can only find the button by its role is a test that fails when the button loses its accessible name.
3. State shared between tests. Order-dependent tests pass locally in one order and fail in CI in another, and they fail catastrophically under parallelism. Playwright: "Each test should be completely isolated from another test and should run independently with its own local storage, session storage, data, cookies etc." [pwbp (opens in new window)]. Cypress: "Tests should always be able to be run independently from one another and still pass" [cybp (opens in new window)]. Both recommend beforeEach for setup rather than letting test N depend on test N-1 [pwbp (opens in new window)] [cybp (opens in new window)].
This matters more than it sounds because parallelism is on by default. Playwright runs tests in worker processes that are "OS processes, running independently", each with its own browser, and "you can't communicate between the workers" [pwpar (opens in new window)]. A shared login, a shared seeded record, or a fixed username will collide the moment two workers touch it. Create per-test data with a unique suffix, and set up auth state programmatically rather than by driving the login form in every spec.
4. Treating E2E as the coverage strategy. Every validation rule pushed into a browser test costs a browser boot. When a case can be checked one layer down, check it one layer down [ptp (opens in new window)].
Going deeper
Optional. Each tool above has a dedicated in-depth component if the full set is installed alongside this one; nothing here depends on them.
| Tool | Deeper reference |
|---|---|
| Playwright | playwright-testing |
| Cypress | cypress-testing |
| Selenium WebDriver | selenium-testing |
| WebdriverIO | webdriverio-testing |
| Puppeteer | playwright-testing (Puppeteer is a browser-automation library, not a test framework; migrate to Playwright) |
| TestCafe | playwright-testing (marginal adoption; not recommended for new projects) |
| BrowserStack / Sauce Labs / LambdaTest | cloud-grid-e2e (one pattern, per-vendor references) |