Back to writing

Reliable E2E Tests with Playwright and Prisma

January 28, 2023

End-to-end tests get flaky fast when a real database is involved. Reliable suites start with isolated state and predictable execution.

End-to-end tests are valuable because they behave more like real usage.

That is also why they get flaky so easily.

The moment a real database enters the picture, your tests stop being pure function calls. They start depending on persistent state, ordering, and timing. That is where trust in the suite usually starts to break down.

In practice, two problems cause most of the pain:

  • leftover database state
  • tests stepping on each other in parallel

Problem 1: state leaks between tests

If one test creates a user and another test assumes that user does not exist, your suite now depends on execution order.

That is enough to make failures feel random.

For smaller projects, the most practical fix is often the least glamorous one: clear the database before each test and reseed it into a known shape.

export async function cleanup() {
  await prisma.$transaction([
    prisma.comment.deleteMany(),
    prisma.post.deleteMany(),
    prisma.user.deleteMany(),
  ]);
}

Then use it in beforeEach:

test.beforeEach(async () => {
  await cleanup();
  await seed();
});

That gives every test the same starting point.

It is not the only possible strategy, but it is a very effective default when you care more about trust than cleverness.

Why seeding matters as much as cleanup

Cleanup alone is not enough.

Most meaningful tests need a known world to run against: users, roles, records, permissions, feature flags, whatever the app actually depends on. A clean database with no predictable baseline is still a weak test environment.

That is why I like explicit seed data in test setup. It makes the test world intentional instead of accidental.

Problem 2: parallel tests and shared state

Playwright runs tests in parallel by default, which is great for speed.

It is less great when multiple tests mutate the same database at the same time.

Once that happens, one test can invalidate another test's assumptions mid-run. You now have race conditions, and race conditions are exactly the kind of problem that makes people stop trusting end-to-end suites.

The blunt fix is often the correct one:

const config: PlaywrightTestConfig = {
  workers: 1,
};

That is not a glamorous solution, but it is a pragmatic one when your tests share real persistence and you do not yet have proper isolation per worker.

The tradeoff people get backward

The common mistake is optimizing for speed before optimizing for trust.

That produces a fast suite that nobody believes.

I would much rather have a slower suite that fails honestly than a fast one that randomly lies. Once the suite is reliable, you can look for better isolation strategies, parallel-friendly fixtures, or test database sharding.

But speed is a second move.

A useful default stance

If your E2E suite touches a real database:

  • start each test from known state
  • reseed deliberately
  • avoid parallelism until you can isolate it safely

That baseline is boring, but boring is good in test infrastructure.

The whole point of the suite is confidence. Build that first, then make it faster.