Testing Overview
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.
Testing approaches
Database unit testing with pgTAP
pgTAP is a unit testing framework for PostgreSQL that allows testing:
- Database structure: tables, columns, constraints
- Row Level Security (RLS) policies
- Functions and procedures
- Data integrity
This example demonstrates setting up and testing RLS policies for a simple todo application:
-
Create a test table with RLS enabled:
_16-- Create a simple todos table_16create table todos (_16id uuid primary key default gen_random_uuid(),_16task text not null,_16user_id uuid references auth.users not null,_16completed boolean default false_16);_16_16-- Enable RLS_16alter table todos enable row level security;_16_16-- Create a policy_16create policy "Users can only access their own todos"_16on todos for all -- this policy applies to all operations_16to authenticated_16using ((select auth.uid()) = user_id); -
Set up your testing environment:
_10# Create a new test for our policies using supabase cli_10supabase test new todos_rls.test -
Write your RLS tests:
_55begin;_55-- install tests utilities_55-- install pgtap extension for testing_55create extension if not exists pgtap with schema extensions;_55-- Start declare we'll have 6 test cases in our test suite_55select plan(4);_55_55-- Setup our testing data_55-- Set up auth.users entries_55insert into auth.users (id, email) values_55('123e4567-e89b-12d3-a456-426614174000', 'user1@test.com'),_55('987fcdeb-51a2-43d7-9012-345678901234', 'user2@test.com');_55_55-- Create test todos_55insert into public.todos (task, user_id) values_55('User 1 Task 1', '123e4567-e89b-12d3-a456-426614174000'),_55('User 1 Task 2', '123e4567-e89b-12d3-a456-426614174000'),_55('User 2 Task 1', '987fcdeb-51a2-43d7-9012-345678901234');_55_55-- as User 1_55set local role authenticated;_55set local request.jwt.claim.sub = '123e4567-e89b-12d3-a456-426614174000';_55_55-- Test 1: User 1 should only see their own todos_55select results_eq(_55'select count(*) from todos',_55ARRAY[2::bigint],_55'User 1 should only see their 2 todos'_55);_55_55-- Test 2: User 1 can create their own todo_55select lives_ok(_55$$insert into todos (task, user_id) values ('New Task', '123e4567-e89b-12d3-a456-426614174000'::uuid)$$,_55'User 1 can create their own todo'_55);_55_55-- as User 2_55set local request.jwt.claim.sub = '987fcdeb-51a2-43d7-9012-345678901234';_55_55-- Test 3: User 2 should only see their own todos_55select results_eq(_55'select count(*) from todos',_55ARRAY[1::bigint],_55'User 2 should only see their 1 todo'_55);_55_55-- Test 4: User 2 cannot modify User 1's todo_55SELECT results_ne(_55$$ update todos set task = 'Hacked!' where user_id = '123e4567-e89b-12d3-a456-426614174000'::uuid returning 1 $$,_55$$ values(1) $$,_55'User 2 cannot modify User 1 todos'_55);_55_55select * from finish();_55rollback; -
Run the tests:
_10supabase test db_10psql:todos_rls.test.sql:4: NOTICE: extension "pgtap" already exists, skipping_10./todos_rls.test.sql .. ok_10All tests successful._10Files=1, Tests=6, 0 wallclock secs ( 0.01 usr + 0.00 sys = 0.01 CPU)_10Result: PASS
Application-Level testing
Testing through application code provides end-to-end verification. Unlike database-level testing with pgTAP, application-level tests cannot use transactions for isolation.
Application-level tests should not rely on a clean database state, as resetting the database before each test can be slow and makes tests difficult to parallelize. Instead, design your tests to be independent by using unique user IDs for each test case.
Here's an example using TypeScript that mirrors the pgTAP tests above:
_104import { createClient } from '@supabase/supabase-js'_104import { beforeAll, describe, expect, it } from 'vitest'_104import crypto from 'crypto'_104_104describe('Todos RLS', () => {_104 // Generate unique IDs for this test suite to avoid conflicts with other tests_104 const USER_1_ID = crypto.randomUUID()_104 const USER_2_ID = crypto.randomUUID()_104_104 const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)_104_104 beforeAll(async () => {_104 // Setup test data specific to this test suite_104 const adminSupabase = createClient(process.env.SUPABASE_URL!, process.env.SERVICE_ROLE_KEY!)_104_104 // Create test users with unique IDs_104 await adminSupabase.auth.admin.createUser({_104 id: USER_1_ID,_104 email: `user1-${USER_1_ID}@test.com`,_104 password: 'password123',_104 // We want the user to be usable right away without email confirmation_104 email_confirm: true,_104 })_104 await adminSupabase.auth.admin.createUser({_104 id: USER_2_ID,_104 email: `user2-${USER_2_ID}@test.com`,_104 password: 'password123',_104 email_confirm: true,_104 })_104_104 // Create initial todos_104 await adminSupabase.from('todos').insert([_104 { task: 'User 1 Task 1', user_id: USER_1_ID },_104 { task: 'User 1 Task 2', user_id: USER_1_ID },_104 { task: 'User 2 Task 1', user_id: USER_2_ID },_104 ])_104 })_104_104 it('should allow User 1 to only see their own todos', async () => {_104 // Sign in as User 1_104 await supabase.auth.signInWithPassword({_104 email: `user1-${USER_1_ID}@test.com`,_104 password: 'password123',_104 })_104_104 const { data: todos } = await supabase.from('todos').select('*')_104_104 expect(todos).toHaveLength(2)_104 todos?.forEach((todo) => {_104 expect(todo.user_id).toBe(USER_1_ID)_104 })_104 })_104_104 it('should allow User 1 to create their own todo', async () => {_104 await supabase.auth.signInWithPassword({_104 email: `user1-${USER_1_ID}@test.com`,_104 password: 'password123',_104 })_104_104 const { error } = await supabase.from('todos').insert({ task: 'New Task', user_id: USER_1_ID })_104_104 expect(error).toBeNull()_104 })_104_104 it('should allow User 2 to only see their own todos', async () => {_104 // Sign in as User 2_104 await supabase.auth.signInWithPassword({_104 email: `user2-${USER_2_ID}@test.com`,_104 password: 'password123',_104 })_104_104 const { data: todos } = await supabase.from('todos').select('*')_104 expect(todos).toHaveLength(1)_104 todos?.forEach((todo) => {_104 expect(todo.user_id).toBe(USER_2_ID)_104 })_104 })_104_104 it('should prevent User 2 from modifying User 1 todos', async () => {_104 await supabase.auth.signInWithPassword({_104 email: `user2-${USER_2_ID}@test.com`,_104 password: 'password123',_104 })_104_104 // Attempt to update the todos we shouldn't have access to_104 // result will be a no-op_104 await supabase.from('todos').update({ task: 'Hacked!' }).eq('user_id', USER_1_ID)_104_104 // Log back in as User 1 to verify their todos weren't changed_104 await supabase.auth.signInWithPassword({_104 email: `user1-${USER_1_ID}@test.com`,_104 password: 'password123',_104 })_104_104 // Fetch User 1's todos_104 const { data: todos } = await supabase.from('todos').select('*')_104_104 // Verify that none of the todos were changed to "Hacked!"_104 expect(todos).toBeDefined()_104 todos?.forEach((todo) => {_104 expect(todo.task).not.toBe('Hacked!')_104 })_104 })_104})
Test isolation strategies
For application-level testing, consider these approaches for test isolation:
- Unique Identifiers: Generate unique IDs for each test suite to prevent data conflicts
- Cleanup After Tests: If necessary, clean up created data in an
afterAll
orafterEach
hook - Isolated Data Sets: Use prefixes or namespaces in data to separate test cases
Continuous integration testing
Set up automated database testing in your CI pipeline:
- Create a GitHub Actions workflow
.github/workflows/db-tests.yml
:
_23name: Database Tests_23_23on:_23 push:_23 branches: [main]_23 pull_request:_23 branches: [main]_23_23jobs:_23 test:_23 runs-on: ubuntu-latest_23_23 steps:_23 - uses: actions/checkout@v2_23_23 - name: Setup Supabase CLI_23 uses: supabase/setup-cli@v1_23_23 - name: Start Supabase_23 run: supabase start_23_23 - name: Run Tests_23 run: supabase test db
Best practices
-
Test Data Setup
- Use begin and rollback to ensure test isolation
- Create realistic test data that covers edge cases
- Use different user roles and permissions in tests
-
RLS Policy Testing
- Test Create, Read, Update, Delete operations
- Test with different user roles: anonymous and authenticated
- Test edge cases and potential security bypasses
- Always test negative cases: what users should not be able to do
-
CI/CD Integration
- Run tests automatically on every pull request
- Include database tests in deployment pipeline
- Keep test runs fast using transactions
Real-World examples
For more complex, real-world examples of database testing, check out:
- Database Tests Example Repository - A production-grade example of testing RLS policies
- RLS Guide and Best Practices
Troubleshooting
Common issues and solutions:
-
Test Failures Due to RLS
- Ensure you've set the correct role
set local role authenticated;
- Verify JWT claims are set
set local "request.jwt.claims"
- Check policy definitions match your test assumptions
- Ensure you've set the correct role
-
CI Pipeline Issues
- Verify Supabase CLI is properly installed
- Ensure database migrations are run before tests
- Check for proper test isolation using transactions