cd..blog

Deterministic Native Module Mocking with Expo Modules SDK and JSI

const published = "Aug 13, 2026, 05:28 PM";const readTime = 5 min;
React NativeExpoTypeScriptUnit TestingJSI
Learn how to implement reliable unit tests for high-performance React Native modules using the Expo Modules SDK and JSI, avoiding common pitfalls in the bridge-less architecture.

The Shift to JSI and the Mocking Gap

As React Native matures into its bridge-less era, the Expo Modules SDK has become the standard for writing high-performance native code. By leveraging the JavaScript Interface (JSI), developers can invoke synchronous native methods without the overhead of JSON serialization. However, this architectural shift introduces a significant challenge: traditional Jest mocks that rely on NativeModules often fail or behave inconsistently when interacting with the C++ layer of the New Architecture.

In a production environment, relying on jest.mock to swap out an entire native module often leads to 'leaky' tests where the mock implementation diverges from the actual native behavior. To build resilient mobile applications in 2026, we must move toward deterministic mocking patterns that respect the synchronous nature of JSI while maintaining type safety across the TypeScript-Native boundary.

Designing for Testability with Expo Modules

The primary mistake in native module design is tight coupling between the UI components and the raw native interface. When using the Expo Modules SDK, the native side typically exports a class or a set of functions. Instead of consuming these directly, we should implement a provider pattern or a thin wrapper that facilitates dependency injection.

Consider a high-frequency sensor module. If your component imports SensorModule directly from the native package, you are forced to mock the entire module at the Jest level, which is global and prone to side effects. Instead, define a clear interface and use a factory pattern.

Defining the Interface

// src/modules/sensor/types.ts
export interface SensorData {
  x: number;
  y: number;
  z: number;
}

export interface ISensorModule {
  startListening(): void;
  stopListening(): void;
  getLastValue(): SensorData | null;
}

By defining this interface, we decouple our application logic from the specific implementation provided by the Expo Modules SDK.

Implementing the Mocking Strategy

When testing components that rely on JSI-based modules, we need to simulate the synchronous return values that JSI provides. Standard Promise-based mocks will cause runtime errors if the calling code expects an immediate value.

The Manual Mock Pattern

Create a __mocks__ directory adjacent to your native module wrapper. This allows Jest to automatically pick up the mock implementation during test execution. For Expo modules, the mock should mirror the structure of the NativeModule object exported by the SDK.

// src/modules/sensor/__mocks__/index.ts
import { ISensorModule } from '../types';

export const SensorModule: ISensorModule = {
  startListening: jest.fn(),
  stopListening: jest.fn(),
  getLastValue: jest.fn(() => ({ x: 0, y: 0, z: 0 })),
};

Handling Synchronous JSI Calls

One of the most powerful features of the New Architecture is the ability to call native functions synchronously. In your tests, ensure you are using mockReturnValue rather than mockResolvedValue to prevent race conditions in your component lifecycle tests.

import { SensorModule } from '../sensor';

describe('SensorComponent', () => {
  it('updates UI immediately with synchronous sensor data', () => {
    (SensorModule.getLastValue as jest.Mock).mockReturnValue({
      x: 1.5, y: 2.0, z: -0.5
    });

    const { getByText } = render(<SensorDisplay />);
    expect(getByText('X: 1.5')).toBeTruthy();
  });
});

Advanced Mocking: The Expo UseModule Hook

Expo recently introduced more ergonomic ways to consume modules. If you are using the useModule hook from expo-modules-core, mocking becomes slightly more complex because the hook manages the module instance internally.

To mock this effectively, you must mock the requireNativeModule function provided by Expo. This ensures that any component calling useModule receives your controlled mock object instead of attempting to initialize a native bridge that doesn't exist in the Node.js environment.

import { requireNativeModule } from 'expo-modules-core';

jest.mock('expo-modules-core', () => ({
  ...jest.requireActual('expo-modules-core'),
  requireNativeModule: jest.fn((name) => {
    if (name === 'MySensorModule') {
      return {
        startListening: jest.fn(),
        getLastValue: jest.fn(),
      };
    }
    return {};
  }),
}));

Tradeoffs: Manual Mocks vs. Auto-Generated Mocks

There is a constant tension between the speed of development and the accuracy of mocks.

  1. Manual Mocks: These provide the highest control. You can simulate edge cases, such as the native module being unavailable or returning malformed data. The downside is the maintenance burden; if the native Swift or Kotlin signature changes, you must manually update the TypeScript mock.
  2. Auto-Generated Mocks: Tools like jest-expo provide some automation, but they often struggle with custom JSI types. They are best suited for standard Expo SDK modules rather than custom internal native modules.

For production-grade apps, I recommend Manual Mocks with Type Enforcement. By casting your mock to the module's interface (as shown in the ISensorModule example), the TypeScript compiler will alert you if your mock falls out of sync with the expected contract.

Integration Testing with EAS Build

Unit tests only go so far. Because JSI modules interact directly with memory, memory leaks or threading issues won't appear in Jest. Use EAS Build to create internal distribution builds for automated integration testing on real devices.

Using Detox for end-to-end testing allows you to verify that the actual native implementation behaves as expected. In this layer, you should avoid mocking the native module entirely, instead mocking the external data sources the native module interacts with (e.g., a mock GPS signal or a mock Bluetooth peripheral).

Conclusion

Mocking native modules in the Expo ecosystem requires a shift from 'mocking the bridge' to 'mocking the interface.' By leveraging TypeScript interfaces and respecting the synchronous nature of JSI, you can create a test suite that is both fast and reliable. As React Native continues to move away from the bridge, these patterns will become the baseline for any scalable mobile architecture.