Supabase E2E Testing Made Easy With Supawright

Finally, some peace of mind.

Photo by Lidia Nemiroff on Unsplash

End-to-end testing is a nightmare. No, seriously, it’s one of my least favourite things to work on when I’m doing frontend or full-stack work. But they’re so. Darn. Useful. I’m not going to go into the benefits of end-to-end testing, but most of the time it’s the only type of testing I really do.

However, one of the trickiest parts of E2E testing a full-stack web app is managing database records. Tests will often need dummy data such as user accounts, transaction records, etc. etc., and can also create records during the test runs themselves.

It’s good practice to make sure these records are cleaned up at the end of the test cycle to avoid cluttering local databases and to allow you to run your tests repeatedly against long-running remote databases if needs be.

Creating the records you need is generally quite easy, though you can end up with a lot of boilerplate if you have to create lots of records in foreign tables (e.g. you need to create an order but you need a load of products first).

The real challenge comes when tracking the records that the test creates along the way. As your database grows, you might introduce new tables that get automatically populated (think: event tables) and suddenly you’re adding more code to every test do clean up these new records.

Sounds like my idea of hell.

Anyway, I got fed up of doing this all the time in code on my own projects and at work, so I wrote a little library — Supawright — that automates this process for my normal tech stack: Supabase for the backend and Playwright for E2E testing.

Supabase is a backend-as-a-service tool that competes with the likes of Firebase and AWS Amplify, but with the key benefit of being open source. Supabase is awesome, and I use it all the time. If you’re interested in why I use it both at work and for personal projects, check out this article about the tech stack I use at the early-stage startup I’m currently building.

Playwright is a brilliant open-source test suite that I’m sure I don’t need to gush about now. Long story short, I use it for the majority of my testing.

That’s enough rambling. Let’s get to the good stuff.

The good stuff

Supawright is a Playwright test harness for E2E testing with Supabase.

Supawright can create database tables and records for you, and will clean up after itself when the test exits. It will create records recursively based on foreign key constraints, and will automatically discover any related records that were not created by Supawright and delete them as well.

Getting set up

To get setup, install Supawright with your favourite package manager:

pnpm i -D supawright

You’ll then have to (unfortunately) make some alterations to Supabase’s generated TypeScript types (we need a type not an interface), so change the following line wherever you keep yours (typically database.ts):

\- export interface Database {
\+ export type Database = {

I recommend setting up a make target (or similar) to automatically make this change for you whenever you update your types, e.g.:

types:
  pnpm supabase gen types typescript --local | \\
  sed 's/export interface Database {/export type Database = {/' \\
  > src/types/database.ts

Then, in your test file, create a test function using withSupawright. You’ll use this instead of the test function that Playwright provides.

import { withSupawright } from 'supawright'
import type { Database } from './database'

const test = withSupawright<Database, 'public' | 'other'>(['public', 'other'])

In the example above, we’re telling Supawright that we care about the public and other schemas in the database. Make sure you expose these schemas so the Supabase client can access them.

The first test

Assuming you have a test function as above, you can now write tests and use the supawright fixture to recursively create database tables. Consider the following table structure:

create table public."user" (
  id uuid primary key default uuid_generate_v4(),
  email text not null unique,
  password text not null
);

create table public.session (
  id uuid primary key default uuid_generate_v4(),
  user_id uuid not null references public."user"(id),
  token text,
  created_at timestamp with time zone not null default now()
);

If you use Supawright to create a session, it will automatically create a user for you, and you can access the user’s id in the session’s user_id column. Supawright will also automatically generate fake data for any columns that are not nullable and do not have a default value.

test('creates a user', async ({ supawright }) => {
  const session = await supawright.create('public', 'session')
  expect(session.user_id).toBeDefined()
})

You can optionally pass a data object as the second argument to the create function to override the fake data that is generated. If you pass in data for a foreign key column, Supawright will not create a record for that table.

If your table is in the public schema, you can omit the schema name.

test("doesn't create user", async ({ supawright }) => {
  const user = await supawright.create('user', {
    email: 'some-email@supawrightmail.com',
  })
  const session = await supawright.create('session', {
    user_id: user.id,
  })

  // Supawright will not create a user record, since we've passed in
  // a user_id.
  const { data: users } = await supawright.supabase().from('user').select()
  expect(users.length).toBe(1)
})

When the test exits, Supawright will automatically clean up all the records it has created, and will inspect foreign key constraints to delete records in the correct order.

It will also discover any additional records in the database that were not created by Supawright, and will delete them as well, provided they have a foreign key relationship with a record that was created by Supawright.

This runs recursively. Consider the following example:

test('setup', async ({ supawright }) => {
  const user = await supawright.create('user')

  // We're using the standard Supabase client here, so Supawright
  // is not automatically aware of the records we're creating.
  await supawright
    .supabase()
    .from('session')
    .insert(\[{ user_id: user.id }, { user_id: user.id }\])

  // However, Supawright will discover these records and delete
  // them when the test exits.
})

test('everything has been cleaned up', async ({ supawright }) => {
  const { data: sessions } = await supawright
    .supabase()
    .from('session')
    .select()

  expect(sessions.length).toBe(0)
})

Overrides

If you have custom functions you wish to use to generate fake data or create records, you can pass optional config as the second argument to the withSupawright function.

The generators object is a record of Postgres types to functions that return a value of that type. Supawright will use these functions to generate fake data for any columns that are not nullable and do not have a default value.

If you’re using user-defined types, specify the USER-DEFINED type name in the generators object. This will be used for enums, for example.

The overrides object is a record of schema names to a record of table names to functions that return a record of column names to values. Supawright will use these functions to create records in the database. These return an array of Fixtures which Supawright will use to record the records it has created.

This is useful if you use a database trigger to populate certain tables and need to run custom code to activate the trigger.

const test = withSupawright<Database, 'public' | 'other'>(['public', 'other'], {
  generators: {
    smallint: () => 123,
    text: (table: string, column: string) => `${table}.${column}`,
  },
  overrides: {
    public: {
      user: async ({ supawright, data, supabase, generators }) => {
        const { data: user } = await supabase
          .from('user')
          .insert({
            email: 'coolemail@supawrightmail.com',
            password: generators.text(),
          })
          .select()
          .single()

        // Things go here...

        return [
          {
            schema: 'public',
            table: 'user',
            data: user,
          },
        ]
      },
    },
  },
})

Connection details

By default, Supawright will look for the SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables to connect to your Supabase instance. You can override these using the supabase key in the config object.

Supawright also needs access to a Supabase database for schema inspection, and will use the default Supabase localhost database. If you’d like to override this, provide a database key in the config object.

const test = withSupawright<Database, 'public' | 'other'>(['public', 'other'], {
  supabase: {
    supabaseUrl: 'my-supabase-url.com',
    serviceRoleKey: 'my-service-role-key',
  },
  database: {
    host: 'localhost',
    port: 54322,
    user: 'me',
    password: 'password',
    database: 'my-database',
  },
})

What’s next?

There are a few more things I’d like to be able to add to Supawright, such as automatically creating generators for custom enum and composite types, fixing the slightly janky typings and (maybe) convincing Supabase to make the generated interface a type 😉

I really hope you find Supawright useful. I’m very open to suggestions and contributions, so please feel free to create an issue on GitHub or get in touch with me directly on X or LinkedIn.

That’s it for now though. Happy building!