Your frontend works locally. Then you deploy to production — a button changes class, a popup refuses to close, a real user hits a 500 error. Manual testing happens when you remember. Selenium is slow and flaky. Cypress is nice but only runs in Chromium and reloads the same tab. You need something better.
We, at Meteora Web, build applications every day and have seen too many test suites abandoned because they were brittle, slow, or impossible to maintain. Playwright — Microsoft's open source framework — is what we now use for end-to-end testing on real projects. We're not going back.
This guide goes straight from code to CI, no fluff. If you've never used Playwright, you'll find everything to get started. If you already know it, you'll find strategies we refined on client projects with tight budgets and deadlines.
Why does Playwright outperform Selenium and Cypress for E2E testing?
The classic problem with E2E tests is fragility: an element not yet loaded, wrong timing, a CSS selector that changes. Selenium forces you to write explicit wait and sleep. Cypress has auto-wait but runs in a single browser (Chromium by default) and lacks multi-context support.
Playwright fixes this at the core: auto-waiting. Every command waits until the element is visible, enabled, and stable before interacting. Zero sleep in your code. Plus:
Sponsored Protocol
- Multi-browser: Chromium, Firefox, WebKit with the same API. Test on Safari and Edge without emulators.
- Isolated contexts: open multiple browser contexts in one session — great for testing authenticated and guest flows simultaneously.
- Network interception: mock APIs and responses directly from the test, no external server needed.
- Flexible execution: headless, headed, partial CI runs. Native parallelism.
We migrated a client from Cypress + Selenium Grid to Playwright: the suite runtime dropped from 45 minutes to 12. False positives went to zero.
How to write E2E tests with Playwright?
Let's start with a real example: testing login on a Laravel/Vue app using JWT. Install Playwright in a Node project:
npm init playwright@latest
This creates the standard structure with tests/ and playwright.config.ts. Configure it for multiple browsers:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: 1,
use: {
baseURL: 'http://localhost:8000',
viewport: { width: 1280, height: 720 },
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
],
});
Now an E2E test for login:
Sponsored Protocol
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test('login with valid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
// Playwright automatically waits for navigation after submit
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.locator('.welcome')).toContainText('Welcome');
});
test('login with wrong password shows error', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'wrongpass');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toBeVisible();
});
Notice: no await page.waitForTimeout(). Playwright waits until the button is clickable, the field visible, the new URL loaded. Tests are deterministic.
Sponsored Protocol
Robust selectors
Avoid fragile selectors like #app > div:nth-child(2). Use data-testid attributes in your frontend:
<button data-testid="submit-login">Sign In</button>
await page.click('[data-testid="submit-login"]');
Playwright also supports text, placeholder, ARIA role selectors. Pick ones that are least likely to change with design updates.
Mocking API calls
To test scenarios without a real backend, intercept requests:
await page.route('**/api/login', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ token: 'fake-jwt', user: { name: 'Test' } }),
});
});
This makes tests fast and independent from external environments.
Playwright in CI/CD — Seamless Integration
The real value of E2E tests comes when they run on every push. Playwright integrates easily. Here's an example for GitHub Actions:
# .github/workflows/e2e.yml
name: Playwright E2E
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build && npm run start &
working-directory: ./frontend
- run: npx playwright test
We also use the HTML report Playwright generates: just add --reporter=html and publish the folder as an artifact. Failures become visible with screenshots, traces, and videos.
Sponsored Protocol
Parallelism and sharding
For large suites, enable parallel execution with fullyParallel: true. If you have many tests, use sharding to distribute across multiple CI jobs:
npx playwright test --shard=1/3
npx playwright test --shard=2/3
npx playwright test --shard=3/3
With three parallel jobs, a 30-minute suite becomes 10 minutes. And your CI bill doesn't explode.
How to handle states and waits in E2E tests?
Even with auto-waiting, there are tricky situations: modal popups after a timer, CSS animations, asynchronous AJAX calls. Playwright provides specific tools:
- Configurable expect:
await expect(locator).toBeVisible({ timeout: 10000 })to increase timeout only for that assertion. - Wait for event:
await page.waitForResponse('**/api/save')to wait until a specific request completes. - page.waitForFunction: for custom JavaScript conditions, e.g., a counter reaching zero.
Practical example: wait for a toast to disappear after an operation:
Sponsored Protocol
await page.locator('.toast-success').waitFor({ state: 'hidden' });
Never write await page.waitForTimeout(3000). If a test fails due to timing, don't just increase the timeout — review your waiting logic. Use Playwright's native methods.
What to do next
- Install Playwright in an existing frontend project with
npm init playwright@latest. - Write your first test for the login page or a critical flow (cart, checkout, contact form).
- Add the test to CI with a GitHub Actions or GitLab CI pipeline. Start with one browser, then add multi-browser.
- Introduce data-testid in Vue/React components to make selectors immutable.
- Remove every sleep from your code: replace with event-based or locator-based waits.
E2E tests are not a luxury — they are the safety net that turns a deploy into an act of confidence, not a gamble. We at Meteora Web see this every day on the projects we manage. If you need help setting up your testing strategy, start with our pillar guide on software testing and then reach out.