16.5 E2E Testing — Playwright + TypeScript
Playwright Installation and Setup
npm install --save-dev @playwright/test
npx playwright install # Install browsers
playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['list']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
Basic Test Writing
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('successful login', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('alice@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Login' }).click()
// Redirect to dashboard after login
await expect(page).toHaveURL('/dashboard')
await expect(page.getByText('Welcome, Alice!')).toBeVisible()
})
test('login failure with wrong password', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('alice@example.com')
await page.getByLabel('Password').fill('wrongpassword')
await page.getByRole('button', { name: 'Login' }).click()
await expect(page.getByText('Invalid email or password.')).toBeVisible()
await expect(page).toHaveURL('/login')
})
test('unauthenticated user redirect', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveURL('/login')
})
})
Page Object Pattern (POM)
// e2e/pages/login.page.ts
import { Page, Locator, expect } 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: 'Login' })
this.errorMessage = page.getByTestId('error-message')
}
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()
}
async expectErrorMessage(message: string) {
await expect(this.errorMessage).toContainText(message)
}
}
// e2e/pages/dashboard.page.ts
export class DashboardPage {
readonly page: Page
readonly welcomeMessage: Locator
readonly userMenu: Locator
constructor(page: Page) {
this.page = page
this.welcomeMessage = page.getByTestId('welcome-message')
this.userMenu = page.getByRole('button', { name: 'User Menu' })
}
async expectLoaded(userName: string) {
await expect(this.page).toHaveURL('/dashboard')
await expect(this.welcomeMessage).toContainText(userName)
}
async logout() {
await this.userMenu.click()
await this.page.getByRole('menuitem', { name: 'Logout' }).click()
}
}
// e2e/auth.spec.ts — Using POM
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/login.page'
import { DashboardPage } from './pages/dashboard.page'
test.describe('Authentication (POM pattern)', () => {
test('login → dashboard → logout', async ({ page }) => {
const loginPage = new LoginPage(page)
const dashboardPage = new DashboardPage(page)
await loginPage.goto()
await loginPage.login('alice@example.com', 'password123')
await dashboardPage.expectLoaded('Alice')
await dashboardPage.logout()
await expect(page).toHaveURL('/login')
})
})
Type-Safe Fixtures
// e2e/fixtures.ts
import { test as base, expect } from '@playwright/test'
import { LoginPage } from './pages/login.page'
import { DashboardPage } from './pages/dashboard.page'
// Fixture type definition
type MyFixtures = {
loginPage: LoginPage
dashboardPage: DashboardPage
authenticatedPage: { page: typeof base.page }
}
// Extend fixtures
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page))
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page))
},
// Pre-authenticated page fixture
authenticatedPage: async ({ page }, use) => {
await page.goto('/login')
await page.getByLabel('Email').fill('alice@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Login' }).click()
await expect(page).toHaveURL('/dashboard')
await use({ page })
},
})
export { expect }
// e2e/dashboard.spec.ts — Using custom fixture
import { test, expect } from './fixtures'
test('access profile when authenticated', async ({ authenticatedPage }) => {
const { page } = authenticatedPage
await page.goto('/profile')
await expect(page.getByText('My Profile')).toBeVisible()
})
API Mocking
// e2e/api-mock.spec.ts
import { test, expect } from '@playwright/test'
test('mock API response', async ({ page }) => {
// Use mock response instead of real API
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
],
total: 2,
}),
})
})
await page.goto('/users')
await expect(page.getByText('Alice')).toBeVisible()
await expect(page.getByText('Bob')).toBeVisible()
await expect(page.getByTestId('user-count')).toHaveText('2 users')
})
test('test API error handling', async ({ page }) => {
await page.route('**/api/users/**', async (route) => {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'User not found.' } }),
})
})
await page.goto('/users/999')
await expect(page.getByText('User not found.')).toBeVisible()
})
Pro Tips
Visual Regression Testing
test('snapshot comparison', async ({ page }) => {
await page.goto('/components/button')
// Screenshot snapshot
await expect(page).toHaveScreenshot('button-default.png')
await expect(page.getByRole('button')).toHaveScreenshot('button-element.png')
})
CI Environment Optimization
// playwright.config.ts
export default defineConfig({
// Increase timeout for slow tests in CI
timeout: process.env.CI ? 60_000 : 30_000,
expect: {
// Visual test threshold
toHaveScreenshot: {
maxDiffPixels: 100,
},
},
})