Skip to main content
Many startups run E2E tests against a QA environment that shares a production database and a handful of test accounts. This guide shows you how to write safe, parallelizable Playwright tests under those constraints — and how Stably makes the hard parts easy.

Why This Is Hard

When your QA environment is backed by a production database you cannot:
  • Spin up an isolated database per test run
  • Create unlimited disposable users
  • Drop tables or truncate data as cleanup
But you still need tests that run in parallel without stepping on each other, clean up after themselves reliably, and never touch real customer data.
With a production-connected DB, treat every shared user and shared workspace as read-only unless a test creates a dedicated child entity first.

The Core Contract

Every test must follow four rules:
1

Create its own data

Tests create the entities they need, prefixed with e2e-<uuid> for identification.
2

Mutate only what it created

Never edit or delete baseline entities like shared workspaces, users, or billing settings.
3

Clean up by exact IDs

Delete only by the specific IDs the test created — never by broad filters.
4

Schedule a safety net

Use Stably Scheduler to sweep stale e2e-* records nightly.

Shared Accounts = Identity Only

Reuse pre-created accounts for sign-in. Never modify account settings in parallel tests.

Data Is Test-Owned

Every entity a test creates is namespaced with e2e-<uuid>, making it safe to delete.

Cleanup Is Deterministic

Delete by stored IDs in afterEach, not broad filters that could match real data.

Scheduler Sweeps Stragglers

A nightly Stably schedule runs a cleanup project that removes stale e2e-* data older than 24 hours.

Playwright Implementation

1. Reuse auth state for shared accounts

Create a setup project that logs in once and saves auth state. This avoids repeated login flows and keeps shared accounts as read-only identity providers.
auth/setup-auth.ts
import { test as setup } from '@playwright/test';

setup('login as qa shared account', async ({ page }) => {
  await page.goto(process.env.BASE_URL!);
  await page.getByLabel('Email').fill(process.env.E2E_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.E2E_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.context().storageState({ path: 'playwright/.auth/qa-user.json' });
});
Store credentials as Stably environment variables so you can manage E2E_USER_EMAIL and E2E_USER_PASSWORD per environment without touching code. Run tests with npx stably test --env QA to inject the right credentials automatically.

2. Namespace + cleanup tracker fixture

This fixture gives every test a collision-resistant namespace and tracks cleanup functions that run automatically after each test.
tests/fixtures/test-data.ts
import { test as base } from '@playwright/test';
import { randomUUID } from 'crypto';

type CleanupFn = () => Promise<void>;

type TestDataFixture = {
  namespace: string;
  trackCleanup: (fn: CleanupFn) => void;
};

export const test = base.extend<TestDataFixture>({
  namespace: async ({}, use, testInfo) => {
    const ns = [
      'e2e',
      randomUUID(),
      testInfo.project.name,
      String(testInfo.workerIndex),
    ].join('-');

    await use(ns);
  },

  trackCleanup: async ({}, use) => {
    const cleanupFns: CleanupFn[] = [];
    await use((fn: CleanupFn) => { cleanupFns.push(fn) });

    // Run cleanup in reverse order (LIFO)
    for (const fn of cleanupFns.reverse()) {
      try {
        await fn();
      } catch (error) {
        console.error('cleanup failed', error);
      }
    }
  },
});

export { expect } from '@playwright/test';
The UUID in the namespace prevents collisions even when Stably Cloud scales your tests to many parallel workers. ULID is also a good option if you want sortable IDs.

3. Use shared workspace, mutate only child data

The shared workspace acts as a read-only container. Tests create child resources inside it with the e2e- prefix and only ever touch those.
tests/e2e/projects.spec.ts
import { test, expect } from '../fixtures/test-data';

test.use({ storageState: 'playwright/.auth/qa-user.json' });

test('creates a child project in shared workspace safely', async ({ page, namespace, trackCleanup }) => {
  const workspaceId = process.env.E2E_SHARED_WORKSPACE_ID!;
  const projectName = `e2e-${namespace}`;

  await page.goto(`/workspaces/${workspaceId}/projects`);
  await page.getByRole('button', { name: 'New project' }).click();
  await page.getByLabel('Project name').fill(projectName);
  await page.getByRole('button', { name: 'Create' }).click();

  const projectId = await getProjectIdByName(page.request, workspaceId, projectName);
  trackCleanup(async () => {
    await deleteProjectById(page.request, workspaceId, projectId);
  });

  await expect(page.getByText(projectName)).toBeVisible();
});

async function getProjectIdByName(
  request: import('@playwright/test').APIRequestContext,
  workspaceId: string,
  name: string,
) {
  const res = await request.get(
    `/api/internal/workspaces/${workspaceId}/projects?name=${encodeURIComponent(name)}`,
  );
  const body = await res.json();
  return body.items[0].id as string;
}

async function deleteProjectById(
  request: import('@playwright/test').APIRequestContext,
  workspaceId: string,
  id: string,
) {
  await request.delete(`/api/internal/workspaces/${workspaceId}/projects/${id}`);
}

4. Serialize shared-state mutation tests

If a test must change workspace-level settings (billing, members, global toggles), isolate it in a serial suite so it never runs in parallel with other tests.
import { test } from '@playwright/test';

test.describe.configure({ mode: 'serial' });

Running with Stably

Organize with Playwright projects

Separate your E2E tests from your cleanup project so they can be triggered independently.
playwright.config.ts
import { defineConfig } from '@stablyai/playwright-test';

export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: '/auth/setup-auth.ts',
    },
    {
      name: 'e2e',
      dependencies: ['setup'],
      testDir: 'tests/e2e',
      use: { storageState: 'playwright/.auth/qa-user.json' },
      stably: {
        notifications: {
          slack: {
            channelName: '#test-results',
            notifyOnResult: 'failures-only',
          },
        },
      },
    },
    {
      name: 'cleanup',
      testDir: 'tests/cleanup',
      workers: 1,
      retries: 0,
    },
  ],
});
Using defineConfig from @stablyai/playwright-test lets you configure Stably notifications per project while staying fully Playwright-compatible.

Run on Stably Cloud

With the namespace fixture preventing collisions, you can safely scale parallelism. Start conservative and increase workers as you confirm tests stay collision-free.
# Start with a few workers
npx stably cloud test --project=e2e --workers=10

# Scale up after collision-free runs
npx stably cloud test --project=e2e --workers=50
Stably Cloud supports up to 100 parallel workers, so once your data strategy is solid you can get fast feedback even on large suites.

Schedule cleanup as a safety net

Per-test cleanup should be your primary defense. But tests can crash, workers can be killed, and cleanup code can fail. Add a cleanup Playwright project that sweeps stale e2e-* records, and schedule it with Stably. Cleanup project:
tests/cleanup/stale-e2e-data.spec.ts
import { test, expect } from '@playwright/test';

test('delete stale e2e data older than 24h', async ({ request }) => {
  const res = await request.post('/api/internal/cleanup/e2e', {
    data: {
      prefix: 'e2e-',
      olderThanHours: 24,
    },
  });

  expect(res.ok()).toBeTruthy();
});
Nightly schedule in stably.yaml:
stably.yaml
schedules:
  nightly-e2e-cleanup:
    cron: "0 3 * * *"
    stablyTestArgs: "--project cleanup"
    timezone: "America/Los_Angeles"
    autofix: true
Setting autofix: true means if the cleanup test itself breaks (for example, due to an API change), Stably’s fix agent will automatically diagnose and repair it. You can also create and edit schedules visually from the Stably Scheduler UI.

Monitor with alerts

Configure Slack or email notifications so your team knows immediately if cleanup fails or E2E tests start flaking:
stably.yaml
schedules:
  nightly-e2e-cleanup:
    cron: "0 3 * * *"
    stablyTestArgs: "--project cleanup"
    timezone: "America/Los_Angeles"
    autofix: true

  morning-e2e-suite:
    cron: "0 9 * * 1-5"
    stablyTestArgs: "--project e2e --workers 30"
    timezone: "America/Los_Angeles"
Failed runs surface in the Stably dashboard with screenshots, traces, and AI-powered failure analysis — making it easy to tell whether a failure is a real bug, a stale data issue, or a test that needs updating.

Guardrails

  • Prefix all test-created names with e2e-<uuid>
  • Delete by exact IDs stored during the test, never by broad queries
  • Keep one manual emergency cleanup command (npx stably test --project cleanup)
  • Increase workers gradually after confirming no collisions
  • Use Stably environments to manage credentials per environment
  • Parallel write tests against the same shared entity
  • Reusing mutable fixture records across test files
  • Relying only on nightly cleanup (always clean per test first)
  • Depending on test execution order
  • Hardcoding customer IDs in tests

Rollout Plan

1

Add the namespace + cleanup tracker fixture

Start with the fixtures above. No existing tests break since the fixture is opt-in.
2

Convert your flakiest mutable tests

Pick 3-5 tests that create or modify shared data and convert them to the create-and-own pattern.
3

Add the cleanup project and schedule it

Wire up the cleanup project in playwright.config.ts and add a nightly schedule in stably.yaml.
4

Serialize shared-state mutation tests

Move tests that must change workspace settings into test.describe.configure({ mode: 'serial' }).
5

Scale parallelism on Stably Cloud

Increase workers gradually via npx stably cloud test --workers=N. Monitor the Stably dashboard for collisions or cleanup failures.