How to Build a Scalable Playwright Framework for Microservices
Architecture-first testing that won’t fall over when your services do.
Why Playwright for Microservices?
Microservices bring independence, parallel delivery, and… chaos. End-to-end tests that cross too many services become slow, flaky, and political. Playwright’s strengths—fast parallelism, first-class API testing, solid network control, and traceability—make it perfect for a pragmatic, layered approach:
Core Principles (the “non-negotiables”)
Reference Repository Layout
tests/
common/
fixtures/
auth.fixture.ts # tokens, users, orgs
db.fixture.ts # seed/cleanup utilities (if allowed)
utils/
httpClient.ts # typed wrapper around request.newContext()
data.ts # faker-driven builders (deterministic; seedable)
contracts/
user-service.pact.ts # optional CDC producers/consumers
services/
user/
api/
createUser.spec.ts
getUser.spec.ts
ui/
onboarding.spec.ts
mocks/
users.sample.json
billing/
api/
invoice.spec.ts
mocks/
invoices.sample.json
playwright.config.ts
.env, .env.staging, .env.ci
.github/workflows/ci.yml
Playwright Config: Projects = Service Boundaries
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
reporter: [['list'], ['html', { open: 'never' }]],
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
use: {
trace: 'on-first-retry',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
baseURL: process.env.BASE_URL || 'http://localhost:3000',
},
projects: [
{
name: 'user-service-api',
testMatch: /tests\/services\/user\/api\/.*\.spec\.ts/,
use: { baseURL: process.env.USER_API_URL }
},
{
name: 'user-service-ui',
testMatch: /tests\/services\/user\/ui\/.*\.spec\.ts/,
use: { ...devices['Desktop Chrome'], baseURL: process.env.USER_UI_URL }
},
{
name: 'billing-service-api',
testMatch: /tests\/services\/billing\/api\/.*\.spec\.ts/,
use: { baseURL: process.env.BILLING_API_URL }
},
],
// Tag routing for fast lanes
grep: new RegExp(process.env.GREP || ''),
grepInvert: process.env.SKIP ? new RegExp(process.env.SKIP) : undefined,
});
Why this matters
Strong, Typed Fixtures (Auth, Data, Clients)
// tests/common/fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
type Auth = {
token: string;
as: (role: 'admin' | 'member') => Promise<string>;
};
export const test = base.extend<{ auth: Auth }>({
auth: async ({ request }, use) => {
const getToken = async (role: string) => {
const res = await request.post('/auth/token', { data: { role } });
return (await res.json()).access_token as string;
};
await use({
token: await getToken('member'),
as: async (role) => getToken(role),
});
},
});
export const expect = test.expect;
// tests/common/utils/httpClient.ts
import { APIRequestContext, request, expect } from '@playwright/test';
export async function client(baseURL: string, token?: string): Promise<APIRequestContext> {
return await request.newContext({
baseURL,
extraHTTPHeaders: token ? { Authorization: `Bearer ${token}` } : {},
});
}
// tests/common/utils/data.ts
import { faker } from '@faker-js/faker';
faker.seed(42);
export const userBuilder = (overrides: Partial<{ name: string; email: string }> = {}) => ({
name: overrides.name ?? faker.person.fullName(),
email: overrides.email ?? faker.internet.email().toLowerCase(),
});
Example: API Test (fast, isolated)
// tests/services/user/api/createUser.spec.ts
import { test, expect } from '../../../common/fixtures/auth.fixture';
import { client } from '../../../common/utils/httpClient';
import { userBuilder } from '../../../common/utils/data';
test.describe.configure({ mode: 'parallel' });
test('creates a user and fetches it by id @smoke @user', async ({ auth }) => {
const api = await client(process.env.USER_API_URL!, auth.token);
const payload = userBuilder();
const create = await api.post('/users', { data: payload });
expect(create.ok()).toBeTruthy();
const { id } = await create.json();
const get = await api.get(`/users/${id}`);
expect(get.ok()).toBeTruthy();
const body = await get.json();
expect(body.email).toBe(payload.email);
});
Example: UI Test with Network Control
// tests/services/user/ui/onboarding.spec.ts
import { test, expect } from '@playwright/test';
test('onboarding shows welcome banner for new accounts @critical @ui', async ({ page }) => {
await page.route('**/api/users/*', route => {
// return stable, local data
route.fulfill({ json: { id: 'u-123', name: 'Asha', email: 'asha@example.com' } });
});
await page.goto('/');
await page.getByRole('button', { name: /get started/i }).click();
await expect(page.getByText(/welcome, asha/i)).toBeVisible();
});
Why intercept? Deterministic UI, no waiting for five other services to behave. Keep cross-service E2E for one or two true business-critical flows.
Recommended by LinkedIn
Contract Testing at the Seams (Optional but Powerful)
(Whether you use Pact, OpenAPI schemas, or Protobufs—pick one and enforce it.)
Data Strategy That Doesn’t Rot
Speed: Parallelism, Sharding, and Test Buckets
CI/CD You Can Grow Into (GitHub Actions snippet)
# .github/workflows/ci.yml
name: e2e
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
project: [user-service-api, user-service-ui, billing-service-api]
shard: [1,2] # 2-way sharding per project
env:
BASE_URL: ${{ secrets.BASE_URL }}
USER_API_URL: ${{ secrets.USER_API_URL }}
USER_UI_URL: ${{ secrets.USER_UI_URL }}
BILLING_API_URL: ${{ secrets.BILLING_API_URL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx playwright install --with-deps
- name: Run tests
run: |
npx playwright test \
--project=${{ matrix.project }} \
--shard=${{ matrix.shard }}/2 \
--reporter=line,html
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: report-${{ matrix.project }}-${{ matrix.shard }}
path: playwright-report
Environment & Secrets
Observability for Tests (yes, really)
Local Dev UX: make it easy
What to Test Where (the Test Pyramid That Actually Works)
Common Pitfalls (and blunt fixes)
Prashant, this framework looks solid. It seems to assume that microservice boundaries remain relatively stable over time. I'm curious, how do you handle scenarios where services frequently merge, split, or change ownership? Does the testing architecture need significant restructuring when those boundaries shift?