Testing React Native New Architecture: Fabric, JSI, and TurboModules

Testing React Native New Architecture: Fabric, JSI, and TurboModules

React Native's New Architecture — Fabric renderer, JSI (JavaScript Interface), and TurboModules — changes how native modules communicate with JavaScript. For testing, the key impact is that TurboModules use synchronous JSI calls instead of asynchronous bridge messages, and Fabric renders components differently from the legacy renderer.

This guide covers testing strategies for apps on the New Architecture: mocking TurboModules, testing Fabric-rendered components, and verifying JSI module behaviour.

What Changed for Tests

Old Architecture:

  • Native modules communicated via JSON messages over the async bridge
  • Module methods were always async (even if they returned synchronously)
  • NativeModules was the standard API for accessing native code

New Architecture:

  • TurboModules use JSI for direct, synchronous native calls
  • Codegen generates TypeScript/Flow specs that validate module interfaces
  • Fabric handles rendering with C++ instead of the legacy UIManager

For tests, the practical difference:

Old New
Mock NativeModules.MyModule Mock the TurboModule via jest-expo or manual mock
Module calls always await Module may be sync or async depending on implementation
UIManager for layout Fabric layout happens natively — harder to intercept

Setting Up for New Architecture Tests

Expo SDK 50+ enables New Architecture by default. jest-expo handles most of the configuration:

npm install -D jest-expo @testing-library/react-native @testing-library/jest-native

jest.config.js:

module.exports = {
  preset: 'jest-expo',
  setupFilesAfterFramework: [
    '@testing-library/jest-native/extend-expect',
  ],
}

jest-expo's preset automatically sets up the New Architecture environment in jsdom.

Mocking TurboModules

TurboModules are defined by a Codegen spec. In tests, provide a Jest mock that matches the spec:

// native-modules/NativeBiometrics.ts (Codegen spec)
import type { TurboModule } from 'react-native/Libraries/TurboModule/RCTExport'
import { TurboModuleRegistry } from 'react-native'

export interface Spec extends TurboModule {
  isSupported: () => boolean
  authenticate: (reason: string) => Promise<boolean>
  getEnrolledBiometryType: () => string | null
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeBiometrics')

Create a manual mock:

// __mocks__/NativeBiometrics.ts
export default {
  isSupported: jest.fn(() => true),
  authenticate: jest.fn().mockResolvedValue(true),
  getEnrolledBiometryType: jest.fn(() => 'FaceID'),
}

Configure Jest to use the mock:

// jest.config.js
module.exports = {
  preset: 'jest-expo',
  moduleNameMapper: {
    'NativeBiometrics': '<rootDir>/__mocks__/NativeBiometrics.ts',
  },
}

Now test your component that uses biometrics:

// hooks/useBiometricAuth.ts
import NativeBiometrics from 'NativeBiometrics'

export function useBiometricAuth() {
  const isSupported = NativeBiometrics.isSupported()

  async function authenticate(reason: string): Promise<boolean> {
    if (!isSupported) return false
    return NativeBiometrics.authenticate(reason)
  }

  return { isSupported, authenticate }
}
// hooks/useBiometricAuth.test.ts
import { renderHook, act } from '@testing-library/react-native'
import { describe, it, expect, jest, beforeEach } from '@jest/globals'
import NativeBiometrics from 'NativeBiometrics'
import { useBiometricAuth } from './useBiometricAuth'

describe('useBiometricAuth', () => {
  beforeEach(() => jest.clearAllMocks())

  it('reports biometrics as supported when native module says so', () => {
    jest.mocked(NativeBiometrics.isSupported).mockReturnValue(true)

    const { result } = renderHook(() => useBiometricAuth())

    expect(result.current.isSupported).toBe(true)
  })

  it('authenticates and returns true on success', async () => {
    jest.mocked(NativeBiometrics.isSupported).mockReturnValue(true)
    jest.mocked(NativeBiometrics.authenticate).mockResolvedValue(true)

    const { result } = renderHook(() => useBiometricAuth())

    let authResult: boolean
    await act(async () => {
      authResult = await result.current.authenticate('Confirm identity')
    })

    expect(authResult!).toBe(true)
    expect(NativeBiometrics.authenticate).toHaveBeenCalledWith('Confirm identity')
  })

  it('returns false when biometrics not supported', async () => {
    jest.mocked(NativeBiometrics.isSupported).mockReturnValue(false)

    const { result } = renderHook(() => useBiometricAuth())

    let authResult: boolean
    await act(async () => {
      authResult = await result.current.authenticate('Confirm identity')
    })

    expect(authResult!).toBe(false)
    expect(NativeBiometrics.authenticate).not.toHaveBeenCalled()
  })
})

Testing with JSI Synchronous Modules

JSI modules can return values synchronously. Test both the sync path and async path:

// native-modules/NativeSecureStorage.ts
export interface Spec extends TurboModule {
  getItem: (key: string) => string | null  // Synchronous!
  setItem: (key: string, value: string) => void
  removeItem: (key: string) => void
}
// __mocks__/NativeSecureStorage.ts
const store = new Map<string, string>()

export default {
  getItem: jest.fn((key: string) => store.get(key) ?? null),
  setItem: jest.fn((key: string, value: string) => { store.set(key, value) }),
  removeItem: jest.fn((key: string) => { store.delete(key) }),
}
// hooks/useSecureStorage.test.ts
import { describe, it, expect, jest, beforeEach } from '@jest/globals'
import NativeSecureStorage from 'NativeSecureStorage'
import { getAuthToken, setAuthToken, clearAuthToken } from './secureAuth'

describe('secure auth token management', () => {
  beforeEach(() => {
    jest.clearAllMocks()
    jest.mocked(NativeSecureStorage.getItem).mockReturnValue(null)
  })

  it('retrieves auth token synchronously', () => {
    jest.mocked(NativeSecureStorage.getItem).mockReturnValue('tok_abc123')

    const token = getAuthToken()

    expect(token).toBe('tok_abc123')
    expect(NativeSecureStorage.getItem).toHaveBeenCalledWith('auth_token')
  })

  it('stores auth token correctly', () => {
    setAuthToken('tok_new')

    expect(NativeSecureStorage.setItem).toHaveBeenCalledWith('auth_token', 'tok_new')
  })

  it('clears auth token on logout', () => {
    clearAuthToken()

    expect(NativeSecureStorage.removeItem).toHaveBeenCalledWith('auth_token')
  })
})

Testing Fabric-Rendered Components

Fabric components render with the same React Native Testing Library API. The key difference is that layout measurements may behave differently:

// components/CameraView.tsx
import { View, Text } from 'react-native'
// Fabric native component
import { NativeCameraView } from './NativeCameraView'

interface Props {
  isActive: boolean
  onCapture: (uri: string) => void
}

export function CameraView({ isActive, onCapture }: Props) {
  return (
    <View testID="camera-container">
      {isActive ? (
        <NativeCameraView
          onCapture={(event) => onCapture(event.nativeEvent.uri)}
          testID="native-camera"
        />
      ) : (
        <View testID="camera-placeholder">
          <Text>Camera is off</Text>
        </View>
      )}
    </View>
  )
}

Mock the Fabric native component:

// __mocks__/NativeCameraView.tsx
import { View } from 'react-native'

export const NativeCameraView = jest.fn(({ testID, onCapture }: any) => (
  <View
    testID={testID}
    // Simulate a native event for testing
    onLayout={() => onCapture?.({ nativeEvent: { uri: 'file://test.jpg' } })}
  />
))
// components/CameraView.test.tsx
import { render, screen, fireEvent } from '@testing-library/react-native'
import { describe, it, expect, jest } from '@jest/globals'
import { CameraView } from './CameraView'

describe('CameraView', () => {
  it('shows native camera when active', () => {
    render(<CameraView isActive onCapture={jest.fn()} />)

    expect(screen.getByTestId('native-camera')).toBeOnTheScreen()
    expect(screen.queryByTestId('camera-placeholder')).not.toBeOnTheScreen()
  })

  it('shows placeholder when inactive', () => {
    render(<CameraView isActive={false} onCapture={jest.fn()} />)

    expect(screen.getByText('Camera is off')).toBeOnTheScreen()
    expect(screen.queryByTestId('native-camera')).not.toBeOnTheScreen()
  })

  it('calls onCapture with the captured URI', () => {
    const onCapture = jest.fn()
    render(<CameraView isActive onCapture={onCapture} />)

    fireEvent(screen.getByTestId('native-camera'), 'layout')

    expect(onCapture).toHaveBeenCalledWith('file://test.jpg')
  })
})

Testing Codegen Type Safety

Codegen generates TypeScript interfaces from your module specs. Write tests that exercise the boundary between TypeScript types and native calls:

// Verify the mock matches the spec
describe('NativeBiometrics mock matches spec', () => {
  it('implements all required spec methods', () => {
    expect(typeof NativeBiometrics.isSupported).toBe('function')
    expect(typeof NativeBiometrics.authenticate).toBe('function')
    expect(typeof NativeBiometrics.getEnrolledBiometryType).toBe('function')
  })

  it('isSupported returns a boolean', () => {
    const result = NativeBiometrics.isSupported()
    expect(typeof result).toBe('boolean')
  })

  it('authenticate returns a Promise', () => {
    const result = NativeBiometrics.authenticate('test')
    expect(result).toBeInstanceOf(Promise)
  })
})

What Automated Unit Tests Miss

Unit tests with mocked TurboModules validate your JavaScript logic but not:

  • Real native module behaviour — the mock returns what you tell it to; the real module may have edge cases
  • JSI memory management — garbage collection of native objects
  • Bridge stall detection — the New Architecture doesn't have a bridge, but native call timeouts still exist
  • Platform-specific implementation — iOS and Android TurboModule implementations may differ

For this coverage, test on a real device or simulator using Maestro or Detox.

HelpMeTest can schedule tests against your Expo EAS builds to run the full native stack on real devices.

Summary

New Architecture testing strategy:

  • TurboModules → create __mocks__/ manual mocks matching the Codegen spec; configure moduleNameMapper
  • JSI sync modules → mock return values directly (no .mockResolvedValue); test sync paths
  • Fabric components → mock with a View that emits events; use RNTL normally
  • Codegen types → write tests that verify your mock matches the spec interface

The shift to New Architecture makes native modules faster and more type-safe, but the test approach is the same: isolate the JavaScript boundary, mock the native side, and verify the JS behaviour.

Read more