QA and Test Automation: Unit, Integration, and E2E Explained
A practical guide to the testing pyramid — what unit, integration, and end-to-end tests actually do, when to write each, and how to automate them in CI without slowing your team down.
Learn end-to-end testing with Playwright — writing reliable tests, handling authentication, testing across browsers, and integrating with CI/CD pipelines.
By codefunc
End-to-end tests verify your application works as users experience it. They catch integration issues that unit tests miss. Playwright has become the standard E2E testing tool — it is fast, reliable, and works across all browsers. Understanding Playwright patterns helps you write tests that catch real bugs without constant flakiness.
Bottom line: Playwright tests user flows, not implementation details. Use locators based on accessible roles and text, not CSS selectors. Handle async operations with auto-waiting, not arbitrary sleeps. Run tests in CI on every PR. Start with critical user paths (login, checkout, core features) before testing edge cases.
# Create new project with Playwright
npm init playwright@latest
# Or add to existing project
npm install -D @playwright/test
npx playwright install
This creates:
playwright.config.ts — configurationtests/ — test directorytests-examples/ — example tests// tests/homepage.spec.ts
import { test, expect } from '@playwright/test';
test('homepage has correct title', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example/);
});
test('user can navigate to about page', async ({ page }) => {
await page.goto('https://example.com');
await page.getByRole('link', { name: 'About' }).click();
await expect(page).toHaveURL(/.*about/);
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
});
Run tests:
npx playwright test
npx playwright test --headed # See browser
npx playwright test --ui # Interactive UI mode
Locators find elements on the page. Prefer accessible locators over CSS selectors.
// 1. Role-based (best - matches accessibility)
page.getByRole('button', { name: 'Submit' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('link', { name: 'Learn more' })
page.getByRole('heading', { name: 'Welcome' })
// 2. Label text
page.getByLabel('Email address')
page.getByLabel('Password')
// 3. Placeholder
page.getByPlaceholder('Enter your email')
// 4. Text content
page.getByText('Sign up for free')
page.getByText(/welcome/i) // Regex for flexibility
// 5. Test ID (when above don't work)
page.getByTestId('submit-button')
// 6. CSS selector (last resort)
page.locator('.submit-btn')
page.locator('#email-input')
// Bad: Breaks if class changes
page.locator('.btn-primary.submit')
// Bad: Breaks if structure changes
page.locator('form > div:nth-child(3) > button')
// Good: Resilient, tests accessibility
page.getByRole('button', { name: 'Submit' })
// Find button inside specific section
const nav = page.getByRole('navigation');
await nav.getByRole('link', { name: 'Home' }).click();
// Find within a list item
const todoItem = page.getByRole('listitem').filter({ hasText: 'Buy groceries' });
await todoItem.getByRole('button', { name: 'Delete' }).click();
// Click
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button').dblclick();
await page.getByRole('button').click({ button: 'right' });
// Type
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Email').pressSequentially('user@example.com'); // Simulates typing
// Clear and type
await page.getByLabel('Email').clear();
await page.getByLabel('Email').fill('new@example.com');
// Select dropdown
await page.getByLabel('Country').selectOption('US');
await page.getByLabel('Country').selectOption({ label: 'United States' });
// Checkbox and radio
await page.getByLabel('Accept terms').check();
await page.getByLabel('Accept terms').uncheck();
await page.getByRole('radio', { name: 'Express shipping' }).check();
// Hover
await page.getByRole('button', { name: 'Menu' }).hover();
// File upload
await page.getByLabel('Upload file').setInputFiles('path/to/file.pdf');
await page.keyboard.press('Enter');
await page.keyboard.press('Tab');
await page.keyboard.press('Control+A');
await page.keyboard.type('Hello World');
// Visibility
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('dialog')).toBeHidden();
// Text content
await expect(page.getByRole('heading')).toHaveText('Welcome');
await expect(page.getByRole('heading')).toContainText('Welcome');
// Attributes
await expect(page.getByRole('link')).toHaveAttribute('href', '/about');
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toBeDisabled();
// Form state
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
await expect(page.getByLabel('Accept')).toBeChecked();
// Count
await expect(page.getByRole('listitem')).toHaveCount(5);
// Page state
await expect(page).toHaveURL(/.*dashboard/);
await expect(page).toHaveTitle('Dashboard | MyApp');
// Screenshot comparison
await expect(page).toHaveScreenshot('homepage.png');
Continue test after failure:
await expect.soft(page.getByText('Welcome')).toBeVisible();
await expect.soft(page.getByText('Dashboard')).toBeVisible();
// Test continues even if first assertion fails
Playwright auto-waits for elements, but sometimes you need explicit waits.
// Wait for element
await page.getByRole('button', { name: 'Submit' }).waitFor();
await page.getByRole('alert').waitFor({ state: 'visible' });
// Wait for navigation
await page.waitForURL('**/dashboard');
// Wait for network request
await page.waitForResponse('**/api/users');
// Wait for load state
await page.waitForLoadState('networkidle');
// Custom wait
await page.waitForFunction(() => {
return document.querySelector('.loaded') !== null;
});
// Bad: Flaky, slow
await page.waitForTimeout(3000);
// Good: Wait for specific condition
await expect(page.getByRole('alert')).toBeVisible();
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
});
test('user can view profile', async ({ page }) => {
await page.getByRole('link', { name: 'Profile' }).click();
await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible();
});
Save login state and reuse across tests:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: {
storageState: 'playwright/.auth/user.json',
},
},
],
});
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
await page.context().storageState({ path: authFile });
});
Organize tests with page objects:
// pages/login.page.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.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
test('successful login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'wrong');
await expect(loginPage.errorMessage).toHaveText('Invalid credentials');
});
Mock API responses for faster, reliable tests:
test('displays user data from API', async ({ page }) => {
// Mock API response
await page.route('**/api/user', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: 'John Doe',
email: 'john@example.com',
}),
});
});
await page.goto('/profile');
await expect(page.getByText('John Doe')).toBeVisible();
await expect(page.getByText('john@example.com')).toBeVisible();
});
test('handles API error gracefully', async ({ page }) => {
await page.route('**/api/user', async (route) => {
await route.fulfill({
status: 500,
body: 'Internal Server Error',
});
});
await page.goto('/profile');
await expect(page.getByText('Failed to load profile')).toBeVisible();
});
For generating test data, use the Fake Data Generator.
// playwright.config.ts
import { defineConfig, devices } 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: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
# .github/workflows/playwright.yml
name: Playwright Tests
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
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
npx playwright test --ui
npx playwright test --debug
npx playwright show-trace trace.zip
Record tests by interacting with the browser:
npx playwright codegen https://example.com
test('form validation', async ({ page }) => {
await page.goto('/register');
// Submit empty form
await page.getByRole('button', { name: 'Register' }).click();
// Check validation messages
await expect(page.getByText('Email is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();
// Fill with invalid data
await page.getByLabel('Email').fill('invalid-email');
await page.getByRole('button', { name: 'Register' }).click();
await expect(page.getByText('Invalid email format')).toBeVisible();
// Fill with valid data
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: 'Register' }).click();
await expect(page).toHaveURL('/welcome');
});
test('confirm dialog', async ({ page }) => {
await page.goto('/items');
await page.getByRole('button', { name: 'Delete' }).click();
// Modal appears
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await expect(modal.getByText('Are you sure?')).toBeVisible();
// Confirm deletion
await modal.getByRole('button', { name: 'Confirm' }).click();
// Modal closes, item removed
await expect(modal).toBeHidden();
await expect(page.getByText('Item deleted')).toBeVisible();
});
test('table sorting', async ({ page }) => {
await page.goto('/users');
// Click header to sort
await page.getByRole('columnheader', { name: 'Name' }).click();
// Verify order
const rows = page.getByRole('row');
await expect(rows.nth(1)).toContainText('Alice');
await expect(rows.nth(2)).toContainText('Bob');
// Click again for reverse
await page.getByRole('columnheader', { name: 'Name' }).click();
await expect(rows.nth(1)).toContainText('Zoe');
});
Use these codefunc tools alongside this guide:
Start with critical user flows — login, main features, checkout. Use role-based locators for resilience. Mock APIs for speed and reliability. Run tests in CI on every PR. Use the trace viewer to debug failures. Page objects keep tests maintainable as they grow. Don't test implementation details; test user behavior.
A practical guide to the testing pyramid — what unit, integration, and end-to-end tests actually do, when to write each, and how to automate them in CI without slowing your team down.
When to use XPath vs CSS selectors, how to write stable queries, and a browser-side workflow for testing against real HTML without shipping brittle scrapers.
A practical guide to monorepo architecture — when to use monorepos, setting up Turborepo or Nx, shared packages, caching, and CI/CD optimization.
Find a developer tool