End-to-end tests verify your application works from the user's perspective. Playwright makes this reliable and fast.
Step 1: Install Playwright
pnpm add -D @playwright/test
npx playwright install
Step 2: Configuration
// 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: process.env.CI ? "github" : "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
},
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"] } },
{ name: "mobile-safari", use: { ...devices["iPhone 13"] } },
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Step 3: Homepage Test
// e2e/homepage.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Homepage", () => {
test("should display the hero section", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/RCB Software/);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(page.getByRole("link", { name: /get started|contact/i })).toBeVisible();
});
test("should navigate to services page", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: /services/i }).first().click();
await expect(page).toHaveURL(/\/services/);
});
test("should load within performance budget", async ({ page }) => {
const start = Date.now();
await page.goto("/");
await page.waitForLoadState("networkidle");
const loadTime = Date.now() - start;
expect(loadTime).toBeLessThan(5000);
});
});
Step 4: Contact Form Test
// e2e/contact.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Contact Form", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/contact");
});
test("should show validation errors for empty form", async ({ page }) => {
await page.getByRole("button", { name: /send|submit/i }).click();
await expect(page.getByText(/name is required|please enter/i)).toBeVisible();
await expect(page.getByText(/email is required|valid email/i)).toBeVisible();
});
test("should submit form successfully", async ({ page }) => {
// Mock the API response
await page.route("**/api/contact", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true }),
});
});
await page.getByLabel(/name/i).fill("Test User");
await page.getByLabel(/email/i).fill("test@example.com");
await page.getByLabel(/company/i).fill("Test Company");
await page.getByLabel(/message/i).fill("This is a test message for e2e testing.");
await page.getByRole("button", { name: /send|submit/i }).click();
await expect(page.getByText(/thank you|success|sent/i)).toBeVisible();
});
test("should handle API errors gracefully", async ({ page }) => {
await page.route("**/api/contact", async (route) => {
await route.fulfill({ status: 500 });
});
await page.getByLabel(/name/i).fill("Test User");
await page.getByLabel(/email/i).fill("test@example.com");
await page.getByLabel(/message/i).fill("Test message");
await page.getByRole("button", { name: /send|submit/i }).click();
await expect(page.getByText(/error|try again|failed/i)).toBeVisible();
});
});
Step 5: Navigation and Responsive Tests
// e2e/navigation.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Navigation", () => {
test("desktop navigation links work", async ({ page }) => {
await page.goto("/");
const navLinks = [
{ name: /services/i, url: "/services" },
{ name: /about/i, url: "/about" },
{ name: /pricing/i, url: "/pricing" },
{ name: /blog/i, url: "/blog" },
{ name: /contact/i, url: "/contact" },
];
for (const { name, url } of navLinks) {
await page.goto("/");
const link = page.getByRole("navigation").getByRole("link", { name }).first();
if (await link.isVisible()) {
await link.click();
await expect(page).toHaveURL(new RegExp(url));
}
}
});
test("mobile menu opens and closes", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto("/");
// Open menu
const menuButton = page.getByRole("button", { name: /menu|toggle/i });
if (await menuButton.isVisible()) {
await menuButton.click();
await expect(page.getByRole("navigation")).toBeVisible();
// Close menu
await menuButton.click();
}
});
});
Step 6: Authentication Flow Test
// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test("should redirect unauthenticated users to login", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login|\/sign-in/);
});
test("should show login form", async ({ page }) => {
await page.goto("/login");
await expect(page.getByLabel(/email/i)).toBeVisible();
await expect(page.getByLabel(/password/i)).toBeVisible();
await expect(page.getByRole("button", { name: /sign in|log in/i })).toBeVisible();
});
});
Step 7: Visual Regression Testing
// e2e/visual.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Visual Regression", () => {
test("homepage matches snapshot", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot("homepage.png", {
maxDiffPixels: 100,
fullPage: true,
});
});
test("dark mode matches snapshot", async ({ page }) => {
await page.goto("/");
await page.emulateMedia({ colorScheme: "dark" });
await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot("homepage-dark.png", {
maxDiffPixels: 100,
fullPage: true,
});
});
});
Step 8: Accessibility Tests
// e2e/accessibility.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Accessibility", () => {
test("homepage should have no accessibility violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test("contact page should have no accessibility violations", async ({ page }) => {
await page.goto("/contact");
const results = await new AxeBuilder({ page })
.exclude(".third-party-widget") // Exclude elements you cannot control
.analyze();
expect(results.violations).toEqual([]);
});
});
Step 9: CI/CD Integration
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: npx playwright install --with-deps
- run: pnpm exec playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Step 10: Package.json Scripts
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:update": "playwright test --update-snapshots"
}
}
Summary
- Test real user flows: navigation, forms, authentication
- Mock API responses for reliable tests
- Visual regression catches unintended UI changes
- Accessibility tests with axe-core integration
- Run in CI with artifact uploads for debugging failures
Need Quality Assurance?
We build tested, reliable web applications with comprehensive quality assurance processes. Contact us to discuss your project.