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)
NativeModuleswas 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-nativejest.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; configuremoduleNameMapper - 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.