DEV Community

Styrow.dev
Styrow.dev

Posted on Originally published at styrow.dev

Designing Playwright Tests for Distributed Microservice State Contention

🚨 β€œTwo Playwright workers bought the last itemβ€”and one failed randomly.”
On a 10-worker grid, isolated browser processes can still race on shared Redis. How do you guarantee state isolation without redesigning InventoryService or SessionService?

πŸ“Œ Problem Statement
β€’ Worker isolation does not isolate external state.
❌ Global inventory/session keys create cross-test coupling.
βœ… One global key set cannot guarantee isolation: namespace or shard.

πŸ’‘ Solution & Code Walkthrough
β€’ Derive project + workerIndex + testId as a unique tenant.
β€’ Send X-Test-Namespace; scope keys and sessions via gateway/service adapter.
β€’ Seed before each test; delete only that tenant with afterEach.always.
β€’ Use dedicated test Redis. Run stock check β†’ DECR β†’ session SET NX as one Lua transaction; rollback DECR if SET fails.

import { test as base, expect } from '@playwright/test';
export const test = base.extend({
  tenant: async ({}, use, ti) => {
    const tenant = `${ti.project.name}-${ti.workerIndex}-${ti.testId}`;
    await use(tenant);
  },
});
test.beforeEach(async ({ request, tenant }) =>
  request.put(`/tests/${tenant}`, { stock: 1 }));
test.afterEach.always(async ({ request, tenant }) =>
  request.delete(`/tests/${tenant}`));
test('last item reserves once', async ({ request, tenant }) => {
  // Service invariant: one Lua transaction; no GET/DECR split.
  const reserve = (id: string) => request.post('/checkout/reserve', {
    headers: { 'X-Test-Namespace': tenant },
    data: { sku: 'SKU-42', orderId: id },
  });
  expect((await reserve(crypto.randomUUID())).ok()).toBe(true);
  expect((await reserve(crypto.randomUUID())).ok()).toBe(false);
});
Enter fullscreen mode Exit fullscreen mode

πŸ”‘ Key Takeaways
βœ… State isolation is a first-class fixture.
βœ… Cleanup is namespaced and always runs.
βœ… Atomic writes remove races; retries mask them.
βœ… No namespace? Shard by SKU or isolate each worker’s cache.

❓ Quick Summary Q&A
Q: Does worker isolation solve it? A: Noβ€”only processes are isolated.
Q: Services cannot namespace? A: Shard by SKU or isolate each worker’s cache.

TAGS: playwright, typescript, redis, microservices
────────────────────────────────────────
πŸ“± Download the automation guide
App Store & Google Play: ────────────────────────────────────────

πŸ“² 𝐅𝐑𝐄𝐄 πŒπŽππˆπ‹π„ 𝐀𝐏𝐏 β€” πŸ”πŸŽπŸŽ+ 𝐒𝐃𝐄𝐓 𝐐&𝐀𝐬
Practice real-world interview scenarios offline on the free QA Automation & SDET Prep app:

πŸ€– 𝐆𝐨𝐨𝐠π₯𝐞 𝐏π₯𝐚𝐲 (𝐀𝐧𝐝𝐫𝐨𝐒𝐝):
https://play.google.com/store/apps/details?id=com.app.seleniuminterviewquestions&referrer=utm_source%3Ddevto%26utm_medium%3Darticle%26utm_campaign%3Dselenium_20260917

🍎 𝐀𝐩𝐩 π’π­π¨π«πž (π’πŽπ’):
https://apps.apple.com/app/id6786760948?pt=128640464&ct=devto_selenium_20260917&mt=8

────────────────────────────────────────
────────────────────────────────────────

Top comments (1)

Collapse
 
raju_dandigam profile image
Raju Dandigam

@styrow_dev, treating the namespace as a first-class fixture is the right way to make external-state isolation visible. I wouldn’t make afterEach cleanup part of correctness, though, because a worker crash or canceled CI job can skip teardown; a run-scoped lease or TTL plus a sweeper gives orphaned namespaces a bounded lifetime. I’d also issue the two reservation requests concurrently with Promise.all, since sequential calls prove depletion but not contention. How do you make the stock race repeatable enough that the Lua transaction is exercised on every run?