Qaskills Supabase Testing
Testing patterns for Supabase applications covering auth flow testing, Row Level Security policy testing, realtime subscription testing, and edge function testing
install
source · Clone the upstream repo
git clone https://github.com/PramodDutta/qaskills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/PramodDutta/qaskills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/seed-skills/supabase-testing" ~/.claude/skills/pramoddutta-qaskills-supabase-testing && rm -rf "$T"
manifest:
seed-skills/supabase-testing/SKILL.mdtags
source content
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
- Local-first testing -- Always use Supabase CLI (
) to run a local Postgres instance with all Supabase features. Never test directly against production.supabase start - 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.
- 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.
- Realtime requires connection testing -- Subscription tests must establish actual WebSocket connections. Mock-only tests miss critical reconnection and conflict scenarios.
- Edge functions are isolated -- Supabase Edge Functions run in Deno. Test them independently with their own test runner before integration testing.
- Storage policies mirror RLS -- Storage bucket policies follow the same RLS pattern. Apply the same testing rigor to file access as to row access.
- 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
# 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
// __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<SupabaseClient> { 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<string, unknown> ): 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<void> { const serviceClient = createServiceClient(); await serviceClient.auth.admin.deleteUser(userId); } // Reset database to clean state export async function resetDatabase(): Promise<void> { 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'); }
// __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<string, unknown> = {} ): Promise<TestUserContext> { const user = await createTestUser(undefined, undefined, { role, ...metadata }); this.users.push(user); return user; } async createAdmin(): Promise<TestUserContext> { return this.createUser('admin'); } async createModerator(): Promise<TestUserContext> { return this.createUser('moderator'); } async cleanup(): Promise<void> { for (const user of this.users) { await cleanupTestUser(user.userId).catch(() => {}); } this.users = []; } }
Row Level Security (RLS) Testing
Schema and Policies
-- 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' ) );
-- 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
// __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<typeof createServiceClient>; 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
// __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<typeof createServiceClient>; 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
// __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
// __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<typeof createServiceClient>; 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
// __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<typeof createServiceClient>; 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
// 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' } } ); } });
// __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
// 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<typeof createServiceClient>; 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
- Always use
for local testing -- Never test against production or staging Supabase instances. Local testing is fast, isolated, and free.supabase start - 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.
- 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.
- Clean up test users after each suite -- Use a TestUserManager that tracks created users and deletes them in afterAll to prevent test database bloat.
- Disable email confirmation for test environments -- Set
in the local config to avoid needing to handle email verification in tests.enable_confirmations = false - 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.
- 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.
- Test realtime with proper timeouts -- Realtime events have latency. Use adequate waits (1-2 seconds) between subscribing and expecting events.
- Use unique identifiers in test data -- Include timestamps or random strings in test usernames and emails to prevent collisions between parallel test runs.
- Test storage policies separately from application logic -- Storage bucket policies are independent of table RLS. Write dedicated storage tests.
Anti-Patterns to Avoid
- Testing against production Supabase -- Production testing creates real data, costs money, and risks exposing test users. Always use local instances.
- 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.
- 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.
- Sharing test users between test files -- Parallel test execution with shared users causes race conditions. Each test file should create its own users.
- Testing only SELECT policies -- INSERT, UPDATE, and DELETE policies have different USING and WITH CHECK clauses. Test each operation type.
- 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.
- Hardcoding Supabase keys in test files -- Use environment variables or read from the local Supabase config. Hardcoded keys get committed to version control.
- Not testing auth edge cases -- Token expiry, session refresh, and concurrent sessions are common sources of production bugs. Test these flows.
- Skipping migration rollback tests -- Only testing the
migration is not enough. Verify thatup
migrations cleanly reverse schema changes.down - 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