Clean up test data with a Playwright fixture

  • Testing
  • Playwright
  • Auth
  • JavaScript
  • Data

Testing presents many issues. When testing in the browser, we need to get pages full of realistic data in order to mimic user interactions. But sometimes that's easier said than done.

Ideally we want a clean, repeatable environment for our test to run in. That means that it can set up its own data and nothing will get shared between tests. Any failures are purely down to the logic being tested and were not the result of an earlier test leaking in.

At Vidsy, everything centres around a brief. They can have many creators working on them, each submitting multiple videos in all manner of shapes and sizes. Maintaining a stable testing environment with automated tests constantly changing this data would be impossible.

As a result, we have tests setting up their own briefs to test what they need to, but this results in hundreds of useless briefs hanging around our environment no longer serving any use. It would be really handy if we could clean this up as we went along. Thankfully, Playwright fixtures give us a neat way to reset between runs.

Set up and tear down

Fixtures will run on a per-test basis by default. Nothing in one test will stick around into another. This means we can assume each run starts from a clean slate.

Every fixture has a set up phase and a tear down phase. In here you can prepare the environment for a particular test, run the test, and then tidy things up at the end.

export const myFixture = async (use) => {
  // Set up phase

  // Run the test
  await use();

  // Tear down phase
};

The test run happens within the use call. Typically you would pass something through here to the test to make use of the fixture.

For our fixture, we can pass through a function that collects other functions to run. Then once the test is complete we can use the tear down phase to clean everything up.

Setting up

First off, we need to know exactly how we're going to interact with this fixture. We'll be building this in TypeScript, so here's an interface that describes what's going on:

export interface Cleanup {
  add: (cleanupFn: () => Promise<unknown>) => symbol;
  remove: (key: symbol) => void;
}

There's two methods that can be called.

The add method will add a function to a big list of functions to call at the end of the test. This is the clean up work itself. The method returns a symbol, which just acts as a token to more easily look up the function that was passed. The remove method uses that token to easily remove it from the list.

Removal of a clean up function is fairly uncommon. But where we're testing that something has been removed, if the test goes well there will be nothing to clean up.

With that in place, we can set up our fixture.

export const cleanup = async (use: (fixture: Cleanup) => Promise<void>) => {
  const cleanupFns: Map<symbol, () => Promise<unknown>> = new Map();
  // ...
};

cleanupFns is our big list of functions. It's doing nothing more fancy than storing any function that gets passed to it.

Following that, we call use and pass it the two methods described earlier.

await use({
  add: (cleanupFn) => {
    const key = Symbol();
    cleanupFns.set(key, cleanupFn);
    return key;
  },
  remove: (key) => {
    cleanupFns.delete(key);
  },
});

Tearing down

Anything after the use becomes part of the tear down phase. Every test will enter this even if it fails.

await Promise.all(Array.from(cleanupFns).map(([, fn]) => fn()));

It's here that we can then tell the fixture to call each clean up function in the list. It's those functions that deal with the data clean up, rather than anything particular to this fixture. This means we can reuse this fixture across all of our test suite.

Using the fixture

With all that set up, we can start cleaning up data as we go.

Each test will start with some kind of data set up. This can happen in whatever manner is needed. For us, it's a function that runs a set of GraphQL mutations to set up the data we need.

test("cannot save changes when required data is removed", async ({
  cleanup,
  page,
}) => {
  const brief = await createBrief();
  cleanup.add(() => voidBrief(brief.id));
  // ...
});

This test is setting up a brief in order to check if clearing any of its required fields will get caught by our validation. It's specified it's using the cleanup fixture as part of its setup.

createBrief creates the brief we will test with. Once it's been created, we immediately call cleanup.add() to void the brief and remove it from the system. We register this as early as possible to make sure it happens. Were the test to fail somewhere further down it wouldn't reach the clean up step and the fixture doesn't know what to clean up.

Once the test has completed, the tear down phase in the fixture comes to life, runs the function from the queue, and voids the brief. Perfect.

Summary

Fixtures are a simple but flexible part of the Playwright setup. Knowing that they have a dedicated set up and tear down phase allows us to build out useful reusable functionality, such as data clean up, without needing to worry about the overall status of the test.