Introduction
End-to-end (E2E) testing is crucial for ensuring your application works as users experience it. Playwright and Cypress are the two dominant frameworks in 2025. This guide helps you choose the right one.
Quick Comparison
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Playwright vs Cypress โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ PLAYWRIGHT CYPRESS โ
โ โโโโโโโโโโ โโโโโโโโ โ
โ Microsoft maintained Cypress.io maintained โ
โ Multi-browser Single browser (Chromium) โ
โ Native async Custom cy.* commands โ
โ Auto-wait Built-in waits โ
โ Parallel execution Parallel with plugin โ
โ Great debugging Great debugging โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Playwright
Setup
# Install Playwright
npm init playwright@latest
# Or add to existing project
npm install -D @playwright/test
npx playwright install chromium
Writing Tests
// tests/example.spec.ts
import { test, expect } from '@playwright/test';
test('user can login', async ({ page }) => {
await page.goto('https://example.com/login');
// Fill form
await page.fill('[data-testid="email"]', '[email protected]');
await page.fill('[data-testid="password"]', 'password123');
// Submit
await page.click('[data-testid="submit"]');
// Verify redirect
await expect(page).toHaveURL('/dashboard');
// Verify welcome message
await expect(page.locator('[data-testid="welcome"]')).toContainText('Welcome');
});
Page Object Pattern
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('[data-testid="email"]');
this.passwordInput = page.locator('[data-testid="password"]');
this.submitButton = page.locator('[data-testid="submit"]');
this.errorMessage = page.locator('[data-testid="error"]');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
// Usage in test
import { LoginPage } from '../pages/LoginPage';
test('login flow', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('[email protected]', 'password123');
await expect(page).toHaveURL('/dashboard');
});
Configuration
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'https://example.com',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
{
name: 'firefox',
use: { browserName: 'firefox' },
},
{
name: 'webkit',
use: { browserName: 'webkit' },
},
],
});
Cypress
Setup
# Install Cypress
npm install cypress --save-dev
# Open Cypress
npx cypress open
Writing Tests
// cypress/e2e/login.cy.ts
describe('Login Flow', () => {
beforeEach(() => {
cy.visit('/login');
});
it('should login successfully', () => {
cy.get('[data-testid="email"]').type('[email protected]');
cy.get('[data-testid="password"]').type('password123');
cy.get('[data-testid="submit"]').click();
// Verify redirect
cy.url().should('include', '/dashboard');
// Verify welcome message
cy.get('[data-testid="welcome"]').should('contain', 'Welcome');
});
it('should show error with invalid credentials', () => {
cy.get('[data-testid="email"]').type('[email protected]');
cy.get('[data-testid="password"]').type('wrongpassword');
cy.get('[data-testid="submit"]').click();
cy.get('[data-testid="error"]').should('be.visible');
});
});
Custom Commands
// cypress/support/commands.ts
declare namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
logout(): Chainable<void>;
}
}
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('[data-testid="email"]').type(email);
cy.get('[data-testid="password"]').type(password);
cy.get('[data-testid="submit"]').click();
});
// Usage
it('should work', () => {
cy.login('[email protected]', 'password123');
});
Feature Comparison
comparison:
browser_support:
playwright: "Chromium, Firefox, WebKit (all major)"
cypress: "Chromium-based only (Electron)"
parallelization:
playwright: "Built-in, easy"
cypress: "Requires plugin, more complex"
auto_wait:
playwright: "Auto-waits for elements"
cypress: "Explicit waits needed"
multi_tab:
playwright: "Native support"
cypress: "Plugin required"
iframes:
playwright: "Native support"
cypress: "Plugin or workaround"
test_isolation:
playwright: "Great (fresh context)"
cypress: "Good (clears state)"
When to Choose
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Decision Guide โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ Choose PLAYWRIGHT if: โ
โ โข Need multi-browser testing โ
โ โข Complex scenarios (tabs, iframes) โ
โ โข Parallel testing is critical โ
โ โข Want native async/await โ
โ โข Need WebKit testing โ
โ โ
โ Choose CYPRESS if: โ
โ โข Simple internal tools โ
โ โข Fast team adoption (gentle learning curve) โ
โ โข Excellent debugging experience โ
โ โข Dashboard.io integration โ
โ โข Component testing needed โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Best Practices
Test Data Attributes
<!-- Always use data-testid for tests -->
<button data-testid="submit" class="btn-primary">
Submit
</button>
Page Object Pattern
// Both frameworks support page objects
// Keeps tests clean and maintainable
CI Integration
# GitHub Actions - Playwright
- name: Run Playwright tests
run: npx playwright test
# GitHub Actions - Cypress
- name: Run Cypress tests
uses: cypress-io/github-action@v5
Key Takeaways
- Playwright - More powerful, multi-browser, better for complex apps
- Cypress - Easier to learn, great for simple internal tools
- Both are excellent - Choice depends on your specific needs
Comments