isaacharrisholt / supawright

A Playwright test harness for E2E testing with Supabase
MIT License
47 stars 2 forks source link

Supawright

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.

Installation

pnpm i -D supawright

Usage

Setup

[!IMPORTANT] Unfortunately, older versions of postgres-meta would generate TypeScript types as an interface, whereas for type constraints, we need a type.

Fix instructions Change the following line in your generated Supabase types (typically `database.ts`): ```diff - export interface Database { + export type Database = { ``` I recommend setting up a `make` target (or whichever build tool you use) to automatically make this change for you, e.g. ```make types: pnpm supabase gen types typescript --local | \ sed 's/export interface Database {/export type Database = {/' \ > src/types/database.ts ```

To get started, create a test file, e.g. can-login.test.ts, and create a test function with the withSupawright helper:

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

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

[!NOTE] Unfortunately, I haven't found a nice way of infering the schema names from the first argument, so you'll have to specify the schemas you'd like Supawright to use in two places.

Tests

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('can login', 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('can login', 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('can login', async ({ supawright }) => {
  const user = await supawright.create('user')

  // Since we're using the standard Supabase client here, Supawright
  // is unaware 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.
})

[!NOTE] The .supabase() method of the Supawright object takes an optional schema name to create a Supabase client in the chosen schema.

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(...)
                        .select()
                        .single()
                    ...
                    return [{
                        schema: 'public',
                        table: 'user',
                        data: user,
                    }]
                }
            }
        }
    }
)

If your generator returns null or undefined, Supawright will fall back to using the built-in generators. In the case of enums, Supawright will pick a random valid enum value.

Teardown

If you'd like to run some code after each test, but before Supawright cleans up after itself, you can pass a beforeTeardown function to the withSupawright function. This function will be passed the Supawright object from the test, as well as the page object from Playwright.

const test = withSupawright<Database, 'public' | 'other'>(['public', 'other'], {
  beforeTeardown: async ({ page, supawright }) => {
    await page.waitForLoadState('networkidle')
  }
})

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'
  }
})

Support

Are you a company using Supawright? Get in touch if you're interested in supporting the package, or if you'd like premium support.

TODO