Supabase Testing: Unit Tests and Integration Testing

Testing Supabase applications ensures code reliability, prevents regressions, validates business logic, and maintains application quality through unit tests verifying individual functions, integration tests validating database operations, end-to-end tests simulating user workflows, and mocking strategies isolating components. Unlike untested applications breaking unexpectedly with schema changes, authentication failures, or data corruption causing production incidents, comprehensive test suites catch bugs early, document expected behavior, enable confident refactoring, and automate quality assurance throughout development lifecycle. This comprehensive guide covers setting up testing environments with Jest and Vitest, writing unit tests for database queries and authentication, implementing integration tests validating RLS policies, mocking Supabase client for isolated tests, testing real-time subscriptions, validating Edge Functions, using test databases for isolation, implementing continuous integration pipelines, and measuring test coverage. Testing becomes essential when building production applications requiring reliability, working in teams needing quality standards, implementing critical business logic, or maintaining legacy codebases preventing regressions. Before proceeding, understand database queries, authentication, and migrations.
Testing Environment Setup
# Install testing dependencies
npm install -D jest @jest/globals @types/jest
npm install -D @testing-library/react @testing-library/jest-dom
npm install -D @supabase/supabase-js
# Or use Vitest (faster alternative)
npm install -D vitest @vitest/ui
npm install -D @testing-library/react @testing-library/user-event
# Project structure
project/
src/
lib/
supabase.ts
utils/
queries.ts
components/
PostsList.tsx
tests/
unit/
queries.test.ts
auth.test.ts
integration/
database.test.ts
rls.test.ts
e2e/
user-flow.test.ts
mocks/
supabase.ts
setup.ts
jest.config.js
vitest.config.ts
# jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{js,ts,tsx}',
'!src/**/*.d.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
}
# vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
coverage: {
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules/', 'tests/'],
},
},
})Unit Testing Database Queries
// tests/mocks/supabase.ts - Mock Supabase client
export const mockSupabase = {
from: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
single: jest.fn(),
order: jest.fn().mockReturnThis(),
})),
auth: {
signUp: jest.fn(),
signInWithPassword: jest.fn(),
signOut: jest.fn(),
getUser: jest.fn(),
},
}
// src/utils/queries.ts - Functions to test
import { supabase } from '../lib/supabase'
export async function getPosts() {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data
}
export async function createPost(title: string, content: string) {
const { data, error } = await supabase
.from('posts')
.insert({ title, content })
.select()
.single()
if (error) throw error
return data
}
export async function updatePost(id: string, updates: any) {
const { data, error } = await supabase
.from('posts')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) throw error
return data
}
// tests/unit/queries.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getPosts, createPost, updatePost } from '@/utils/queries'
import * as supabaseModule from '@/lib/supabase'
import { mockSupabase } from '../mocks/supabase'
// Mock the Supabase client
vi.mock('@/lib/supabase', () => ({
supabase: mockSupabase,
}))
describe('Database Queries', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getPosts', () => {
it('should fetch all posts ordered by created_at', async () => {
const mockPosts = [
{ id: '1', title: 'Post 1', content: 'Content 1' },
{ id: '2', title: 'Post 2', content: 'Content 2' },
]
mockSupabase.from().select().order = vi.fn().mockResolvedValue({
data: mockPosts,
error: null,
})
const posts = await getPosts()
expect(mockSupabase.from).toHaveBeenCalledWith('posts')
expect(posts).toEqual(mockPosts)
expect(posts).toHaveLength(2)
})
it('should throw error when query fails', async () => {
const mockError = new Error('Database error')
mockSupabase.from().select().order = vi.fn().mockResolvedValue({
data: null,
error: mockError,
})
await expect(getPosts()).rejects.toThrow('Database error')
})
})
describe('createPost', () => {
it('should create a new post', async () => {
const newPost = { id: '3', title: 'New Post', content: 'New Content' }
mockSupabase.from().insert().select().single = vi.fn().mockResolvedValue({
data: newPost,
error: null,
})
const result = await createPost('New Post', 'New Content')
expect(mockSupabase.from).toHaveBeenCalledWith('posts')
expect(result).toEqual(newPost)
})
it('should throw error on invalid data', async () => {
const mockError = { message: 'Title is required' }
mockSupabase.from().insert().select().single = vi.fn().mockResolvedValue({
data: null,
error: mockError,
})
await expect(createPost('', 'Content')).rejects.toThrow()
})
})
})Integration Testing with Test Database
// tests/setup.ts - Test database setup
import { createClient } from '@supabase/supabase-js'
// Use separate test database
const supabaseUrl = process.env.VITE_TEST_SUPABASE_URL!
const supabaseKey = process.env.VITE_TEST_SUPABASE_ANON_KEY!
export const testSupabase = createClient(supabaseUrl, supabaseKey)
// Setup and teardown helpers
export async function setupTestData() {
// Insert test data
await testSupabase.from('posts').insert([
{ title: 'Test Post 1', content: 'Content 1', published: true },
{ title: 'Test Post 2', content: 'Content 2', published: false },
])
}
export async function cleanupTestData() {
// Clean all test data
await testSupabase.from('posts').delete().neq('id', '00000000-0000-0000-0000-000000000000')
await testSupabase.from('users').delete().neq('id', '00000000-0000-0000-0000-000000000000')
}
// tests/integration/database.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { testSupabase, setupTestData, cleanupTestData } from '../setup'
describe('Database Integration Tests', () => {
beforeAll(async () => {
await cleanupTestData()
await setupTestData()
})
afterAll(async () => {
await cleanupTestData()
})
it('should fetch posts from real database', async () => {
const { data, error } = await testSupabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
expect(error).toBeNull()
expect(data).toBeDefined()
expect(data.length).toBeGreaterThan(0)
})
it('should filter published posts', async () => {
const { data, error } = await testSupabase
.from('posts')
.select('*')
.eq('published', true)
expect(error).toBeNull()
expect(data).toBeDefined()
expect(data.every(post => post.published)).toBe(true)
})
it('should create and retrieve post', async () => {
const newPost = {
title: 'Integration Test Post',
content: 'Test content',
published: true,
}
// Create
const { data: created, error: createError } = await testSupabase
.from('posts')
.insert(newPost)
.select()
.single()
expect(createError).toBeNull()
expect(created).toMatchObject(newPost)
// Retrieve
const { data: retrieved, error: getError } = await testSupabase
.from('posts')
.select('*')
.eq('id', created.id)
.single()
expect(getError).toBeNull()
expect(retrieved).toMatchObject(newPost)
})
it('should update post', async () => {
const { data: post } = await testSupabase
.from('posts')
.select('*')
.limit(1)
.single()
const updates = { title: 'Updated Title' }
const { data: updated, error } = await testSupabase
.from('posts')
.update(updates)
.eq('id', post.id)
.select()
.single()
expect(error).toBeNull()
expect(updated.title).toBe('Updated Title')
})
it('should delete post', async () => {
const { data: post } = await testSupabase
.from('posts')
.select('*')
.limit(1)
.single()
const { error } = await testSupabase
.from('posts')
.delete()
.eq('id', post.id)
expect(error).toBeNull()
// Verify deletion
const { data: deleted } = await testSupabase
.from('posts')
.select('*')
.eq('id', post.id)
.single()
expect(deleted).toBeNull()
})
})Testing Row Level Security Policies
// tests/integration/rls.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.VITE_TEST_SUPABASE_URL!
const supabaseAnonKey = process.env.VITE_TEST_SUPABASE_ANON_KEY!
describe('Row Level Security Policies', () => {
let userAClient: any
let userBClient: any
let userAId: string
let userBId: string
beforeAll(async () => {
// Create User A
const clientA = createClient(supabaseUrl, supabaseAnonKey)
const { data: userA } = await clientA.auth.signUp({
email: '[email protected]',
password: 'password123',
})
userAId = userA.user!.id
userAClient = clientA
// Create User B
const clientB = createClient(supabaseUrl, supabaseAnonKey)
const { data: userB } = await clientB.auth.signUp({
email: '[email protected]',
password: 'password123',
})
userBId = userB.user!.id
userBClient = clientB
})
afterAll(async () => {
// Cleanup users
await userAClient.auth.signOut()
await userBClient.auth.signOut()
})
it('should allow users to create their own posts', async () => {
const { data, error } = await userAClient
.from('posts')
.insert({
title: 'User A Post',
content: 'Content',
})
.select()
.single()
expect(error).toBeNull()
expect(data).toBeDefined()
expect(data.user_id).toBe(userAId)
})
it('should prevent users from viewing other users private posts', async () => {
// User A creates private post
const { data: privatePost } = await userAClient
.from('posts')
.insert({
title: 'Private Post',
content: 'Private content',
published: false,
})
.select()
.single()
// User B tries to access
const { data, error } = await userBClient
.from('posts')
.select('*')
.eq('id', privatePost.id)
.single()
// Should return null or error due to RLS
expect(data).toBeNull()
})
it('should allow users to view published posts', async () => {
// User A creates published post
const { data: publishedPost } = await userAClient
.from('posts')
.insert({
title: 'Published Post',
content: 'Public content',
published: true,
})
.select()
.single()
// User B should see it
const { data, error } = await userBClient
.from('posts')
.select('*')
.eq('id', publishedPost.id)
.single()
expect(error).toBeNull()
expect(data).toBeDefined()
expect(data.title).toBe('Published Post')
})
it('should prevent users from updating other users posts', async () => {
// User A creates post
const { data: post } = await userAClient
.from('posts')
.insert({ title: 'Original', content: 'Content' })
.select()
.single()
// User B tries to update
const { error } = await userBClient
.from('posts')
.update({ title: 'Hacked' })
.eq('id', post.id)
// Should fail
expect(error).toBeDefined()
// Verify not updated
const { data: unchanged } = await userAClient
.from('posts')
.select('*')
.eq('id', post.id)
.single()
expect(unchanged.title).toBe('Original')
})
it('should prevent users from deleting other users posts', async () => {
// User A creates post
const { data: post } = await userAClient
.from('posts')
.insert({ title: 'To Delete', content: 'Content' })
.select()
.single()
// User B tries to delete
const { error } = await userBClient
.from('posts')
.delete()
.eq('id', post.id)
// Should fail
expect(error).toBeDefined()
// Verify still exists
const { data: stillExists } = await userAClient
.from('posts')
.select('*')
.eq('id', post.id)
.single()
expect(stillExists).toBeDefined()
})
})Testing Authentication Flows
// tests/integration/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.VITE_TEST_SUPABASE_URL!,
process.env.VITE_TEST_SUPABASE_ANON_KEY!
)
describe('Authentication', () => {
const testEmail = `test${Date.now()}@example.com`
const testPassword = 'SecurePassword123!'
beforeEach(async () => {
await supabase.auth.signOut()
})
describe('Sign Up', () => {
it('should create a new user account', async () => {
const { data, error } = await supabase.auth.signUp({
email: testEmail,
password: testPassword,
})
expect(error).toBeNull()
expect(data.user).toBeDefined()
expect(data.user?.email).toBe(testEmail)
expect(data.session).toBeDefined()
})
it('should reject weak passwords', async () => {
const { error } = await supabase.auth.signUp({
email: '[email protected]',
password: '123', // Too weak
})
expect(error).toBeDefined()
expect(error?.message).toContain('password')
})
it('should reject duplicate email', async () => {
// First signup
await supabase.auth.signUp({
email: testEmail,
password: testPassword,
})
// Duplicate signup
const { error } = await supabase.auth.signUp({
email: testEmail,
password: testPassword,
})
expect(error).toBeDefined()
})
})
describe('Sign In', () => {
beforeEach(async () => {
// Create user for testing
await supabase.auth.signUp({
email: testEmail,
password: testPassword,
})
await supabase.auth.signOut()
})
it('should sign in with valid credentials', async () => {
const { data, error } = await supabase.auth.signInWithPassword({
email: testEmail,
password: testPassword,
})
expect(error).toBeNull()
expect(data.user).toBeDefined()
expect(data.session).toBeDefined()
expect(data.session?.access_token).toBeDefined()
})
it('should reject invalid password', async () => {
const { error } = await supabase.auth.signInWithPassword({
email: testEmail,
password: 'WrongPassword123!',
})
expect(error).toBeDefined()
expect(error?.message).toContain('Invalid')
})
it('should reject non-existent email', async () => {
const { error } = await supabase.auth.signInWithPassword({
email: '[email protected]',
password: testPassword,
})
expect(error).toBeDefined()
})
})
describe('Session Management', () => {
it('should maintain session after sign in', async () => {
await supabase.auth.signUp({
email: testEmail,
password: testPassword,
})
const { data: { session } } = await supabase.auth.getSession()
expect(session).toBeDefined()
expect(session?.user).toBeDefined()
})
it('should clear session after sign out', async () => {
await supabase.auth.signUp({
email: testEmail,
password: testPassword,
})
await supabase.auth.signOut()
const { data: { session } } = await supabase.auth.getSession()
expect(session).toBeNull()
})
})
})Testing React Components
// tests/unit/PostsList.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PostsList } from '@/components/PostsList'
import * as supabaseModule from '@/lib/supabase'
// Mock Supabase
const mockSupabase = {
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({
data: [
{ id: '1', title: 'Post 1', content: 'Content 1' },
{ id: '2', title: 'Post 2', content: 'Content 2' },
],
error: null,
}),
})),
}
vi.mock('@/lib/supabase', () => ({
supabase: mockSupabase,
}))
describe('PostsList Component', () => {
it('should render loading state initially', () => {
render(<PostsList />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
it('should fetch and display posts', async () => {
render(<PostsList />)
await waitFor(() => {
expect(screen.getByText('Post 1')).toBeInTheDocument()
expect(screen.getByText('Post 2')).toBeInTheDocument()
})
expect(mockSupabase.from).toHaveBeenCalledWith('posts')
})
it('should display error message on fetch failure', async () => {
mockSupabase.from = vi.fn(() => ({
select: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({
data: null,
error: { message: 'Database error' },
}),
}))
render(<PostsList />)
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
})
it('should handle empty posts list', async () => {
mockSupabase.from = vi.fn(() => ({
select: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({
data: [],
error: null,
}),
}))
render(<PostsList />)
await waitFor(() => {
expect(screen.getByText(/no posts/i)).toBeInTheDocument()
})
})
})Testing Best Practices
- Use Separate Test Database: Never run tests against production database to prevent data corruption
- Clean Up After Tests: Remove test data in afterAll or afterEach hooks maintaining clean state
- Test RLS Policies: Verify Row Level Security preventing unauthorized access with real user accounts
- Mock External Dependencies: Mock Supabase client for unit tests isolating component logic
- Test Edge Cases: Include tests for empty states, errors, and boundary conditions
- Maintain Coverage: Aim for 80%+ code coverage ensuring critical paths are tested
- Automate CI/CD: Run tests automatically on every commit preventing regressions
supabase db reset between test suites for clean state. Combine with local development workflow.Continuous Integration Setup
# .github/workflows/test.yml
name: Run Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: supabase/postgres:15.1.0.54
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Setup Supabase CLI
uses: supabase/setup-cli@v1
with:
version: latest
- name: Start Supabase local
run: supabase start
- name: Run migrations
run: supabase db reset
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
env:
VITE_TEST_SUPABASE_URL: ${{ secrets.TEST_SUPABASE_URL }}
VITE_TEST_SUPABASE_ANON_KEY: ${{ secrets.TEST_SUPABASE_ANON_KEY }}
- name: Generate coverage report
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
- name: Stop Supabase
run: supabase stop
# package.json scripts
{
"scripts": {
"test": "vitest",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest watch"
}
}Common Testing Issues
- Tests Fail in CI: Ensure test database credentials are set in GitHub secrets or CI environment
- RLS Tests Always Pass: Verify RLS is enabled and policies are correct in test database
- Flaky Tests: Clean up data properly and avoid relying on specific database state
- Timeout Errors: Increase test timeout for slow database operations or network requests
Next Steps
- Setup Local Testing: Use local development for isolated testing
- Test Edge Functions: Validate serverless functions locally
- Build Applications: Apply testing to React or Next.js apps
- Learn TypeScript: Add type-safe testing
Conclusion
Testing Supabase applications ensures reliability, prevents regressions, and maintains quality through unit tests verifying individual functions, integration tests validating database operations with real Supabase instances, RLS tests confirming security policies, and component tests ensuring UI behavior. By setting up testing environments with Jest or Vitest, writing unit tests mocking Supabase client for isolation, implementing integration tests against test databases, validating Row Level Security with multiple user contexts, testing authentication flows including sign-up and login, creating component tests for React applications, automating CI/CD pipelines running tests on every commit, and maintaining code coverage above 80%, you build production-ready applications. Testing strategies include using separate test databases preventing production corruption, cleaning up data after tests maintaining isolation, testing RLS policies with real user accounts, mocking external dependencies for unit tests, covering edge cases and error scenarios, measuring coverage ensuring critical paths tested, and automating continuous integration catching bugs early. Always use dedicated test database never production, clean up thoroughly preventing flaky tests, test RLS comprehensively verifying security, mock appropriately balancing isolation and integration, cover error scenarios not just happy paths, maintain high coverage protecting against regressions, and automate everything running tests on every change. Testing becomes essential for production applications, team collaboration maintaining quality standards, critical business logic requiring validation, and legacy codebases preventing breaking changes. Continue building with migrations, local development, and performance optimization.
$ share --platform
$ cat /comments/ (0)
$ cat /comments/
// No comments found. Be the first!


