chore: rework and extend database testing docs and exemples

This commit is contained in:
avallete committed 2025-02-03 17:45:33 +09:00
1 parent d00bdef97f
commit c9bf2a2f1a
4 files changed
+781 -527

No files matched your search

@@ -1922,10 +1922,14 @@ export const local_development: NavMenuConstant = {
url: undefined,
items: [
{ name: 'Getting started', url: '/guides/local-development/testing/overview' },
{ name: 'Unit testing', url: '/guides/database/testing' },
{
name: 'Integration testing',
url: '/guides/local-development/managing-config',
name: 'pgTAP advanced guide',
url: '/guides/local-development/testing/pgtap-extended',
},
{ name: 'Database testing', url: '/guides/database/testing' },
{
name: 'RLS policies testing',
url: '/guides/database/extensions/pgtap#testing-rls-policies',
},
],
},
@@ -1,405 +0,0 @@
---
id: 'pg-regress'
title: 'Testing with pg_regress'
description: 'Use pg_regress for snapshot-based testing of your database, including RLS policies and query results.'
---
# Testing with pg_regress
pg_regress is a powerful testing approach that uses snapshot testing, similar to Jest snapshots in JavaScript. It's particularly useful for testing database behavior, RLS policies, and catching unintended changes in query results.
## Why pg_regress?
1. **Snapshot Testing**: Easily catch unintended changes in database behavior
2. **SQL-Native**: Write tests in pure SQL without learning new testing frameworks
3. **Version Control Friendly**: Changes in behavior are clearly visible in git diffs
4. **Transaction-Based**: Tests run in isolation and don't affect the database state
5. **Concurrent Execution**: Tests can run in parallel since they're transaction-based
## Getting Started
1. Create a test directory structure:
```bash
mkdir -p supabase/tests/regress/{sql,expected}
```
2. Create your first test file (`supabase/tests/regress/sql/schema_test.sql`):
```sql
-- Test database schema structure
begin;
-- Test table existence and structure
\d auth.users;
\d public.todos;
-- Test column existence and types
select
column_name,
data_type
from information_schema.columns
where table_schema = 'auth'
and table_name = 'users'
order by ordinal_position;
-- Test foreign key constraints
select
tc.table_schema,
tc.table_name,
kcu.column_name,
ccu.table_schema AS foreign_table_schema,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
from information_schema.table_constraints tc
join information_schema.key_column_usage kcu
on tc.constraint_name = kcu.constraint_name
join information_schema.constraint_column_usage ccu
on ccu.constraint_name = tc.constraint_name
where tc.constraint_type = 'FOREIGN KEY'
and tc.table_schema = 'public'
and tc.table_name = 'todos'
order by kcu.ordinal_position;
rollback;
```
3. Run the test to generate expected output:
```bash
pg_regress --inputdir=supabase/tests/regress/sql --outputdir=supabase/tests/regress/expected schema_test
```
## Example Tests
### 1. Testing Database Structure
File: `supabase/tests/regress/sql/database_structure.sql`
```sql
begin;
-- Test extension availability
select extname, extversion
from pg_extension
where extname in ('pgtap', 'uuid-ossp', 'pg_jwt')
order by extname;
-- Test schema existence
select schema_name
from information_schema.schemata
where schema_name in ('public', 'auth', 'storage')
order by schema_name;
-- Test role existence
select rolname
from pg_roles
where rolname in ('anon', 'authenticated', 'service_role')
order by rolname;
rollback;
```
### 2. Testing RLS Policies
File: `supabase/tests/regress/sql/rls_policies.sql`
```sql
begin;
-- Create test table
create table profiles (
id uuid primary key default gen_random_uuid(),
name text,
user_id uuid references auth.users(id)
);
-- Enable RLS
alter table profiles enable row level security;
-- Create policies
create policy "Public profiles are viewable by everyone"
on profiles for select using (true);
create policy "Users can insert their own profile"
on profiles for insert with check (auth.uid() = user_id);
create policy "Users can update own profile"
on profiles for update using (auth.uid() = user_id);
-- List all policies
select schemaname, tablename, policyname, permissive, roles, cmd, qual
from pg_policies
where tablename = 'profiles'
order by policyname;
-- Test policy effects
-- As anonymous user
set local role anon;
select current_user;
insert into profiles (name, user_id) values ('Test User', 'test-uuid'); -- Should fail
select count(*) from profiles; -- Should succeed (public read)
-- As authenticated user
set local role authenticated;
set local "request.jwt.claims" to '{"sub": "test-uuid"}';
select current_user;
insert into profiles (name, user_id) values ('Test User', 'test-uuid'); -- Should succeed
insert into profiles (name, user_id) values ('Other User', 'other-uuid'); -- Should fail
update profiles set name = 'Updated Name' where user_id = 'test-uuid'; -- Should succeed
update profiles set name = 'Hacked Name' where user_id = 'other-uuid'; -- Should fail
rollback;
```
### 3. Testing Functions and Triggers
File: `supabase/tests/regress/sql/functions_triggers.sql`
```sql
begin;
-- Create test table with trigger
create table audit_logs (
id serial primary key,
table_name text,
operation text,
record_id uuid,
old_data jsonb,
new_data jsonb,
timestamp timestamptz default now()
);
-- Create audit function
create or replace function audit_changes()
returns trigger as $$
begin
insert into audit_logs (table_name, operation, record_id, old_data, new_data)
values (
TG_TABLE_NAME,
TG_OP,
coalesce(NEW.id, OLD.id),
case when TG_OP = 'DELETE' then row_to_json(OLD)::jsonb else null end,
case when TG_OP in ('INSERT', 'UPDATE') then row_to_json(NEW)::jsonb else null end
);
return coalesce(NEW, OLD);
end;
$$ language plpgsql security definer;
-- Create test table
create table posts (
id uuid primary key default gen_random_uuid(),
title text,
content text,
user_id uuid references auth.users(id)
);
-- Add trigger
create trigger posts_audit
after insert or update or delete on posts
for each row execute function audit_changes();
-- Test function info
select
p.proname,
p.prorettype::regtype,
p.prosecdef,
p.provolatile,
p.pronargs
from pg_proc p
join pg_namespace n on p.pronamespace = n.oid
where n.nspname = 'public'
and p.proname = 'audit_changes';
-- Test trigger info
select
t.tgname,
t.tgenabled,
t.tgtype,
p.proname as trigger_function
from pg_trigger t
join pg_class c on t.tgrelid = c.oid
join pg_proc p on t.tgfoid = p.oid
where c.relname = 'posts'
order by t.tgname;
-- Test trigger functionality
insert into posts (title, content, user_id)
values ('Test Post', 'Content', 'test-uuid');
update posts
set title = 'Updated Post'
where user_id = 'test-uuid';
delete from posts
where user_id = 'test-uuid';
-- Check audit logs
select
table_name,
operation,
old_data->>'title' as old_title,
new_data->>'title' as new_title
from audit_logs
order by id;
rollback;
```
### 4. Testing Complex RLS Scenarios
File: `supabase/tests/regress/sql/complex_rls.sql`
```sql
begin;
-- Create test tables
create table organizations (
id uuid primary key default gen_random_uuid(),
name text not null
);
create table org_members (
org_id uuid references organizations(id),
user_id uuid references auth.users(id),
role text check (role in ('owner', 'admin', 'member')),
primary key (org_id, user_id)
);
create table projects (
id uuid primary key default gen_random_uuid(),
name text not null,
org_id uuid references organizations(id),
is_public boolean default false
);
-- Enable RLS
alter table organizations enable row level security;
alter table org_members enable row level security;
alter table projects enable row level security;
-- Create policies
create policy "Users can view organizations they're members of"
on organizations for select
using (
exists (
select 1 from org_members
where org_members.org_id = organizations.id
and org_members.user_id = auth.uid()
)
);
create policy "Users can view public projects"
on projects for select
using (is_public);
create policy "Users can view private projects in their organizations"
on projects for select
using (
exists (
select 1 from org_members
where org_members.org_id = projects.org_id
and org_members.user_id = auth.uid()
)
);
-- Insert test data
insert into organizations (id, name) values
('org1-uuid', 'Org 1'),
('org2-uuid', 'Org 2');
insert into org_members (org_id, user_id, role) values
('org1-uuid', 'user1-uuid', 'admin'),
('org1-uuid', 'user2-uuid', 'member'),
('org2-uuid', 'user2-uuid', 'owner');
insert into projects (id, name, org_id, is_public) values
('proj1-uuid', 'Public Project', 'org1-uuid', true),
('proj2-uuid', 'Private Org 1 Project', 'org1-uuid', false),
('proj3-uuid', 'Private Org 2 Project', 'org2-uuid', false);
-- Test as different users
-- Test as User 1 (admin in Org 1)
set local role authenticated;
set local "request.jwt.claims" to '{"sub": "user1-uuid"}';
select current_user, current_setting('request.jwt.claims');
-- Should see Org 1 but not Org 2
select id, name from organizations order by name;
-- Should see all Org 1 projects and public projects
select id, name, is_public from projects order by name;
-- Test as User 2 (member in Org 1, owner in Org 2)
set local "request.jwt.claims" to '{"sub": "user2-uuid"}';
select current_user, current_setting('request.jwt.claims');
-- Should see both organizations
select id, name from organizations order by name;
-- Should see all projects
select id, name, is_public from projects order by name;
-- Test as anonymous user
set local role anon;
select current_user;
-- Should see no organizations
select id, name from organizations order by name;
-- Should only see public projects
select id, name, is_public from projects order by name;
rollback;
```
### 5. Testing Storage Policies
File: `supabase/tests/regress/sql/storage_policies.sql`
```sql
begin;
-- Create test bucket
insert into storage.buckets (id, name)
values ('avatars', 'User Avatars');
-- Create storage objects
insert into storage.objects (bucket_id, name, owner, metadata)
values
('avatars', 'user1.jpg', 'user1-uuid', '{"size": 1024, "mimetype": "image/jpeg"}'),
('avatars', 'user2.jpg', 'user2-uuid', '{"size": 2048, "mimetype": "image/jpeg"}');
-- Test as anonymous user
set local role anon;
select current_user;
-- Should only see public bucket info
select id, name from storage.buckets;
-- Should see no objects
select bucket_id, name, owner from storage.objects;
-- Test as authenticated user
set local role authenticated;
set local "request.jwt.claims" to '{"sub": "user1-uuid"}';
select current_user, current_setting('request.jwt.claims');
-- Should see all buckets
select id, name from storage.buckets;
-- Should only see own objects
select bucket_id, name, owner from storage.objects order by name;
rollback;
```
## Additional Resources
- [Structured Postgres Regression Tests](https://yrashk.com/blog/2023/04/23/structured-postgres-regression-tests/)
- [PostgreSQL pg_regress Documentation](https://www.postgresql.org/docs/current/regress.html)
- [Supabase Management API Tests](https://github.com/supabase/supabase/tree/master/apps/management-api/tests) - Real-world examples
@@ -6,7 +6,18 @@ description: 'Learn how develop and test your database schema, tables, functions
Testing is a critical part of database development, especially when working with features like Row Level Security (RLS) policies. This guide provides a comprehensive approach to testing your Supabase database.
## Quick Start: Testing RLS in 15 Minutes
## Testing Approaches
### 1. Database Unit Testing with pgTAP
[pgTAP](https://pgtap.org) is a unit testing framework for PostgreSQL that allows you to test:
- Database structure (tables, columns, constraints)
- Row Level Security (RLS) policies
- Functions and procedures
- Data integrity
#### pgTAP: Testing RLS in 15 Minutes
Let's walk through a complete example of setting up and testing RLS policies for a simple todo application:
@@ -34,69 +45,74 @@ using (auth.uid() = user_id);
2. Set up your testing environment:
```bash
# Create test directory
mkdir -p ./supabase/tests/database
# Enable pgTAP extension if not already enabled
supabase sql 'create extension if not exists pgtap with schema extensions;'
# Create test file
touch ./supabase/tests/database/todos_rls.test.sql
# Create a new test for our policies using supabase cli
supabase test new todos_rls.test
```
3. Write your RLS tests:
```sql
begin;
-- install tests utilities
-- install pgtap extension for testing
create extension if not exists pgtap with schema extensions;
-- Start declare we'll have 6 test cases in our test suite
select plan(6);
-- Setup our testing data
-- Set up auth.users entries
insert into auth.users (id, email) values
('123e4567-e89b-12d3-a456-426614174000', 'user1@test.com'),
('987fcdeb-51a2-43d7-9012-345678901234', 'user2@test.com');
-- Create test todos
insert into public.todos (task, user_id) values
('User 1 Task 1', '123e4567-e89b-12d3-a456-426614174000'),
('User 1 Task 2', '123e4567-e89b-12d3-a456-426614174000'),
('User 2 Task 1', '987fcdeb-51a2-43d7-9012-345678901234');
-- Test 1: Verify RLS is enabled
select has_row_level_security('public'::name, 'todos'::name);
select results_eq(
$$SELECT relrowsecurity FROM pg_class WHERE relname = 'todos' AND relnamespace = 'public'::regnamespace$$,
ARRAY[true],
'Row Level Security should be enabled on todos table'
);
-- Test 2: Verify policy exists
select policies_are('public', 'todos', ARRAY['Users can only access their own todos']);
-- Set up test data
insert into auth.users (id, email) values
('d0c2eb55-5c60-4415-93f9-8743b3b0743e', 'user1@test.com'),
('51f0518c-0d3b-4937-b98f-2a50b8aa1997', 'user2@test.com');
insert into public.todos (task, user_id) values
('User 1 Task 1', 'd0c2eb55-5c60-4415-93f9-8743b3b0743e'),
('User 1 Task 2', 'd0c2eb55-5c60-4415-93f9-8743b3b0743e'),
('User 2 Task 1', '51f0518c-0d3b-4937-b98f-2a50b8aa1997');
-- Test as User 1
-- as User 1
set local role authenticated;
set local "request.jwt.claims" to '{"sub": "d0c2eb55-5c60-4415-93f9-8743b3b0743e"}';
set local request.jwt.claim.sub = '123e4567-e89b-12d3-a456-426614174000';
-- Test 3: User 1 should only see their own todos
select results_eq(
'select count(*) from todos',
ARRAY[2::bigint],
'User 1 should only see their 2 todos'
'select count(*) from todos',
ARRAY[2::bigint],
'User 1 should only see their 2 todos'
);
-- Test 4: User 1 can create their own todo
select lives_ok(
$$insert into todos (task, user_id) values ('New Task', 'd0c2eb55-5c60-4415-93f9-8743b3b0743e')$$,
'User 1 can create their own todo'
$$insert into todos (task, user_id) values ('New Task', '123e4567-e89b-12d3-a456-426614174000'::uuid)$$,
'User 1 can create their own todo'
);
-- Test as User 2
set local "request.jwt.claims" to '{"sub": "51f0518c-0d3b-4937-b98f-2a50b8aa1997"}';
-- as User 2
set local request.jwt.claim.sub = '987fcdeb-51a2-43d7-9012-345678901234';
-- Test 5: User 2 should only see their own todos
select results_eq(
'select count(*) from todos',
ARRAY[1::bigint],
'User 2 should only see their 1 todo'
'select count(*) from todos',
ARRAY[1::bigint],
'User 2 should only see their 1 todo'
);
-- Test 6: User 2 cannot modify User 1's todo
select throws_ok(
$$update todos set task = 'Hacked!' where user_id = 'd0c2eb55-5c60-4415-93f9-8743b3b0743e'$$,
'User 2 cannot modify User 1 todos'
SELECT results_ne(
$$ update todos set task = 'Hacked!' where user_id = '123e4567-e89b-12d3-a456-426614174000'::uuid returning 1 $$,
$$ values(1) $$,
'User 2 cannot modify User 1 todos'
);
select * from finish();
@@ -107,112 +123,95 @@ rollback;
```bash
supabase test db
```
## Testing Approaches
### 1. Database Unit Testing with pgTAP
[pgTAP](https://pgtap.org) is a unit testing framework for PostgreSQL that allows you to test:
- Database structure (tables, columns, constraints)
- Row Level Security (RLS) policies
- Functions and procedures
- Data integrity
#### Common pgTAP Testing Patterns
1. Testing Database Structure:
```sql
begin;
select plan(4);
-- Test table existence
select has_table('public', 'todos', 'Table todos should exist');
-- Test column existence
select has_column('public', 'todos', 'task', 'Column task should exist');
select has_column('public', 'todos', 'user_id', 'Column user_id should exist');
-- Test foreign key constraint
select col_is_fk('public', 'todos', 'user_id', 'user_id should be a foreign key');
select * from finish();
rollback;
```
2. Testing RLS Policies with Different Roles:
```sql
begin;
select plan(3);
-- Test anonymous access
set local role anon;
select results_eq(
'select count(*) from todos',
ARRAY[0::bigint],
'Anonymous users should see no todos'
);
-- Test authenticated access with correct user
set local role authenticated;
set local "request.jwt.claims" to '{"sub": "user-id"}';
select results_eq(
'select count(*) from todos where user_id = ''user-id''',
ARRAY[1::bigint],
'User should see their own todos'
);
-- Test authenticated access with wrong user
set local "request.jwt.claims" to '{"sub": "wrong-user"}';
select results_eq(
'select count(*) from todos where user_id = ''user-id''',
ARRAY[0::bigint],
'User should not see other users todos'
);
select * from finish();
rollback;
psql:todos_rls.test.sql:4: NOTICE: extension "pgtap" already exists, skipping
./todos_rls.test.sql .. ok
All tests successful.
Files=1, Tests=6, 0 wallclock secs ( 0.01 usr + 0.00 sys = 0.01 CPU)
Result: PASS
```
### 2. Application-Level Testing
Testing through your application code provides end-to-end verification. Here's an example using TypeScript and Jest:
Testing through your application code provides end-to-end verification. Here's an example using TypeScript and Jest that mirrors our pgTAP tests above:
```typescript
import { createClient } from '@supabase/supabase-js'
import { beforeAll, describe, expect, it } from '@jest/globals'
describe('Todos RLS', () => {
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)
it('should enforce RLS policies', async () => {
// Sign in as user 1
const {
data: { user: user1 },
} = await supabase.auth.signInWithPassword({
// Test users from our pgTAP example
const USER_1_ID = '123e4567-e89b-12d3-a456-426614174000'
const USER_2_ID = '987fcdeb-51a2-43d7-9012-345678901234'
beforeAll(async () => {
// Setup test data
const adminSupabase = createClient(process.env.SUPABASE_URL!, process.env.SERVICE_ROLE_KEY!)
// Create test users (using service role to bypass email verification)
await adminSupabase.auth.admin.createUser({
email: 'user1@test.com',
password: 'password123',
user_metadata: { id: USER_1_ID },
})
await adminSupabase.auth.admin.createUser({
email: 'user2@test.com',
password: 'password123',
user_metadata: { id: USER_2_ID },
})
// Create initial todos
await adminSupabase.from('todos').insert([
{ task: 'User 1 Task 1', user_id: USER_1_ID },
{ task: 'User 1 Task 2', user_id: USER_1_ID },
{ task: 'User 2 Task 1', user_id: USER_2_ID },
])
})
it('should allow User 1 to only see their own todos', async () => {
// Sign in as User 1
await supabase.auth.signInWithPassword({
email: 'user1@test.com',
password: 'password123',
})
// Create a todo as user 1
const { data: todo } = await supabase
.from('todos')
.insert({ task: 'Test task', user_id: user1.id })
.select()
.single()
const { data: todos } = await supabase.from('todos').select('*')
expect(todos).toHaveLength(2)
todos?.forEach((todo) => {
expect(todo.user_id).toBe(USER_1_ID)
})
})
// Sign in as user 2
it('should allow User 1 to create their own todo', async () => {
// Still signed in as User 1
const { error } = await supabase.from('todos').insert({ task: 'New Task', user_id: USER_1_ID })
expect(error).toBeNull()
})
it('should allow User 2 to only see their own todos', async () => {
// Sign in as User 2
await supabase.auth.signInWithPassword({
email: 'user2@test.com',
password: 'password123',
})
// Try to access user 1's todo
const { data: user2Todos } = await supabase.from('todos').select().eq('id', todo.id)
const { data: todos } = await supabase.from('todos').select('*')
expect(todos).toHaveLength(1)
todos?.forEach((todo) => {
expect(todo.user_id).toBe(USER_2_ID)
})
})
expect(user2Todos).toHaveLength(0) // User 2 should not see User 1's todo
it('should prevent User 2 from modifying User 1 todos', async () => {
// Still signed in as User 2
const { error } = await supabase
.from('todos')
.update({ task: 'Hacked!' })
.eq('user_id', USER_1_ID)
expect(error).not.toBeNull()
})
})
```
@@ -274,6 +273,7 @@ jobs:
For more complex, real-world examples of database testing, check out:
- [Basejump's Database Tests](https://github.com/usebasejump/basejump/tree/main/supabase/tests/database) - A production-grade example of testing RLS policies
- [RLS Guide and Best Practices](https://github.com/orgs/supabase/discussions/14576)
## Troubleshooting
@@ -294,3 +294,5 @@ Common issues and solutions:
- [pgTAP Documentation](https://pgtap.org)
- [Supabase CLI Reference](https://supabase.com/docs/reference/cli/supabase-test)
- [pgTap supabase reference](https://supabase.com/docs/guides/database/extensions/pgtap?queryGroups=database-method&database-method=sql#testing-rls-policies)
- [Database testing reference](https://supabase.com/docs/guides/database/testing)
@@ -0,0 +1,653 @@
---
id: 'local-development-testing-pgtap-extended'
title: 'Advanced pgTAP Testing'
description: 'Learn how to leverage dbdev and test helpers for advanced database testing.'
---
While basic pgTAP provides excellent testing capabilities, you can enhance your testing workflow using database development tools and helper packages. This guide covers advanced testing techniques using dbdev and community-maintained test helpers.
## Using Database.dev (dbdev)
[Database.dev](https://database.dev) is a package manager for PostgreSQL that allows you to install and use community-maintained packages, including testing utilities.
### Setting up dbdev
To use dbdev and its packages, you'll need to install some prerequisites:
```sql
create extension if not exists http with schema extensions;
create extension if not exists pg_tle;
drop extension if exists "supabase-dbdev";
select pgtle.uninstall_extension_if_exists('supabase-dbdev');
select
pgtle.install_extension(
'supabase-dbdev',
resp.contents ->> 'version',
'PostgreSQL package manager',
resp.contents ->> 'sql'
)
from http(
(
'GET',
'https://api.database.dev/rest/v1/'
|| 'package_versions?select=sql,version'
|| '&package_name=eq.supabase-dbdev'
|| '&order=version.desc'
|| '&limit=1',
array[
('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header
],
null,
null
)
) x,
lateral (
select
((row_to_json(x) -> 'content') #>> '{}')::json -> 0
) resp(contents);
create extension "supabase-dbdev";
select dbdev.install('supabase-dbdev');
drop extension if exists "supabase-dbdev";
create extension "supabase-dbdev";
```
### Installing Test Helpers
The Basejump Supabase Test Helpers package provides utilities that simplify testing Supabase-specific features:
```sql
select dbdev.install('basejump-supabase_test_helpers');
create extension if not exists "basejump-supabase_test_helpers" version '0.0.6';
```
## Test Helper Benefits
The test helpers package provides several advantages over writing raw pgTAP tests:
1. **Simplified User Management**
- Create test users with `tests.create_supabase_user()`
- Switch contexts with `tests.authenticate_as()`
- Retrieve user IDs using `tests.get_supabase_uid()`
2. **RLS Testing Utilities**
- Verify RLS status with `tests.rls_enabled()`
- Test policy enforcement
- Simulate different user contexts
3. **Reduced Boilerplate**
- No need to manually insert auth.users
- Simplified JWT claim management
- Cleaner test setup and teardown
## Schema-wide RLS Testing
When working with Row Level Security, it's crucial to ensure that RLS is enabled on all tables that need it. You can create a simple test to verify RLS is enabled across an entire schema:
```sql
begin;
select plan(1);
-- Verify RLS is enabled on all tables in the public schema
select tests.rls_enabled('public');
select * from finish();
rollback;
```
## Test File Organization
When working with multiple test files that share common setup requirements, it's beneficial to create a single "pre-test" file that handles the global environment setup. This approach reduces duplication and ensures consistent test environments.
### Creating a Pre-test Hook
Since pgTAP test files are executed in alphabetical order, you can create a setup file that runs first by using a naming convention like `000-setup-tests-hooks.sql`:
```bash
supabase test new 000-setup-tests-hooks
```
This setup file should contain:
1. All shared extensions and dependencies
2. Common test utilities
3. A simple "no-op" test to verify the setup
Here's an example setup file:
```sql
-- install tests utilities
-- instal pgtap extension for testing
create extension if not exists pgtap with schema extensions;
/*
---------------------
---- install dbdev ----
----------------------
Requires:
- pg_tle: https://github.com/aws/pg_tle
- pgsql-http: https://github.com/pramsey/pgsql-http
*/
create extension if not exists http with schema extensions;
create extension if not exists pg_tle;
drop extension if exists "supabase-dbdev";
select pgtle.uninstall_extension_if_exists('supabase-dbdev');
select
pgtle.install_extension(
'supabase-dbdev',
resp.contents ->> 'version',
'PostgreSQL package manager',
resp.contents ->> 'sql'
)
from http(
(
'GET',
'https://api.database.dev/rest/v1/'
|| 'package_versions?select=sql,version'
|| '&package_name=eq.supabase-dbdev'
|| '&order=version.desc'
|| '&limit=1',
array[
('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header
],
null,
null
)
) x,
lateral (
select
((row_to_json(x) -> 'content') #>> '{}')::json -> 0
) resp(contents);
create extension "supabase-dbdev";
select dbdev.install('supabase-dbdev');
drop extension if exists "supabase-dbdev";
create extension "supabase-dbdev";
-- Install test helpers
select dbdev.install('basejump-supabase_test_helpers');
create extension if not exists "basejump-supabase_test_helpers" version '0.0.6';
-- Verify setup with a no-op test
begin;
select plan(1);
select ok(true, 'Pre-test hook completed successfully');
select * from finish();
rollback;
```
### Benefits
This approach offers several advantages:
- Reduces code duplication across test files
- Ensures consistent test environment setup
- Makes it easier to maintain and update shared dependencies
- Provides immediate feedback if the setup process fails
Your subsequent test files (e.g., `001-auth-tests.sql`, `002-rls-tests.sql`) can focus solely on their specific test cases, knowing that the environment is properly configured.
## Example: Advanced RLS Testing
Here's a complete example using test helpers to verify RLS policies putting it all together:
```sql
begin;
-- Assuming we already have our: 000-setup-tests-hooks.sql file we can use tests helpers
select plan(6);
-- Set up test data
-- Create our test supabase users
select tests.create_supabase_user('user1@test.com');
select tests.create_supabase_user('user2@test.com');
-- Create our test data
insert into public.todos (task, user_id) values
('User 1 Task 1', tests.get_supabase_uid('user1@test.com')),
('User 1 Task 2', tests.get_supabase_uid('user1@test.com')),
('User 2 Task 1', tests.get_supabase_uid('user2@test.com'));
-- Test 1: Verify RLS is enabled
select tests.rls_enabled('public', 'todos');
-- Test 2: Verify policy exists
select policies_are('public', 'todos', ARRAY['Users can only access their own todos']);
-- Test as User 1
select tests.authenticate_as('user1@test.com');
-- Test 3: User 1 should only see their own todos
select results_eq(
'select count(*) from todos',
ARRAY[2::bigint],
'User 1 should only see their 2 todos'
);
-- Test 4: User 1 can create their own todo
select lives_ok(
$$insert into todos (task, user_id) values ('New Task', tests.get_supabase_uid('user1@test.com'))$$,
'User 1 can create their own todo'
);
-- Test as User 2
select tests.authenticate_as('user2@test.com');
-- Test 5: User 2 should only see their own todos
select results_eq(
'select count(*) from todos',
ARRAY[1::bigint],
'User 2 should only see their 1 todo'
);
-- Test 6: User 2 cannot modify User 1's todo
SELECT results_ne(
$$ update todos set task = 'Hacked!' where user_id = tests.get_supabase_uid('user1@test.com') returning 1 $$,
$$ values(1) $$,
'User 2 cannot modify User 1 todos'
);
select * from finish();
rollback;
```
## Not Another Todo App: Testing Complex Organizations
While todo apps are great for learning, let's explore testing a more realistic scenario: a multi-tenant content publishing platform. This example demonstrates how to test complex permissions, plan restrictions, and content management.
### System Overview
Our platform implements:
- Organizations with tiered plans (free/pro/enterprise)
- Role-based access (owner/admin/editor/viewer)
- Content management (posts/comments)
- Premium content restrictions
- Plan-based limitations
### What Makes This Complex?
1. **Layered Permissions**
- Role hierarchies affect access rights
- Plan types influence user capabilities
- Content state (draft/published) affects permissions
2. **Business Rules**
- Free plan post limits
- Premium content visibility
- Cross-organization security
### Testing Focus Areas
When writing tests, we'll verify:
- Organization member access control
- Content visibility across roles
- Plan limitation enforcement
- Cross-organization data isolation
#### 1. App schema definitions
Or app schema tables will be defined like so:
```sql
create table public.profiles (
id uuid references auth.users(id) primary key,
username text unique not null,
full_name text,
bio text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table public.organizations (
id bigint primary key generated always as identity,
name text not null,
slug text unique not null,
plan_type text not null check (plan_type in ('free', 'pro', 'enterprise')),
max_posts int not null default 5,
created_at timestamptz default now()
);
create table public.org_members (
org_id bigint references public.organizations(id) on delete cascade,
user_id uuid references auth.users(id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'editor', 'viewer')),
created_at timestamptz default now(),
primary key (org_id, user_id)
);
create table public.posts (
id bigint primary key generated always as identity,
title text not null,
content text not null,
author_id uuid references public.profiles(id) not null,
org_id bigint references public.organizations(id),
status text not null check (status in ('draft', 'published', 'archived')),
is_premium boolean default false,
scheduled_for timestamptz,
category text,
view_count int default 0,
published_at timestamptz,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table public.comments (
id bigint primary key generated always as identity,
post_id bigint references public.posts(id) on delete cascade,
author_id uuid references public.profiles(id),
content text not null,
is_deleted boolean default false,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
```
#### 2. RLS policies declaration
Now we want to setup the RLS policies for each of our tables:
```sql
-- Helper function for role checks
create or replace function public.get_user_org_role(org_id bigint, user_id uuid)
returns text as $$
select role from org_members
where org_id = $1 and user_id = $2;
-- Note the use of security definer to avoid RLS checking recursion issue
-- see: https://supabase.com/docs/guides/database/postgres/row-level-security#use-security-definer-functions
$$ language sql security definer;
-- Enable RLS for all tables
alter table public.profiles enable row level security;
alter table public.organizations enable row level security;
alter table public.org_members enable row level security;
alter table public.posts enable row level security;
alter table public.comments enable row level security;
-- Profiles policies
create policy "Public profiles are viewable by everyone"
on public.profiles for select using (true);
create policy "Users can insert their own profile"
on public.profiles for insert with check (auth.uid() = id);
create policy "Users can update their own profile"
on public.profiles for update using (auth.uid() = id)
with check (auth.uid() = id);
-- Organizations policies
create policy "Public org info visible to all"
on public.organizations for select using (true);
create policy "Org management restricted to owners"
on public.organizations for all using (
public.get_user_org_role(id, auth.uid()) = 'owner'
);
-- Org Members policies
create policy "Members visible to org members"
on public.org_members for select using (
public.get_user_org_role(org_id, auth.uid()) is not null
);
create policy "Member management restricted to admins and owners"
on public.org_members for all using (
public.get_user_org_role(org_id, auth.uid()) in ('owner', 'admin')
);
-- Posts policies
create policy "Complex post visibility"
on public.posts for select using (
-- Published non-premium posts are visible to all
(status = 'published' and not is_premium)
or
-- Premium posts visible to org members only
(status = 'published' and is_premium and
public.get_user_org_role(org_id, auth.uid()) is not null)
or
-- All posts visible to editors and above
public.get_user_org_role(org_id, auth.uid()) in ('owner', 'admin', 'editor')
);
create policy "Post creation rules"
on public.posts for insert with check (
-- Must be org member with appropriate role
public.get_user_org_role(org_id, auth.uid()) in ('owner', 'admin', 'editor')
and
-- Check org post limits for free plans
(
select plan_type = 'free' and
(select count(*) from posts p where p.org_id = posts.org_id) < max_posts
from organizations
where id = posts.org_id
)
);
create policy "Post update rules"
on public.posts for update using (
exists (
select 1
where
-- Editors can update non-published posts
(public.get_user_org_role(org_id, auth.uid()) = 'editor' and status != 'published')
or
-- Admins and owners can update any post
public.get_user_org_role(org_id, auth.uid()) in ('owner', 'admin')
)
);
-- Comments policies
create policy "Comments on published posts are viewable by everyone"
on public.comments for select using (
exists (
select 1 from public.posts
where id = post_id
and status = 'published'
)
and not is_deleted
);
create policy "Authenticated users can create comments"
on public.comments for insert with check (auth.uid() = author_id);
create policy "Users can update their own comments"
on public.comments for update using (author_id = auth.uid());
```
#### 3. Test cases:
Now we have everything we need, let's write our RLS test cases, note that each section could be in it's own test:
```sql
-- Assuming we already have our: 000-setup-tests-hooks.sql file we can use tests helpers
begin;
-- Declare total number of tests
select plan(10);
-- Create our test users
select tests.create_supabase_user('org_owner', 'owner@test.com');
select tests.create_supabase_user('org_admin', 'admin@test.com');
select tests.create_supabase_user('org_editor', 'editor@test.com');
select tests.create_supabase_user('premium_user', 'premium@test.com');
select tests.create_supabase_user('free_user', 'free@test.com');
select tests.create_supabase_user('scheduler', 'scheduler@test.com');
select tests.create_supabase_user('free_author', 'free_author@test.com');
-- Create profiles for test users
insert into profiles (id, username, full_name)
values
(tests.get_supabase_uid('org_owner'), 'org_owner', 'Organization Owner'),
(tests.get_supabase_uid('org_admin'), 'org_admin', 'Organization Admin'),
(tests.get_supabase_uid('org_editor'), 'org_editor', 'Organization Editor'),
(tests.get_supabase_uid('premium_user'), 'premium_user', 'Premium User'),
(tests.get_supabase_uid('free_user'), 'free_user', 'Free User'),
(tests.get_supabase_uid('scheduler'), 'scheduler', 'Scheduler User'),
(tests.get_supabase_uid('free_author'), 'free_author', 'Free Author');
-- First authenticate as service role to bypass RLS for initial setup
select tests.authenticate_as_service_role();
-- Create test organizations and setup data
with new_org as (
insert into organizations (name, slug, plan_type, max_posts)
values
('Test Org', 'test-org', 'pro', 100),
('Premium Org', 'premium-org', 'enterprise', 1000),
('Schedule Org', 'schedule-org', 'pro', 100),
('Free Org', 'free-org', 'free', 2)
returning id, slug
),
-- Setup members and posts
member_setup as (
insert into org_members (org_id, user_id, role)
select
org.id,
user_id,
role
from new_org org cross join (
values
(tests.get_supabase_uid('org_owner'), 'owner'),
(tests.get_supabase_uid('org_admin'), 'admin'),
(tests.get_supabase_uid('org_editor'), 'editor'),
(tests.get_supabase_uid('premium_user'), 'viewer'),
(tests.get_supabase_uid('scheduler'), 'editor'),
(tests.get_supabase_uid('free_author'), 'editor')
) as members(user_id, role)
where org.slug = 'test-org'
or (org.slug = 'premium-org' and role = 'viewer')
or (org.slug = 'schedule-org' and role = 'editor')
or (org.slug = 'free-org' and role = 'editor')
)
-- Setup initial posts
insert into posts (title, content, org_id, author_id, status, is_premium, scheduled_for)
select
title,
content,
org.id,
author_id,
status,
is_premium,
scheduled_for
from new_org org cross join (
values
('Premium Post', 'Premium content', tests.get_supabase_uid('premium_user'), 'published', true, null),
('Free Post', 'Free content', tests.get_supabase_uid('premium_user'), 'published', false, null),
('Future Post', 'Future content', tests.get_supabase_uid('scheduler'), 'published', false, '2024-01-02 12:00:00+00'::timestamptz)
) as posts(title, content, author_id, status, is_premium, scheduled_for)
where org.slug in ('premium-org', 'schedule-org');
-- Test owner privileges
select tests.authenticate_as('org_owner');
select lives_ok(
$$
update organizations
set name = 'Updated Org'
where id = (select id from organizations limit 1)
$$,
'Owner can update organization'
);
-- Test admin privileges
select tests.authenticate_as('org_admin');
select results_eq(
$$select count(*) from org_members$$,
ARRAY[6::bigint],
'Admin can view all members'
);
-- Test editor restrictions
select tests.authenticate_as('org_editor');
select throws_ok(
$$
insert into org_members (org_id, user_id, role)
values (
(select id from organizations limit 1),
(select tests.get_supabase_uid('org_editor')),
'viewer'
)
$$,
'42501',
'new row violates row-level security policy for table "org_members"',
'Editor cannot manage members'
);
-- Premium Content Access Tests
select tests.authenticate_as('premium_user');
select results_eq(
$$select count(*) from posts where org_id = (select id from organizations where slug = 'premium-org')$$,
ARRAY[3::bigint],
'Premium user can see all posts'
);
select tests.clear_authentication();
select results_eq(
$$select count(*) from posts where org_id = (select id from organizations where slug = 'premium-org')$$,
ARRAY[2::bigint],
'Anonymous users can only see free posts'
);
-- Time-Based Publishing Tests
select tests.authenticate_as('scheduler');
select tests.freeze_time('2024-01-01 12:00:00+00'::timestamptz);
select results_eq(
$$select count(*) from posts where scheduled_for > now() and org_id = (select id from organizations where slug = 'schedule-org')$$,
ARRAY[1::bigint],
'Can see scheduled posts'
);
select tests.freeze_time('2024-01-02 13:00:00+00'::timestamptz);
select results_eq(
$$select count(*) from posts where scheduled_for < now() and org_id = (select id from organizations where slug = 'schedule-org')$$,
ARRAY[1::bigint],
'Can see posts after schedule time'
);
select tests.unfreeze_time();
-- Plan Limit Tests
select tests.authenticate_as('free_author');
select lives_ok(
$$
insert into posts (title, content, org_id, author_id, status)
select 'Post 1', 'Content 1', id, auth.uid(), 'draft'
from organizations where slug = 'free-org' limit 1
$$,
'First post creates successfully'
);
select lives_ok(
$$
insert into posts (title, content, org_id, author_id, status)
select 'Post 2', 'Content 2', id, auth.uid(), 'draft'
from organizations where slug = 'free-org' limit 1
$$,
'Second post creates successfully'
);
select throws_ok(
$$
insert into posts (title, content, org_id, author_id, status)
select 'Post 3', 'Content 3', id, auth.uid(), 'draft'
from organizations where slug = 'free-org' limit 1
$$,
'42501',
'new row violates row-level security policy for table "posts"',
'Cannot exceed free plan post limit'
);
select * from finish();
rollback;
```
## Additional Resources
- [Basejump Test Helpers Documentation](https://database.dev/basejump/supabase_test_helpers)
- [Basejump Test Helpers Reference](https://github.com/usebasejump/supabase-test-helpers)
- [Basejump RLS writting blog post](https://usebasejump.com/blog/testing-on-supabase-with-pgtap)
- [Database.dev Package Registry](https://database.dev)
- [pgTLE Documentation](https://github.com/aws/pg_tle)
- [Supabase RLS Performances and Best Practices](https://github.com/orgs/supabase/discussions/14576)