Playwright is the E2E testing framework we use across all 47Network Studio QA engagements. It's replaced Cypress for most of our work โ cross-browser support that actually works, a much better async model, network interception that's genuinely useful, and a trace viewer that makes debugging test failures in CI tolerable. This post covers the architecture decisions that keep Playwright suites maintainable at scale: the page object model, authentication fixtures that don't slow down every test, parallel execution configuration, and CI integration that doesn't flake.
Project structure
tests/
fixtures/ # Shared test setup and teardown
auth.fixture.ts # Authenticated page contexts
db.fixture.ts # Database seeding and cleanup
pages/ # Page Object Model classes
login.page.ts
checkout.page.ts
dashboard.page.ts
specs/ # Test files
auth/
login.spec.ts
registration.spec.ts
checkout/
happy-path.spec.ts
payment-failure.spec.ts
helpers/ # Utilities, API clients, test data factories
api.ts
factories.ts
playwright.config.ts
The page object model: done right
A page object wraps the selectors and actions for a page so tests read like user stories, not CSS selector soup. The key principle: page objects return other page objects โ actions that navigate return the page you arrive on.
// pages/checkout.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
import { OrderConfirmationPage } from './order-confirmation.page';
export class CheckoutPage {
readonly page: Page;
// Locators โ use role/label/text selectors over CSS IDs where possible
// These survive UI refactors better than CSS selectors
readonly emailInput: Locator;
readonly cardNumber: Locator;
readonly placeOrder: Locator;
readonly errorBanner: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByRole('textbox', { name: 'Email address' });
this.cardNumber = page.getByLabel('Card number');
this.placeOrder = page.getByRole('button', { name: 'Place order' });
this.errorBanner = page.getByRole('alert');
}
async fillEmail(email: string) {
await this.emailInput.fill(email);
}
async fillCard(number: string, expiry: string, cvc: string) {
await this.cardNumber.fill(number);
await this.page.getByLabel('Expiry').fill(expiry);
await this.page.getByLabel('CVC').fill(cvc);
}
async submitOrder(): Promise {
await this.placeOrder.click();
return new OrderConfirmationPage(this.page);
}
async expectError(message: string) {
await expect(this.errorBanner).toContainText(message);
}
}
Authentication fixtures: one login per test file, not per test
The most common Playwright performance problem is logging in before every test. With 80 tests that each take 2โ3 seconds to authenticate, you've added 3โ4 minutes to your suite run for no reason. Use storageState to authenticate once per role and reuse the session:
// fixtures/auth.fixture.ts
import { test as base, type Page } from '@playwright/test';
import * as path from 'path';
// Auth state files โ generated once per suite run
const STORAGE = {
admin: path.join(__dirname, '../.auth/admin.json'),
customer: path.join(__dirname, '../.auth/customer.json'),
};
// Global setup โ runs once before all tests
// playwright.config.ts: globalSetup: './fixtures/global-setup.ts'
export async function globalSetup() {
const { chromium } = await import('@playwright/test');
const browser = await chromium.launch();
for (const [role, file] of Object.entries(STORAGE)) {
const page = await browser.newPage();
await page.goto('https://staging.example.com/login');
await page.getByLabel('Email').fill(`${role}@test.example.com`);
await page.getByLabel('Password').fill(process.env[`TEST_${role.toUpperCase()}_PASSWORD`]!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: file });
await page.close();
}
await browser.close();
}
// Fixture that injects an authenticated page
export const test = base.extend<{
adminPage: Page;
customerPage: Page;
}>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: STORAGE.admin });
const page = await context.newPage();
await use(page);
await context.close();
},
customerPage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: STORAGE.customer });
const page = await context.newPage();
await use(page);
await context.close();
},
});
Network interception: testing without external dependencies
Payment processors, email providers, and third-party APIs shouldn't be called in E2E tests. Mock them at the network level:
// specs/checkout/payment-failure.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
test('shows error when card is declined', async ({ customerPage }) => {
// Intercept the payment API call before it happens
await customerPage.route('**/api/payments', async (route) => {
await route.fulfill({
status: 402,
contentType: 'application/json',
body: JSON.stringify({
error: 'card_declined',
message: 'Your card was declined.',
}),
});
});
await customerPage.goto('/checkout');
// ... fill checkout form ...
const confirmPage = await checkoutPage.submitOrder();
// Route intercept catches the payment call before it hits Stripe
await checkoutPage.expectError('Your card was declined.');
});
Parallel execution and sharding
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/specs',
globalSetup: './tests/fixtures/global-setup.ts',
// Run test files in parallel โ workers = CPU cores by default
fullyParallel: true,
workers: process.env.CI ? 4 : undefined, // Limit to 4 in CI to avoid rate limits
// Retry flaky tests once in CI, never locally
retries: process.env.CI ? 1 : 0,
// Fail fast in CI if too many tests fail
maxFailures: process.env.CI ? 10 : undefined,
reporter: [
['html'], // Local: open with 'npx playwright show-report'
['junit', { outputFile: 'test-results/junit.xml' }], // For CI artifact upload
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry', // Capture trace on first retry for debugging
video: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
// Skip Firefox in CI to save time โ run it nightly
...(process.env.CI ? [] : [{ name: 'firefox', use: { ...devices['Desktop Firefox'] } }]),
],
});
Sharding for large suites: when your suite grows past 200 tests, use Playwright's built-in sharding to split across multiple CI workers: npx playwright test --shard=1/4 runs the first quarter of tests. In GitHub Actions, run 4 parallel jobs with strategy: matrix: shard: [1, 2, 3, 4]. Merge the HTML reports with npx playwright merge-reports in a final job.
In 47Network Studio QA engagements a typical Playwright suite covers: authenticated user flows (login, registration, password reset), core business flows (checkout, form submissions, data exports), API contract tests via page.request, and accessibility checks via @axe-core/playwright. The SaaS platform engagement delivered 87 test cases in this structure, running in under 8 minutes on 4 parallel workers.