--- name: "Supabase Testing" description: "Testing patterns for Supabase applications covering auth flow testing, Row Level Security policy testing, realtime subscription testing, and edge function testing" version: 1.0.0 author: thetestingacademy license: MIT tags: [supabase, rls, auth, realtime, edge-functions, postgres, storage, migrations, row-level-security, testing] testingTypes: [unit, integration, e2e] frameworks: [vitest, jest, playwright] languages: [typescript, javascript] domains: [web, api, backend] agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt, gemini-cli, amp] --- # Supabase Testing Skill You are an expert QA engineer specializing in testing Supabase-powered applications. When the user asks you to write, review, or debug tests for Supabase auth, Row Level Security, realtime subscriptions, edge functions, or storage, follow these detailed instructions. ## Core Principles 1. **Local-first testing** -- Always use Supabase CLI (`supabase start`) to run a local Postgres instance with all Supabase features. Never test directly against production. 2. **RLS is your security boundary** -- Row Level Security policies are the most critical code in a Supabase app. Test every policy with multiple user roles and edge cases. 3. **Test auth flows end-to-end** -- Supabase auth involves email confirmation, magic links, OAuth redirects, and JWT tokens. Test the full flow, not just the API call. 4. **Realtime requires connection testing** -- Subscription tests must establish actual WebSocket connections. Mock-only tests miss critical reconnection and conflict scenarios. 5. **Edge functions are isolated** -- Supabase Edge Functions run in Deno. Test them independently with their own test runner before integration testing. 6. **Storage policies mirror RLS** -- Storage bucket policies follow the same RLS pattern. Apply the same testing rigor to file access as to row access. 7. **Migration testing prevents data loss** -- Every schema migration should be tested against a snapshot of production-like data to verify both the up and down paths. ## Project Structure Always organize Supabase testing with this structure: ``` supabase/ config.toml migrations/ 20240101000000_create_profiles.sql 20240102000000_add_rls_policies.sql functions/ hello-world/ index.ts send-notification/ index.ts seed.sql tests/ rls/ profiles.test.ts posts.test.ts comments.test.ts migrations/ migration.test.ts src/ lib/ supabase/ client.ts server.ts middleware.ts __tests__/ unit/ auth.test.ts database.test.ts integration/ auth-flow.test.ts realtime.test.ts storage.test.ts e2e/ signup-login.spec.ts crud-with-rls.spec.ts helpers/ supabase-test-utils.ts test-users.ts seed-test-data.ts ``` ## Supabase Local Setup for Testing ### Configuration ```toml # supabase/config.toml [project] id = "my-project" [db] port = 54322 [auth] site_url = "http://localhost:3000" enable_signup = true [auth.email] enable_confirmations = false # Disable for testing double_confirm_changes = false [auth.external.google] enabled = false ``` ### Test Helper Setup ```typescript // __tests__/helpers/supabase-test-utils.ts import { createClient, SupabaseClient } from '@supabase/supabase-js'; const SUPABASE_URL = process.env.SUPABASE_URL || 'http://127.0.0.1:54321'; const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'; const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'; // Anon client (respects RLS) export function createAnonClient(): SupabaseClient { return createClient(SUPABASE_URL, SUPABASE_ANON_KEY); } // Service role client (bypasses RLS) export function createServiceClient(): SupabaseClient { return createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY); } // Authenticated client for a specific user export async function createAuthenticatedClient( email: string, password: string ): Promise { const client = createAnonClient(); const { error } = await client.auth.signInWithPassword({ email, password }); if (error) throw new Error(`Auth failed for ${email}: ${error.message}`); return client; } // Create a test user and return authenticated client export async function createTestUser( email?: string, password?: string, metadata?: Record ): Promise<{ client: SupabaseClient; userId: string; email: string }> { const testEmail = email || `test-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`; const testPassword = password || 'TestPassword123!'; const serviceClient = createServiceClient(); // Create user via service role (bypasses email confirmation) const { data: user, error } = await serviceClient.auth.admin.createUser({ email: testEmail, password: testPassword, email_confirm: true, user_metadata: metadata || {}, }); if (error) throw new Error(`Failed to create test user: ${error.message}`); const client = await createAuthenticatedClient(testEmail, testPassword); return { client, userId: user.user.id, email: testEmail, }; } // Cleanup test data export async function cleanupTestUser(userId: string): Promise { const serviceClient = createServiceClient(); await serviceClient.auth.admin.deleteUser(userId); } // Reset database to clean state export async function resetDatabase(): Promise { const serviceClient = createServiceClient(); // Delete all non-system data in reverse dependency order await serviceClient.from('comments').delete().neq('id', '00000000-0000-0000-0000-000000000000'); await serviceClient.from('posts').delete().neq('id', '00000000-0000-0000-0000-000000000000'); await serviceClient.from('profiles').delete().neq('id', '00000000-0000-0000-0000-000000000000'); } ``` ```typescript // __tests__/helpers/test-users.ts import { createTestUser, cleanupTestUser } from './supabase-test-utils'; import type { SupabaseClient } from '@supabase/supabase-js'; interface TestUserContext { client: SupabaseClient; userId: string; email: string; } export class TestUserManager { private users: TestUserContext[] = []; async createUser( role: string = 'user', metadata: Record = {} ): Promise { const user = await createTestUser(undefined, undefined, { role, ...metadata }); this.users.push(user); return user; } async createAdmin(): Promise { return this.createUser('admin'); } async createModerator(): Promise { return this.createUser('moderator'); } async cleanup(): Promise { for (const user of this.users) { await cleanupTestUser(user.userId).catch(() => {}); } this.users = []; } } ``` ## Row Level Security (RLS) Testing ### Schema and Policies ```sql -- supabase/migrations/20240101000000_create_profiles.sql CREATE TABLE profiles ( id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY, username TEXT UNIQUE NOT NULL, bio TEXT, avatar_url TEXT, is_public BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; -- Anyone can view public profiles CREATE POLICY "Public profiles are viewable by everyone" ON profiles FOR SELECT USING (is_public = true); -- Users can view their own profile (even if private) CREATE POLICY "Users can view own profile" ON profiles FOR SELECT USING (auth.uid() = id); -- Users can update only their own profile CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id); -- Users can insert their own profile CREATE POLICY "Users can insert own profile" ON profiles FOR INSERT WITH CHECK (auth.uid() = id); -- Only admins can delete profiles CREATE POLICY "Admins can delete profiles" ON profiles FOR DELETE USING ( EXISTS ( SELECT 1 FROM profiles WHERE id = auth.uid() AND (auth.jwt() -> 'user_metadata' ->> 'role') = 'admin' ) ); ``` ```sql -- supabase/migrations/20240102000000_create_posts.sql CREATE TABLE posts ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, author_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, published BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); ALTER TABLE posts ENABLE ROW LEVEL SECURITY; -- Anyone can view published posts CREATE POLICY "Published posts are viewable by everyone" ON posts FOR SELECT USING (published = true); -- Authors can view their own drafts CREATE POLICY "Authors can view own drafts" ON posts FOR SELECT USING (auth.uid() = author_id); -- Authenticated users can create posts CREATE POLICY "Authenticated users can create posts" ON posts FOR INSERT WITH CHECK (auth.uid() = author_id); -- Authors can update their own posts CREATE POLICY "Authors can update own posts" ON posts FOR UPDATE USING (auth.uid() = author_id) WITH CHECK (auth.uid() = author_id); -- Authors can delete their own posts CREATE POLICY "Authors can delete own posts" ON posts FOR DELETE USING (auth.uid() = author_id); ``` ### RLS Policy Tests ```typescript // __tests__/integration/rls/profiles.test.ts import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { createAnonClient, createServiceClient, resetDatabase, } from '../../helpers/supabase-test-utils'; import { TestUserManager } from '../../helpers/test-users'; describe('Profiles RLS Policies', () => { const users = new TestUserManager(); let serviceClient: ReturnType; beforeAll(async () => { serviceClient = createServiceClient(); }); afterAll(async () => { await users.cleanup(); }); beforeEach(async () => { await resetDatabase(); }); describe('SELECT policies', () => { it('should allow anyone to view public profiles', async () => { const { userId } = await users.createUser(); // Create a public profile via service role await serviceClient.from('profiles').insert({ id: userId, username: 'publicuser', bio: 'Public bio', is_public: true, }); // Anonymous client should see the profile const anonClient = createAnonClient(); const { data, error } = await anonClient .from('profiles') .select('*') .eq('id', userId) .single(); expect(error).toBeNull(); expect(data).toBeDefined(); expect(data!.username).toBe('publicuser'); }); it('should hide private profiles from anonymous users', async () => { const { userId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: userId, username: 'privateuser', bio: 'Private bio', is_public: false, }); const anonClient = createAnonClient(); const { data } = await anonClient .from('profiles') .select('*') .eq('id', userId); expect(data).toEqual([]); }); it('should allow users to view their own private profile', async () => { const { client, userId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: userId, username: 'myprofile', bio: 'My private bio', is_public: false, }); const { data, error } = await client .from('profiles') .select('*') .eq('id', userId) .single(); expect(error).toBeNull(); expect(data!.username).toBe('myprofile'); }); it('should hide private profiles from other authenticated users', async () => { const { userId: ownerId } = await users.createUser(); const { client: otherClient } = await users.createUser(); await serviceClient.from('profiles').insert({ id: ownerId, username: 'secretuser', is_public: false, }); const { data } = await otherClient .from('profiles') .select('*') .eq('id', ownerId); expect(data).toEqual([]); }); }); describe('INSERT policies', () => { it('should allow users to create their own profile', async () => { const { client, userId } = await users.createUser(); const { error } = await client.from('profiles').insert({ id: userId, username: `user-${Date.now()}`, bio: 'My bio', }); expect(error).toBeNull(); }); it('should prevent users from creating profiles for other users', async () => { const { client } = await users.createUser(); const { userId: otherUserId } = await users.createUser(); const { error } = await client.from('profiles').insert({ id: otherUserId, username: 'impersonator', }); expect(error).not.toBeNull(); expect(error!.code).toBe('42501'); // RLS violation }); it('should prevent anonymous users from creating profiles', async () => { const anonClient = createAnonClient(); const { error } = await anonClient.from('profiles').insert({ id: '550e8400-e29b-41d4-a716-446655440000', username: 'anonymous', }); expect(error).not.toBeNull(); }); }); describe('UPDATE policies', () => { it('should allow users to update their own profile', async () => { const { client, userId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: userId, username: `user-${Date.now()}`, bio: 'Original bio', }); const { error } = await client .from('profiles') .update({ bio: 'Updated bio' }) .eq('id', userId); expect(error).toBeNull(); const { data } = await client .from('profiles') .select('bio') .eq('id', userId) .single(); expect(data!.bio).toBe('Updated bio'); }); it('should prevent users from updating other profiles', async () => { const { userId: ownerId } = await users.createUser(); const { client: attackerClient } = await users.createUser(); await serviceClient.from('profiles').insert({ id: ownerId, username: `target-${Date.now()}`, bio: 'Original', }); const { data } = await attackerClient .from('profiles') .update({ bio: 'Hacked!' }) .eq('id', ownerId) .select(); // RLS silently filters -- no rows affected expect(data).toEqual([]); // Verify original is unchanged const { data: original } = await serviceClient .from('profiles') .select('bio') .eq('id', ownerId) .single(); expect(original!.bio).toBe('Original'); }); }); describe('DELETE policies', () => { it('should allow admins to delete profiles', async () => { const { client: adminClient } = await users.createAdmin(); const { userId: targetId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: targetId, username: `delete-target-${Date.now()}`, }); // Admin needs their own profile for the policy check const adminUserId = (await adminClient.auth.getUser()).data.user!.id; await serviceClient.from('profiles').insert({ id: adminUserId, username: `admin-${Date.now()}`, }); const { error } = await adminClient .from('profiles') .delete() .eq('id', targetId); expect(error).toBeNull(); }); it('should prevent non-admin users from deleting profiles', async () => { const { client: regularClient } = await users.createUser(); const { userId: targetId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: targetId, username: `no-delete-${Date.now()}`, }); const { data } = await regularClient .from('profiles') .delete() .eq('id', targetId) .select(); // Should not delete anything expect(data).toEqual([]); }); }); }); ``` ### Posts RLS Tests ```typescript // __tests__/integration/rls/posts.test.ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createAnonClient, createServiceClient, } from '../../helpers/supabase-test-utils'; import { TestUserManager } from '../../helpers/test-users'; describe('Posts RLS Policies', () => { const users = new TestUserManager(); let serviceClient: ReturnType; beforeAll(() => { serviceClient = createServiceClient(); }); afterAll(async () => { await users.cleanup(); }); it('should allow anyone to see published posts', async () => { const { userId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: userId, username: `author-${Date.now()}`, }); await serviceClient.from('posts').insert({ author_id: userId, title: 'Published Post', content: 'This is public', published: true, }); const anonClient = createAnonClient(); const { data } = await anonClient .from('posts') .select('*') .eq('published', true); expect(data!.length).toBeGreaterThan(0); expect(data!.some((p) => p.title === 'Published Post')).toBe(true); }); it('should hide draft posts from anonymous users', async () => { const { userId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: userId, username: `draft-author-${Date.now()}`, }); const { data: insertedPost } = await serviceClient.from('posts').insert({ author_id: userId, title: 'Secret Draft', content: 'Not published', published: false, }).select().single(); const anonClient = createAnonClient(); const { data } = await anonClient .from('posts') .select('*') .eq('id', insertedPost!.id); expect(data).toEqual([]); }); it('should allow authors to see their own drafts', async () => { const { client, userId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: userId, username: `my-draft-author-${Date.now()}`, }); await serviceClient.from('posts').insert({ author_id: userId, title: 'My Draft', content: 'Work in progress', published: false, }); const { data } = await client .from('posts') .select('*') .eq('author_id', userId) .eq('published', false); expect(data!.length).toBeGreaterThan(0); expect(data![0].title).toBe('My Draft'); }); it('should prevent users from creating posts as another author', async () => { const { client } = await users.createUser(); const { userId: otherUserId } = await users.createUser(); const { error } = await client.from('posts').insert({ author_id: otherUserId, title: 'Impersonation Post', content: 'Not my account', }); expect(error).not.toBeNull(); }); it('should prevent users from updating other authors posts', async () => { const { userId: authorId } = await users.createUser(); const { client: attackerClient } = await users.createUser(); await serviceClient.from('profiles').insert({ id: authorId, username: `update-target-${Date.now()}`, }); const { data: post } = await serviceClient.from('posts').insert({ author_id: authorId, title: 'Original Title', content: 'Original content', published: true, }).select().single(); const { data } = await attackerClient .from('posts') .update({ title: 'Hacked Title' }) .eq('id', post!.id) .select(); expect(data).toEqual([]); }); }); ``` ## Auth Flow Testing ```typescript // __tests__/integration/auth-flow.test.ts import { describe, it, expect, afterAll } from 'vitest'; import { createAnonClient, createServiceClient } from '../helpers/supabase-test-utils'; describe('Supabase Auth Flows', () => { const createdUserIds: string[] = []; afterAll(async () => { const serviceClient = createServiceClient(); for (const id of createdUserIds) { await serviceClient.auth.admin.deleteUser(id).catch(() => {}); } }); describe('Email/Password Sign Up', () => { it('should sign up a new user with email and password', async () => { const client = createAnonClient(); const email = `signup-${Date.now()}@test.com`; const { data, error } = await client.auth.signUp({ email, password: 'TestPassword123!', }); expect(error).toBeNull(); expect(data.user).toBeDefined(); expect(data.user!.email).toBe(email); createdUserIds.push(data.user!.id); }); it('should reject signup with weak password', async () => { const client = createAnonClient(); const { error } = await client.auth.signUp({ email: `weak-${Date.now()}@test.com`, password: '123', }); expect(error).not.toBeNull(); }); it('should reject duplicate email signup', async () => { const client = createAnonClient(); const email = `dup-${Date.now()}@test.com`; // First signup const { data } = await client.auth.signUp({ email, password: 'TestPassword123!', }); createdUserIds.push(data.user!.id); // Second signup with same email const { data: dup, error } = await client.auth.signUp({ email, password: 'AnotherPassword123!', }); // Supabase returns a fake user for security (no error), but no session expect(dup.session).toBeNull(); }); }); describe('Email/Password Sign In', () => { it('should sign in with correct credentials', async () => { const serviceClient = createServiceClient(); const email = `signin-${Date.now()}@test.com`; const { data: created } = await serviceClient.auth.admin.createUser({ email, password: 'TestPassword123!', email_confirm: true, }); createdUserIds.push(created.user.id); const client = createAnonClient(); const { data, error } = await client.auth.signInWithPassword({ email, password: 'TestPassword123!', }); expect(error).toBeNull(); expect(data.session).toBeDefined(); expect(data.session!.access_token).toBeDefined(); expect(data.user!.email).toBe(email); }); it('should reject incorrect password', async () => { const serviceClient = createServiceClient(); const email = `wrongpw-${Date.now()}@test.com`; const { data: created } = await serviceClient.auth.admin.createUser({ email, password: 'CorrectPassword123!', email_confirm: true, }); createdUserIds.push(created.user.id); const client = createAnonClient(); const { error } = await client.auth.signInWithPassword({ email, password: 'WrongPassword123!', }); expect(error).not.toBeNull(); expect(error!.message).toContain('Invalid login credentials'); }); it('should reject non-existent email', async () => { const client = createAnonClient(); const { error } = await client.auth.signInWithPassword({ email: 'doesnotexist@test.com', password: 'TestPassword123!', }); expect(error).not.toBeNull(); }); }); describe('Session Management', () => { it('should return a valid session after sign in', async () => { const serviceClient = createServiceClient(); const email = `session-${Date.now()}@test.com`; const { data: created } = await serviceClient.auth.admin.createUser({ email, password: 'TestPassword123!', email_confirm: true, }); createdUserIds.push(created.user.id); const client = createAnonClient(); await client.auth.signInWithPassword({ email, password: 'TestPassword123!' }); const { data: sessionData } = await client.auth.getSession(); expect(sessionData.session).not.toBeNull(); expect(sessionData.session!.expires_at).toBeDefined(); }); it('should sign out and invalidate session', async () => { const serviceClient = createServiceClient(); const email = `signout-${Date.now()}@test.com`; const { data: created } = await serviceClient.auth.admin.createUser({ email, password: 'TestPassword123!', email_confirm: true, }); createdUserIds.push(created.user.id); const client = createAnonClient(); await client.auth.signInWithPassword({ email, password: 'TestPassword123!' }); const { error } = await client.auth.signOut(); expect(error).toBeNull(); const { data } = await client.auth.getSession(); expect(data.session).toBeNull(); }); }); describe('User Metadata', () => { it('should store and retrieve user metadata', async () => { const serviceClient = createServiceClient(); const email = `meta-${Date.now()}@test.com`; const { data: created } = await serviceClient.auth.admin.createUser({ email, password: 'TestPassword123!', email_confirm: true, user_metadata: { role: 'editor', displayName: 'Test Editor' }, }); createdUserIds.push(created.user.id); const client = createAnonClient(); await client.auth.signInWithPassword({ email, password: 'TestPassword123!' }); const { data } = await client.auth.getUser(); expect(data.user!.user_metadata.role).toBe('editor'); expect(data.user!.user_metadata.displayName).toBe('Test Editor'); }); it('should update user metadata', async () => { const serviceClient = createServiceClient(); const email = `update-meta-${Date.now()}@test.com`; const { data: created } = await serviceClient.auth.admin.createUser({ email, password: 'TestPassword123!', email_confirm: true, user_metadata: { displayName: 'Original' }, }); createdUserIds.push(created.user.id); const client = createAnonClient(); await client.auth.signInWithPassword({ email, password: 'TestPassword123!' }); await client.auth.updateUser({ data: { displayName: 'Updated' }, }); const { data } = await client.auth.getUser(); expect(data.user!.user_metadata.displayName).toBe('Updated'); }); }); }); ``` ## Realtime Subscription Testing ```typescript // __tests__/integration/realtime.test.ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createServiceClient } from '../helpers/supabase-test-utils'; import { TestUserManager } from '../helpers/test-users'; import type { RealtimeChannel } from '@supabase/supabase-js'; describe('Supabase Realtime Subscriptions', () => { const users = new TestUserManager(); let serviceClient: ReturnType; const channels: RealtimeChannel[] = []; beforeAll(() => { serviceClient = createServiceClient(); }); afterAll(async () => { // Unsubscribe all channels for (const channel of channels) { await channel.unsubscribe(); } await users.cleanup(); }); it('should receive INSERT events on subscribed table', async () => { const { client, userId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: userId, username: `realtime-${Date.now()}`, }); const receivedEvents: any[] = []; const channel = client .channel('posts-inserts') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' }, (payload) => receivedEvents.push(payload) ) .subscribe(); channels.push(channel); // Wait for subscription to be established await new Promise((resolve) => setTimeout(resolve, 1000)); // Insert a post via service client await serviceClient.from('posts').insert({ author_id: userId, title: 'Realtime Test Post', content: 'Testing realtime', published: true, }); // Wait for the event to arrive await new Promise((resolve) => setTimeout(resolve, 2000)); expect(receivedEvents.length).toBeGreaterThan(0); expect(receivedEvents[0].new.title).toBe('Realtime Test Post'); expect(receivedEvents[0].eventType).toBe('INSERT'); }); it('should receive UPDATE events', async () => { const { client, userId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: userId, username: `rt-update-${Date.now()}`, }); const { data: post } = await serviceClient.from('posts').insert({ author_id: userId, title: 'Before Update', content: 'Original', published: true, }).select().single(); const updateEvents: any[] = []; const channel = client .channel('posts-updates') .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'posts', filter: `id=eq.${post!.id}` }, (payload) => updateEvents.push(payload) ) .subscribe(); channels.push(channel); await new Promise((resolve) => setTimeout(resolve, 1000)); await serviceClient .from('posts') .update({ title: 'After Update' }) .eq('id', post!.id); await new Promise((resolve) => setTimeout(resolve, 2000)); expect(updateEvents.length).toBeGreaterThan(0); expect(updateEvents[0].new.title).toBe('After Update'); expect(updateEvents[0].old.title).toBe('Before Update'); }); it('should receive DELETE events', async () => { const { client, userId } = await users.createUser(); await serviceClient.from('profiles').insert({ id: userId, username: `rt-delete-${Date.now()}`, }); const { data: post } = await serviceClient.from('posts').insert({ author_id: userId, title: 'To Be Deleted', content: 'Bye', published: true, }).select().single(); const deleteEvents: any[] = []; const channel = client .channel('posts-deletes') .on( 'postgres_changes', { event: 'DELETE', schema: 'public', table: 'posts' }, (payload) => deleteEvents.push(payload) ) .subscribe(); channels.push(channel); await new Promise((resolve) => setTimeout(resolve, 1000)); await serviceClient.from('posts').delete().eq('id', post!.id); await new Promise((resolve) => setTimeout(resolve, 2000)); expect(deleteEvents.length).toBeGreaterThan(0); expect(deleteEvents[0].old.id).toBe(post!.id); }); it('should handle channel disconnection gracefully', async () => { const { client } = await users.createUser(); const channel = client .channel('disconnect-test') .on('postgres_changes', { event: '*', schema: 'public', table: 'posts' }, () => {}) .subscribe(); channels.push(channel); await new Promise((resolve) => setTimeout(resolve, 500)); // Unsubscribe const status = await channel.unsubscribe(); expect(status).toBe('ok'); }); }); ``` ## Storage Bucket Testing ```typescript // __tests__/integration/storage.test.ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createServiceClient } from '../helpers/supabase-test-utils'; import { TestUserManager } from '../helpers/test-users'; describe('Supabase Storage', () => { const users = new TestUserManager(); let serviceClient: ReturnType; beforeAll(async () => { serviceClient = createServiceClient(); // Ensure test bucket exists await serviceClient.storage.createBucket('avatars', { public: false, fileSizeLimit: 1024 * 1024, // 1MB allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'], }).catch(() => {}); // Ignore if already exists }); afterAll(async () => { await users.cleanup(); }); it('should allow authenticated users to upload to their folder', async () => { const { client, userId } = await users.createUser(); const file = new Blob(['fake image data'], { type: 'image/png' }); const { data, error } = await client.storage .from('avatars') .upload(`${userId}/avatar.png`, file, { contentType: 'image/png', }); expect(error).toBeNull(); expect(data!.path).toBe(`${userId}/avatar.png`); }); it('should prevent users from uploading to other user folders', async () => { const { client } = await users.createUser(); const { userId: otherUserId } = await users.createUser(); const file = new Blob(['fake data'], { type: 'image/png' }); const { error } = await client.storage .from('avatars') .upload(`${otherUserId}/malicious.png`, file, { contentType: 'image/png', }); expect(error).not.toBeNull(); }); it('should enforce file size limits', async () => { const { client, userId } = await users.createUser(); // Create a file larger than 1MB limit const largeFile = new Blob([new ArrayBuffer(2 * 1024 * 1024)], { type: 'image/png' }); const { error } = await client.storage .from('avatars') .upload(`${userId}/too-large.png`, largeFile); expect(error).not.toBeNull(); }); it('should enforce allowed MIME types', async () => { const { client, userId } = await users.createUser(); const file = new Blob(['not an image'], { type: 'application/pdf' }); const { error } = await client.storage .from('avatars') .upload(`${userId}/doc.pdf`, file, { contentType: 'application/pdf', }); expect(error).not.toBeNull(); }); it('should generate signed URLs for private files', async () => { const { client, userId } = await users.createUser(); const file = new Blob(['test data'], { type: 'image/png' }); await client.storage .from('avatars') .upload(`${userId}/signed-test.png`, file, { contentType: 'image/png' }); const { data, error } = await client.storage .from('avatars') .createSignedUrl(`${userId}/signed-test.png`, 60); expect(error).toBeNull(); expect(data!.signedUrl).toContain('token='); }); }); ``` ## Edge Function Testing ```typescript // supabase/functions/hello-world/index.ts import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; serve(async (req) => { try { const { name } = await req.json(); if (!name) { return new Response( JSON.stringify({ error: 'Name is required' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } // Verify auth const authHeader = req.headers.get('Authorization'); if (!authHeader) { return new Response( JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: authHeader } } } ); const { data: { user } } = await supabase.auth.getUser(); return new Response( JSON.stringify({ message: `Hello ${name}!`, userId: user?.id }), { status: 200, headers: { 'Content-Type': 'application/json' } } ); } catch (error) { return new Response( JSON.stringify({ error: 'Internal error' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } }); ``` ```typescript // __tests__/integration/edge-functions.test.ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createTestUser, cleanupTestUser } from '../helpers/supabase-test-utils'; const FUNCTIONS_URL = 'http://127.0.0.1:54321/functions/v1'; describe('Edge Function: hello-world', () => { let accessToken: string; let userId: string; beforeAll(async () => { const user = await createTestUser(); userId = user.userId; const { data } = await user.client.auth.getSession(); accessToken = data.session!.access_token; }); afterAll(async () => { await cleanupTestUser(userId); }); it('should return greeting with user info', async () => { const response = await fetch(`${FUNCTIONS_URL}/hello-world`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ name: 'World' }), }); expect(response.status).toBe(200); const data = await response.json(); expect(data.message).toBe('Hello World!'); expect(data.userId).toBe(userId); }); it('should return 400 when name is missing', async () => { const response = await fetch(`${FUNCTIONS_URL}/hello-world`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({}), }); expect(response.status).toBe(400); const data = await response.json(); expect(data.error).toBe('Name is required'); }); it('should return 401 without auth header', async () => { const response = await fetch(`${FUNCTIONS_URL}/hello-world`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'World' }), }); expect(response.status).toBe(401); }); }); ``` ## Database Migration Testing ```typescript // supabase/tests/migrations/migration.test.ts import { describe, it, expect, beforeAll } from 'vitest'; import { createServiceClient } from '../../__tests__/helpers/supabase-test-utils'; describe('Database Migration Verification', () => { let serviceClient: ReturnType; beforeAll(() => { serviceClient = createServiceClient(); }); it('should have profiles table with correct columns', async () => { const { data, error } = await serviceClient.rpc('get_table_columns', { p_table: 'profiles', }); // Alternatively, query information_schema directly const { data: columns } = await serviceClient .from('information_schema.columns' as any) .select('column_name, data_type, is_nullable') .eq('table_name', 'profiles') .eq('table_schema', 'public'); const columnNames = columns!.map((c: any) => c.column_name); expect(columnNames).toContain('id'); expect(columnNames).toContain('username'); expect(columnNames).toContain('bio'); expect(columnNames).toContain('avatar_url'); expect(columnNames).toContain('is_public'); expect(columnNames).toContain('created_at'); }); it('should have RLS enabled on all tables', async () => { const { data: tables } = await serviceClient.rpc('check_rls_enabled'); // All public tables should have RLS enabled const publicTables = ['profiles', 'posts', 'comments']; for (const table of publicTables) { const tableInfo = tables?.find((t: any) => t.tablename === table); expect(tableInfo?.rowsecurity, `RLS not enabled on ${table}`).toBe(true); } }); it('should have correct foreign key relationships', async () => { // Verify posts.author_id references profiles.id const { data: fks } = await serviceClient .from('information_schema.referential_constraints' as any) .select('constraint_name') .eq('constraint_schema', 'public'); expect(fks!.length).toBeGreaterThan(0); }); }); ``` ## Best Practices 1. **Always use `supabase start` for local testing** -- Never test against production or staging Supabase instances. Local testing is fast, isolated, and free. 2. **Test every RLS policy with at least 3 roles** -- For each policy, test as the resource owner, a different authenticated user, and an anonymous user. 3. **Use the service role client for test setup only** -- Create test data with the service role client, but run actual tests with anon or authenticated clients to verify RLS. 4. **Clean up test users after each suite** -- Use a TestUserManager that tracks created users and deletes them in afterAll to prevent test database bloat. 5. **Disable email confirmation for test environments** -- Set `enable_confirmations = false` in the local config to avoid needing to handle email verification in tests. 6. **Test RLS with both SELECT and the actual operation** -- A user may be able to SELECT a row but not UPDATE or DELETE it. Test each operation independently. 7. **Verify RLS failures are silent, not errors** -- Supabase RLS violations on UPDATE/DELETE return empty results, not errors. Assert on the returned data being empty. 8. **Test realtime with proper timeouts** -- Realtime events have latency. Use adequate waits (1-2 seconds) between subscribing and expecting events. 9. **Use unique identifiers in test data** -- Include timestamps or random strings in test usernames and emails to prevent collisions between parallel test runs. 10. **Test storage policies separately from application logic** -- Storage bucket policies are independent of table RLS. Write dedicated storage tests. ## Anti-Patterns to Avoid 1. **Testing against production Supabase** -- Production testing creates real data, costs money, and risks exposing test users. Always use local instances. 2. **Using the service role client in application tests** -- The service role bypasses RLS entirely. If your tests use it for data access, they cannot verify security policies. 3. **Not testing RLS at all** -- Many teams skip RLS testing because it requires user management. This is the most critical security boundary in a Supabase app. 4. **Sharing test users between test files** -- Parallel test execution with shared users causes race conditions. Each test file should create its own users. 5. **Testing only SELECT policies** -- INSERT, UPDATE, and DELETE policies have different USING and WITH CHECK clauses. Test each operation type. 6. **Ignoring the difference between USING and WITH CHECK** -- USING filters rows you can see, WITH CHECK validates the new row on INSERT/UPDATE. Both need testing. 7. **Hardcoding Supabase keys in test files** -- Use environment variables or read from the local Supabase config. Hardcoded keys get committed to version control. 8. **Not testing auth edge cases** -- Token expiry, session refresh, and concurrent sessions are common sources of production bugs. Test these flows. 9. **Skipping migration rollback tests** -- Only testing the `up` migration is not enough. Verify that `down` migrations cleanly reverse schema changes. 10. **Testing realtime without actually subscribing** -- Mocking the realtime client misses connection issues, reconnection logic, and message ordering problems. ## Running Tests - Start local Supabase: `supabase start` - Run all tests: `pnpm vitest run` - Run RLS tests: `pnpm vitest run __tests__/integration/rls` - Run auth tests: `pnpm vitest run __tests__/integration/auth-flow.test.ts` - Run realtime tests: `pnpm vitest run __tests__/integration/realtime.test.ts` - Run edge function tests: `supabase functions serve && pnpm vitest run __tests__/integration/edge-functions.test.ts` - Reset local database: `supabase db reset` - View local Supabase dashboard: `open http://127.0.0.1:54323` - Stop local Supabase: `supabase stop`