DEV Community

Dusty Mumphrey
Dusty Mumphrey

Posted on

How I solved Supabase's chainable query builder problem in React Native tests

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()
Enter fullscreen mode Exit fullscreen mode

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 }),
    })),
  }),
}))
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

In production code you instantiate it with the real client:

const profileService = new ProfileService(supabase)
Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
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',
  })
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)