Every React Native + Supabase tutorial ends the same way. The app works in the simulator. Tests are left as an exercise for the reader.
This one is different.
I recently published react-native-expo-supabase-starter, a production-ready starter extracted from ReptiDex, a mobile app I built and launched solo. 50 paid subscribers and 200 animals tracked within 9 days of launch. The starter includes the full auth flow, a RevenueCat subscription system, TanStack Query, and Zustand. But the part most people ask about is the testing setup.
Specifically: how do you actually test Supabase queries in Jest without a running database or a fake HTTP server?
Here's the problem and how I solved it.
The Problem
Supabase queries are fluent chains:
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single()
A naive jest.mock('@supabase/supabase-js') breaks because each method in the chain returns a new object. The mock returns undefined partway down the chain and you get cannot read properties of undefined errors that look nothing like what actually went wrong.
The internet's solution is usually one of three things:
Option 1: Hit a real test database. This works but it's slow, requires a running Supabase instance, and makes tests order-dependent. Not unit tests.
Option 2: MSW to intercept at the HTTP layer. This works better, but it's heavy setup for testing individual service methods. You're essentially running a fake HTTP server to test a function that calls one table.
Option 3: A flat jest.mock that returns the same object from every method. This is what most Stack Overflow answers suggest. It looks like this:
jest.mock('@supabase/supabase-js', () => ({
createClient: () => ({
from: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
single: jest.fn().mockResolvedValue({ data: mockData, error: null }),
})),
}),
}))
The problem with this approach is that it's module-level. You can't change the resolved value per test without resetting the entire mock. And it uses mockReturnThis(), which returns the mock function itself rather than the chain object, so verify() assertions on specific methods become unreliable.
None of these options are what you actually want for unit testing service methods. What you want is to inject a mock client and control exactly what each query resolves to, per test.
The Fix: Dependency Injection and a Chainable Mock Helper
Two things work together here.
First: inject the Supabase client into your services rather than importing it directly. This makes the client swappable in tests without module-level mocking.
// src/services/profile-service.ts
import { SupabaseClient } from '@supabase/supabase-js'
import { Database } from '../types/database'
import { Profile, ProfileUpdate } from '../types'
export class ProfileService {
constructor(private client: SupabaseClient<Database>) {}
async getProfile(userId: string): Promise<Profile> {
const { data, error } = await this.client
.from('profiles')
.select('*')
.eq('id', userId)
.single()
if (error) throw error
return data
}
async updateProfile(userId: string, updates: ProfileUpdate): Promise<Profile> {
const { data, error } = await this.client
.from('profiles')
.update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', userId)
.select()
.single()
if (error) throw error
return data
}
}
In production code you instantiate it with the real client:
const profileService = new ProfileService(supabase)
In tests you pass in a mock. No module patching needed.
Second: build a chainable mock helper that correctly returns the same chain object from every method, and resolves at the terminal call.
// tests/helpers/supabase-chain-mock.ts
export function createSupabaseChainMock(resolvedValue: unknown, error?: unknown) {
const chain: Record<string, jest.Mock> = {}
const terminal = error
? jest.fn().mockRejectedValue(error)
: jest.fn().mockResolvedValue({ data: resolvedValue, error: null })
const returnChain = jest.fn().mockReturnValue(chain)
// Filter/builder methods: all return the chain for continued chaining
chain.select = returnChain
chain.insert = returnChain
chain.update = returnChain
chain.delete = returnChain
chain.upsert = returnChain
chain.eq = returnChain
chain.neq = returnChain
chain.gt = returnChain
chain.gte = returnChain
chain.lt = returnChain
chain.lte = returnChain
chain.like = returnChain
chain.ilike = returnChain
chain.in = returnChain
chain.is = returnChain
chain.order = returnChain
chain.limit = returnChain
chain.range = returnChain
chain.filter = returnChain
chain.match = returnChain
chain.not = returnChain
chain.or = returnChain
chain.contains = returnChain
chain.containedBy = returnChain
chain.overlaps = returnChain
// Terminal calls: resolve the chain
chain.single = terminal
chain.maybeSingle = terminal
chain.then = jest.fn((resolve: (val: unknown) => void) =>
Promise.resolve({ data: resolvedValue, error: null }).then(resolve)
)
return {
from: jest.fn().mockReturnValue(chain),
chain,
}
}
The key insight is that returnChain always returns the same chain object. No matter how long the query chain is, every method in it is a mock you can make assertions against. The terminal calls (.single(), .maybeSingle(), and .then() for bare awaits) are where you inject the resolved value or error.
Data Factories Keep Tests Readable
The other pattern that makes a real difference is factory functions for test data. Instead of building raw objects inline, you write a function with typed defaults and override only what the test cares about.
// tests/helpers/factories/profile.factory.ts
import { Profile } from '../../../src/types'
export function createMockProfile(overrides: Partial<Profile> = {}): Profile {
return {
id: 'user-uuid-1',
updated_at: '2024-01-01T00:00:00Z',
username: 'testuser',
full_name: 'Test User',
avatar_url: null,
subscription_tier: 'free',
...overrides,
}
}
Every field has a sensible default. The test only specifies the fields that are relevant to what it's testing.
What Tests Actually Look Like
Put it together and your tests become precise and readable:
// tests/services/profile-service.test.ts
import { ProfileService } from '../../src/services/profile-service'
import { createSupabaseChainMock } from '../helpers/supabase-chain-mock'
import { createMockProfile } from '../helpers/factories/profile.factory'
describe('ProfileService', () => {
describe('getProfile', () => {
it('returns a profile for a given user', async () => {
const mockProfile = createMockProfile({ username: 'dusty' })
const { from, chain } = createSupabaseChainMock(mockProfile)
const mockClient = { from } as any
const service = new ProfileService(mockClient)
const result = await service.getProfile('user-uuid-1')
expect(result).toEqual(mockProfile)
expect(from).toHaveBeenCalledWith('profiles')
expect(chain.eq).toHaveBeenCalledWith('id', 'user-uuid-1')
expect(chain.single).toHaveBeenCalled()
})
it('throws when the query fails', async () => {
const dbError = { message: 'Permission denied', code: '42501' }
const { from } = createSupabaseChainMock(null, dbError)
const mockClient = { from } as any
const service = new ProfileService(mockClient)
await expect(service.getProfile('user-uuid-1')).rejects.toEqual(dbError)
})
})
})
You get exact assertions on which table was queried, which filters were applied, and which terminal call was used. The error case requires no additional setup beyond passing an error to createSupabaseChainMock. Tests run in milliseconds with no network calls.
Auth is Different
The Supabase auth API doesn't use the fluent query builder, so it needs a different approach. Auth methods return promises directly, which means you can mock them with straightforward jest functions rather than chainable mocks.
function createMockAuthClient(overrides: Record<string, jest.Mock> = {}) {
return {
auth: {
signInWithPassword: jest.fn(),
signUp: jest.fn(),
signOut: jest.fn(),
getSession: jest.fn(),
...overrides,
},
} as any
}
it('returns session data on success', async () => {
const mockSession = { user: { id: 'user-uuid-1' }, access_token: 'token' }
const mockClient = createMockAuthClient({
signInWithPassword: jest.fn().mockResolvedValue({
data: { session: mockSession },
error: null,
}),
})
const service = new AuthService(mockClient)
const result = await service.signIn('test@example.com', 'password123')
expect(result.session).toEqual(mockSession)
expect(mockClient.auth.signInWithPassword).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
})
})
Two different mock patterns for two different parts of the Supabase client. The chainable mock for database queries, plain jest mocks for auth. Both use dependency injection, so neither requires touching module-level mocks.
RevenueCat Testing
The starter also includes a full RevenueCat subscription system (Free, Pro, Premium) and test coverage for it. RevenueCat's SDK isn't chainable so the approach is module-level mocking, but scoped and typed correctly:
jest.mock('react-native-purchases', () => ({
__esModule: true,
default: {
setLogLevel: jest.fn(),
configure: jest.fn(),
logIn: jest.fn(),
logOut: jest.fn(),
getOfferings: jest.fn(),
purchasePackage: jest.fn(),
restorePurchases: jest.fn(),
getCustomerInfo: jest.fn(),
},
LOG_LEVEL: { ERROR: 'ERROR' },
}))
import Purchases from 'react-native-purchases'
const mockPurchases = Purchases as jest.Mocked<typeof Purchases>
With helper functions to build realistic CustomerInfo and Offering shapes, the tests cover all three tier states (free, pro, premium), the package-not-found error case, and restore flows.
When to Use Each Approach
Chainable mock helper: unit testing individual service methods. Fast, precise, no external dependencies.
Auth mock pattern: testing auth flows at the service layer where promise-based methods are the surface area.
MSW or a real Supabase test instance: integration tests that need to exercise multiple layers together, or tests that verify RLS policies are behaving correctly. These are slower but test things the unit approach can't.
The three approaches are complementary. The starter uses the first two. The third is documented in docs/supabase-setup.md for when you need it.
The Repo
The full starter is at github.com/Dusttoo/react-native-expo-supabase-starter.
It includes everything covered here plus the full app: Expo Router with file-based navigation, Supabase auth with session persistence via AsyncStorage, a paywall screen with package selection and restore purchases, a profile screen with tier-aware UI, and setup docs for both Supabase and RevenueCat.
The chainable mock helper, auth mock pattern, and factory functions are all in tests/helpers/ and are designed to be copied directly into your own project.
I run Built By Dusty, a software studio that builds custom applications for small businesses and animal breeders. If you're building a mobile app and want a senior engineer who has shipped one, I'd like to hear from you.
Top comments (0)