How to Build a Scalable Playwright Framework for Microservices

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:

  • API-first tests for each service (fast, isolated)
  • Thin UI flows over the top (critical journeys only)
  • Contract checks at the seams (prevent integration drift)


Core Principles (the “non-negotiables”)

  1. Test the microservice, not the universe Keep 80–90% of tests within service boundaries. Cross-service E2E = smoke only.
  2. Own your dependencies Intercept calls, stub neighbors, or spin ephemeral deps with Testcontainers. Flakiness is often just “someone else’s service.”
  3. Contract > Integration CDC (Pact) or schema checks stop breakages before they hit E2E.
  4. Idempotent, hermetic tests Data setup/teardown via APIs or fixtures. No hidden state. No “run twice to pass.”
  5. Parallelism and sharding by design Write tests so they can run anywhere, in any order, all at once.


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

  • Projects let you run npx playwright test --project=billing-service-api in isolation.
  • Tags (via test.describe.configure({ mode: 'parallel' }) + test('@smoke', ...)) power fast lanes: smoke, critical, canary.


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.


Contract Testing at the Seams (Optional but Powerful)

  • Producers publish contract (OpenAPI or Pact).
  • Consumers verify they can parse and use producer responses.
  • Gate CI: if a producer breaks the contract, fail the PR before E2E feels the pain.

(Whether you use Pact, OpenAPI schemas, or Protobufs—pick one and enforce it.)


Data Strategy That Doesn’t Rot

  • Seed via API (preferred) or fixture JSON for known states.
  • Deterministic fake data (seeded Faker) so reruns are predictable.
  • Ephemeral entities: name them with a run-id (e.g., billing-e2e-<shortSha>).
  • Cleanup with afterEach/afterAll or TTL jobs for orphaned data.


Speed: Parallelism, Sharding, and Test Buckets

  • Parallel by default: fullyParallel: true, stateless tests.
  • Shard in CI: npx playwright test --shard=${{ matrix.shard }}/${{ env.TOTAL_SHARDS }}
  • Split by tag: run @smoke on PR, @critical nightly, full suite on release.
  • Retries only on CI to mask infra blips, not bugs.


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

  • 12-factor your test config: process.env.* only.
  • Separate .env per environment (local, staging, prod-like).
  • Tokens via fixtures, not hard-coded.
  • Feature flags? Version them. Tests should assert flagged behavior explicitly.


Observability for Tests (yes, really)

  • Playwright Traces on retry: gold for debugging.
  • Annotate tests with stable IDs and business tags (@billing, @refunds).
  • Export timings to your metrics stack (simple post-job script) to spot regressions.


Local Dev UX: make it easy

  • npm run test:user:api -g @smoke
  • npm run test:ui --headed --debug
  • One command to bring up deps (Docker Compose / Testcontainers).
  • Pre-commit hook for eslint, tsc --noEmit, and npx playwright test -g @lint (ultra-fast lint checks).


What to Test Where (the Test Pyramid That Actually Works)

  • Service API (fast): validation, status codes, core business rules.
  • UI (thin): 5–10 critical flows, visual sanity on key pages.
  • Cross-service E2E (sparingly): checkout, onboarding, billing—only what makes the CFO sweat.
  • Contracts: every interface that crosses a team boundary.


Common Pitfalls (and blunt fixes)

  • “Flakes” that are actually timeouts: add explicit waits on domain events, not sleeps.
  • Stateful tests: if a test must run first, it’s badly designed.
  • Shared test users: collision city. Generate per-test users.
  • Chonky E2Es: split into service-level APIs + 1 happy-path E2E.

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?

To view or add a comment, sign in

More articles by Prashant Sinha

Others also viewed

Explore content categories