Playwright E2E
intermediateEnd-to-end testing with Playwright and page object patterns.
testingplaywrighte2e
Tested on⬢20▲16⚛19TS5.9
$ bunx sinew add testing/playwright-e2eInteractive demo coming soon
1The Problem
E2E tests are often flaky and hard to maintain:
- Tests break when UI changes
- No structure for reusable test utilities
- CI setup is complex
- Testing authenticated flows is difficult
2The Solution
Use Playwright with the Page Object pattern for maintainable tests. Structure tests with fixtures for authentication and shared setup.
3Files
playwright.config.ts
playwright.config.tsTypeScript
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", { open: "never" }], ["list"]],
use: {
baseURL: process.env.BASE_URL || "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: "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,
},
});e2e/fixtures/base.ts
e2e/fixtures/base.tsTypeScript
import { test as base, expect } from "@playwright/test";
export const test = base.extend<{
// Add custom fixtures here
}>({
// Example: Auto-login fixture
// authenticatedPage: async ({ page }, use) => {
// await page.goto("/login");
// await page.fill('[name="email"]', "test@example.com");
// await page.fill('[name="password"]', "password");
// await page.click('button[type="submit"]');
// await page.waitForURL("/dashboard");
// await use(page);
// },
});
export { expect };e2e/pages/home.page.ts
e2e/pages/home.page.tsTypeScript
import { Page, Locator } from "@playwright/test";
export class HomePage {
readonly page: Page;
readonly heading: Locator;
readonly ctaButton: Locator;
readonly navLinks: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole("heading", { level: 1 });
this.ctaButton = page.getByRole("link", { name: /get started/i });
this.navLinks = page.getByRole("navigation").getByRole("link");
}
async goto() {
await this.page.goto("/");
}
async clickCta() {
await this.ctaButton.click();
}
async getNavLinkCount() {
return this.navLinks.count();
}
}e2e/home.spec.ts
e2e/home.spec.tsTypeScript
import { test, expect } from "./fixtures/base";
import { HomePage } from "./pages/home.page";
test.describe("Home Page", () => {
test("has correct title", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/Your App/);
});
test("displays main heading", async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
await expect(homePage.heading).toBeVisible();
});
test("CTA button navigates correctly", async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
await homePage.clickCta();
await expect(page).toHaveURL(/patterns/);
});
});.github/workflows/e2e.yml
.github/workflows/e2e.ymlYAML
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Build app
run: npm run build
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
uses: actions/upload-artifact@v6
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 74Dependencies
$ bun add -D @playwright/test5Configuration
Add to package.json
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug"
}
}JSON
6Usage
Running Tests
# Run all tests
npx playwright test
# Run with UI mode
npx playwright test --ui
# Run specific test file
npx playwright test e2e/home.spec.ts
# Debug mode
npx playwright test --debugBash
Testing Authenticated Routes
// e2e/fixtures/auth.ts
import { test as base } from "@playwright/test";
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
await page.goto("/login");
await page.fill('[name="email"]', process.env.TEST_USER_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_USER_PASSWORD!);
await page.click('button[type="submit"]');
await page.waitForURL("/dashboard");
await use(page);
},
});TypeScript
7Troubleshooting
Tests are flaky
- Use
await expect(...).toBeVisible()instead of relying on timing - Add
page.waitForLoadState("networkidle")for pages with async data - Use retries in CI:
retries: process.env.CI ? 2 : 0
Cannot find element
- Use Playwright's locator methods:
getByRole,getByText,getByTestId - Avoid CSS selectors when possible
- Use the Playwright inspector:
npx playwright test --debug