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 Postgres 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:
12345678910111213141516-- Create a simple todos tablecreate table todos (id uuid primary key default gen_random_uuid(),task text not null,user_id uuid references auth.users not null,completed boolean default false);-- Enable RLSalter table todos enable row level security;-- Create a policycreate policy "Users can only access their own todos"on todos for all -- this policy applies to all operationsto authenticatedusing ((select auth.uid()) = user_id); -
Set up your testing environment:
12# Create a new test for our policies using supabase clisupabase test new todos_rls.test -
Write your RLS tests:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455begin;-- install tests utilities-- install pgtap extension for testingcreate extension if not exists pgtap with schema extensions;-- Start declare we'll have 4 test cases in our test suiteselect plan(4);-- Setup our testing data-- Set up auth.users entriesinsert into auth.users (id, email) values ('123e4567-e89b-12d3-a456-426614174000', 'user1@test.com'), ('987fcdeb-51a2-43d7-9012-345678901234', 'user2@test.com');-- Create test todosinsert 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');-- as User 1set local role authenticated;set local request.jwt.claim.sub = '123e4567-e89b-12d3-a456-426614174000';-- Test 1: User 1 should only see their own todosselect results_eq( 'select count(*) from todos', ARRAY[2::bigint], 'User 1 should only see their 2 todos');-- Test 2: User 1 can create their own todoselect lives_ok( $$insert into todos (task, user_id) values ('New Task', '123e4567-e89b-12d3-a456-426614174000'::uuid)$$, 'User 1 can create their own todo');-- as User 2set local request.jwt.claim.sub = '987fcdeb-51a2-43d7-9012-345678901234';-- Test 3: User 2 should only see their own todosselect results_eq( 'select count(*) from todos', ARRAY[1::bigint], 'User 2 should only see their 1 todo');-- Test 4: User 2 cannot modify User 1's todoSELECT 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();rollback; -
Run the tests:
123456supabase test dbpsql:todos_rls.test.sql:4: NOTICE: extension "pgtap" already exists, skipping./todos_rls.test.sql .. okAll tests successful.Files=1, Tests=6, 0 wallclock secs ( 0.01 usr + 0.00 sys = 0.01 CPU)Result: 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:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104import { } from '@supabase/supabase-js'import { , , , } from 'vitest'import from 'crypto'('Todos RLS', () => { // Generate unique IDs for this test suite to avoid conflicts with other tests const = .() const = .() const = (..!, ..!) (async () => { // Setup test data specific to this test suite const = (..!, ..!) // Create test users with unique IDs await ...({ : , : `user1-${}@test.com`, : 'password123', // We want the user to be usable right away without email confirmation : true, }) await ...({ : , : `user2-${}@test.com`, : 'password123', : true, }) // Create initial todos await .('todos').([ { : 'User 1 Task 1', : }, { : 'User 1 Task 2', : }, { : 'User 2 Task 1', : }, ]) }) ('should allow User 1 to only see their own todos', async () => { // Sign in as User 1 await ..({ : `user1-${}@test.com`, : 'password123', }) const { : } = await .('todos').('*') ().(2) ?.(() => { (.).() }) }) ('should allow User 1 to create their own todo', async () => { await ..({ : `user1-${}@test.com`, : 'password123', }) const { } = await .('todos').({ : 'New Task', : }) ().() }) ('should allow User 2 to only see their own todos', async () => { // Sign in as User 2 await ..({ : `user2-${}@test.com`, : 'password123', }) const { : } = await .('todos').('*') ().(1) ?.(() => { (.).() }) }) ('should prevent User 2 from modifying User 1 todos', async () => { await ..({ : `user2-${}@test.com`, : 'password123', }) // Attempt to update the todos we shouldn't have access to // result will be a no-op await .('todos').({ : 'Hacked!' }).('user_id', ) // Log back in as User 1 to verify their todos weren't changed await ..({ : `user1-${}@test.com`, : 'password123', }) // Fetch User 1's todos const { : } = await .('todos').('*') // Verify that none of the todos were changed to "Hacked!" ().() ?.(() => { (.)..('Hacked!') }) })})
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
:
1234567891011121314151617181920212223name: Database Testson: push: branches: [main] pull_request: branches: [main]jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Supabase CLI uses: supabase/setup-cli@v1 - name: Start Supabase run: supabase start - name: Run Tests 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