Endform logo

Why most Playwright setups fail

OS
Written by Oliver Stenbom
A playwright building a castle in the sand

Prepare yourselves, we will be here for a while

End-to-end testing your web application isn’t something you do for a day or for a week. It’s a process you gradually adopt, until it becomes a vital part of how you ship software. Playwright is the best tool out there for end-to-end testing in a reliable and efficient way - but even the best tools used in the wrong way can have disastrous outcomes.

We believe that a great Playwright setup will pay for itself many times over in the long run. You don’t want to feel like a slow and flaky test suite is making you less productive as a software engineer. Quite the opposite. We want a suite like this to be speeding us up!

What bad setups feel like

If you feel like you are slowly losing trust in your end-to-end testing suite, it could be because of your setup. Here are some classic signs:

  • Flakes due to other tests interfering with the same test data / user state
  • Tests that fail on retries because of dirty state from previous runs
  • Tests that need an excessive amount of time to run due to many UI based setup steps
  • Previously solved test failures that pop up in other tests

If engineers don’t believe that test suite failures indicate that the application is actually broken, they will normally keep retrying the suite for hours before actually debugging the issue. What a waste!

Here is our greatest advice for creating great Playwright setups, collected over years of running Playwright in the wild.

API-driven test data

The single most important thing that you can do for your Playwright setup long term, is to have the ability to create test data on demand from an API. Let’s talk about why this matters.

Here’s your typical first Playwright test:

import { test } from '@playwright/test';

test('login', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.getByRole('textbox', { name: 'Username' }).fill('admin');
  await page.getByRole('textbox', { name: 'Password' }).fill('password');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.waitForURL('https://example.com/dashboard');
});

Two weeks later we decide that we want to also test the checkout.

import { test } from '@playwright/test';

test('checkout', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.getByRole('textbox', { name: 'Username' }).fill('admin');
  await page.getByRole('textbox', { name: 'Password' }).fill('password');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.waitForURL('https://example.com/checkout');
  await page.getByRole('textbox', { name: 'Card number' }).fill('1234567890123456');
  await page.getByRole('textbox', { name: 'Expiration' }).fill('12/25');
  // other checkout steps and assertions
});

Three weeks later we add three more tests to test the team invite flow, the logout flow and the delete user flow.

A visual overview of the proportion of time spent logging in in a simple test setup.

We’re now spending more than half of our time testing the login flow in our test suite. As we begin to need more data associated with our users, or things like several users to test collaboration, the proportion of time that we spend just setting things up instead of meaningfully testing our application completely takes over.

The cheapest way around this is to continue to use the UI, but at least to be able to share the initial state between tests. This is normally done with a setup project in Playwright combined with the storageState option.

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  use: {
    storageState: ".auth/user.json",
  },
  projects: [
    {
      name: "setup",
      testMatch: "setup.spec.ts",
    },
    {
      name: "main project",
      testIgnore: ["setup.spec.ts"],
      dependencies: ["setup"],
      use: {
        ...devices["Desktop Chrome"],
      },
    },
  ],
});

In our experience this is a good quick fix but it doesn’t hold as we start to scale our suite. Already now with the delete user test, it’s likely that we will accidentally delete our user while one of the other tests is still using it. Instead, learning to create and use API endpoints to create more useful kinds of test data will make your suite much easier to extend.

A common way of using API endpoints like this are through fixtures.

import { test } from '@playwright/test';
import { createE2EUser, createSubscription } from './utils';

export const testWithNewUser = test.extend<{
  newUser: void;
}>({
  newUser: [
    async ({ baseURL, page }, use) => {
      // Calls the API to create a new user
      const { cookies, user } = await createE2EUser();

      // Calls the API to create a pro subscription for the user
      await createSubscription(user, { plan: 'pro' });

      await page.context().addCookies(cookies);
      await use();
    },
    { auto: true },
  ],
});

testWithNewUser('check pro features', async ({ page, newUser }) => {
   // test that pro features work as expected
});

When you start to use an API more extensively for creating test data, your suite will start to feel more like this:

A visual overview of the proportion of time spent logging with an API-driven test setup.

The key here is the existence of the API endpoints that make it possible for engineers to mix and match test data in order to spend more time writing meaningful tests and less time debugging UI that has already been tested. Using this kind of setup won’t just make your test suite feel more reliable, but it should also make it considerably faster.

Propagating shared learnings

Once you start to scale your test suite beyond four or five tests, you will start to find that even with decent API endpoints for creating test data on demand, you still find yourself repeating the same steps and fixing the same problems in many places.

Let’s highlight some of the most common pitfalls in this space.

Stable environments with fixtures

One random Thursday, your test suite started failing because a third party advertising provider’s servers were down. Very frustrating, since there was nothing wrong with the product and the problem was completely out of your hands.

But you’re good at reading Playwright’s documentation on best practices, so you added a few intercepts for the third party provider.

await page.route('**/api/fetch_data_third_party_dependency', route => route.fulfill({
  status: 200,
  body: testData,
}));
await page.goto('https://example.com');

Two weeks later, the same thing happens again and your suite’s failing. Why the deja vu?

Problems like these that affect your test suite globally are great to fix at a fixture level, instead of within individual tests. A common pattern we like to use is a “mother fixture”.

export const test = base.extend<{
  ignoreThirdPartyDependency: void;
}>({
  ignoreThirdPartyDependency: [async ({ page }, use) => {
    await page.route('**/api/fetch_data_third_party_dependency', route => route.fulfill({
      status: 200,
      body: testData,
    }));
    await use();
  }, {
    auto: true,
    box: true, // don't report this as a separate step in traces and reports
  }],
});

If all of our tests use or extend from this base or “mother” fixture, then it means that we have a single place to add knowledge around how our environment works.

import { test } from 'mother-test.ts';

test('normal test', async ({ page }) => {
  await page.goto('https://example.com');
});

Base fixtures like this are also a great place to add helper functions that can be used by tests that need them.

Reproducible steps with page objects

One common cause of flaky tests that we see are dependencies between Playwright steps that aren’t explicitly declared.

await page.getByRole('button', { name: 'Buy product' }).click();
await page.waitForRequest('https://example.com/api/products/1');

Since it’s not exactly clear how long the click action takes, we might not be waiting for the expected request at the right time.

A great way of fixing this is using Promise.all:

await Promise.all([
  page.getByRole('button', { name: 'Buy product' }).click(),
  page.waitForRequest('https://example.com/api/products/1'),
]);

Now the ordering doesn’t matter! As long as both things happen we can move on.

Now imagine that we’ve just figured that out, but buying the product is something we already do in 15 tests. We sure can do a lot of copy-pasting, but that doesn’t mean that the next person to write a test will remember to do the same thing.

Page object models are a popular way of creating an abstract representation of pages and the actions that can be taken on them.

export class ProductPage {
  constructor(private page: Page) {}

  async buyProduct() {
    await Promise.all([
      this.page.getByRole('button', { name: 'Buy product' }).click(),
      this.page.waitForRequest('https://example.com/api/products/1'),
    ]);
  }
}

The most popular reason behind using them is to avoid duplicating locators for elements across many tests. That’s great, but I think that the most important reason to use page object models is to be able to share knowledge about our expectations on pages across tests, and there’s much more that describes the state of the world than a locator.

Wrapping up

It’s easy to write very simple tests that are harder to maintain in the long run. Early on, it feels like a burden to have to create a sophisticated setup for just a few tests. But what starts as a minor inconvenience becomes a productivity killer that will erode trust in your testing strategy over time.

So work on stability from day one. Invest in API driven test data and learn to use fixtures and page objects correctly early on. We have found that good setups using these methods haven’t just helped us ship faster, but also helped us to discover and fix many application bugs. End-to-end testing isn’t just a symbolic act. We are firm believers that developing your application and test suite in tandem meaningfully improves your product.

Start with the foundations. Your future self will thank you.


Endform is an end-to-end test runner for Playwright tests. If you’re interested in running your e2e tests faster than anywhere else, try running your suite with us!

⚡ Speed up your E2E tests

Endform runs your entire Playwright suite in parallel. What used to take minutes now takes seconds.

Get started for free → Trial includes 2000 free test minutes. No credit card required.

Frequently Asked Questions

What is Endform?
Endform runs browser based end to end tests for web applications quickly and reliably. We target the end to end testing framework Playwright.
How do I get started with Endform?
Getting started with Endform is easy! Just switch out one CLI command and you are up and running. We are fully Playwright compatible - no configuration changes needed.
How does Endform work?
Endform distributes your Playwright tests across hundreds of machines in the cloud. We run one test per machine, and coordinate the collection of results. This way your test suite finishes in the fastest possible time, while letting you focus on writing tests instead of managing infrastructure.
How fast is Endform compared to other runners?

Endform runs Playwright tests significantly faster than traditional runners by utilizing full parallelization and a highly optimized runtime.

We have seen speedups of some test suites of over 20x, and we can run most test suites in under 2 minutes.

Do you support other test frameworks than Playwright?
No. As of today we only support running Playwright tests. This lets us focus on providing the best possible experience for Playwright users. In the future we may consider adding support for other frameworks.