Testing Native Modules in Isolation: Mock Strategies for Jest
AI generated
RN
native
React Native · Testing · Native Modules
Testing Native Modules in Isolation: Mock Strategies for Jest
Making native functionality testable without running real native code

A Jest test run happens on Node.js and cannot execute Swift or Kotlin code, yet a lot of React Native logic depends directly on native modules: camera, location, biometrics, push notifications. Anyone who does not cleanly mock these dependencies ends up either with unsolvable test failures or with tests that silently verify nothing at all. This article covers concrete mock strategies for native modules in Jest, a running example with a camera module, and where the line between a meaningful unit test and a necessary end-to-end test actually sits.

14 min read Jest Native Modules Mocking Unit Testing TurboModules

1. Why Native Modules Need Mocking in Jest at All

Jest runs JavaScript or TypeScript code in a Node.js environment, without an iOS simulator, without an Android emulator, and without access to real native libraries. A NativeModules call that, in the real app context, gets forwarded to native Swift or Kotlin code simply hits nothing in Jest, because the bridge that connects the JavaScript and native sides in React Native does not exist in the test environment at all. Without a mock, such a call either fails with an error or returns undefined, depending on how robustly the respective module wrapper was written.

Mocking solves this problem by replacing the native interface with a JavaScript stand-in that follows the real module's public API but is fully runnable inside Jest. The point of such a mock is not to simulate native functionality, but to test the JavaScript-side logic that builds on the native module in isolation from actual hardware and operating system APIs: does the right error message show up on a camera failure, is a location formatted correctly in the UI, does a biometric fallback trigger when the sensor is unavailable.

2. Basics: jest.mock and the __mocks__ Convention

Jest offers two common ways to mock a module: an inline jest.mock() call directly in the test file, or a dedicated mock file in a __mocks__ directory next to the real module, which Jest picks up automatically. For native React Native modules, the second approach has proven itself, because a native module is typically used across many different test files, and a central mock prevents every test file from maintaining its own, potentially inconsistent stand-in.

It matters to keep the mock as close as possible to the actual API signature of the native module, including promise-based return values if the real bridge works asynchronously. A mock that returns a value synchronously while the real module returns a promise leads to a deceptive success in tests that resurfaces in the real app as an unhandled await problem, because the test code never learned to correctly wait for the asynchronous response.

3. Practical Example: Mocking a Camera Module

The running example is a camera module that captures a photo and returns the file path, or throws a specific error when permission is missing. The component using this module needs to react correctly to a successful capture, to a permission error, and to a generic hardware error. A central mock in __mocks__/react-native-camera-module.ts models all three cases as configurable functions that can be switched to the desired behavior per test.

It matters that the mock is implemented as jest.fn(), not as a rigid function, so individual tests can adjust the return behavior per test case via mockResolvedValueOnce or mockRejectedValueOnce. This way, the same mock covers both the success case and every relevant error case without maintaining a separate mock file for each scenario.


// __mocks__/react-native-camera-module.ts
export const CameraError = {
  PERMISSION_DENIED: "PERMISSION_DENIED",
  HARDWARE_UNAVAILABLE: "HARDWARE_UNAVAILABLE",
} as const;

export const takePhoto = jest.fn(async (): Promise<{ uri: string }> => {
  return { uri: "file:///mock/photo.jpg" };
});

export const requestCameraPermission = jest.fn(async (): Promise<boolean> => {
  return true;
});

// Example test: correctly handle a permission error
import { takePhoto, requestCameraPermission, CameraError } from "react-native-camera-module";
import { render, fireEvent, screen, waitFor } from "@testing-library/react-native";
import { CaptureScreen } from "../CaptureScreen";

it("shows a permission error message when the camera is denied", async () => {
  (requestCameraPermission as jest.Mock).mockResolvedValueOnce(false);
  (takePhoto as jest.Mock).mockRejectedValueOnce(
    new Error(CameraError.PERMISSION_DENIED)
  );

  render(<CaptureScreen />);
  fireEvent.press(screen.getByTestId("capture-button"));

  await waitFor(() => {
    expect(screen.getByText(/camera access denied/i)).toBeTruthy();
  });
});

4. Mocking TurboModules and NativeEventEmitter

For modules wired up through the new TurboModule architecture, mocking barely differs in principle: the TurboModule spec type defines a clear TypeScript interface, and the Jest mock just needs to reproduce that same interface synchronously or asynchronously. The main difference lies in the import structure, since TurboModules are usually wired up through a generated codegen layer, which a mock has to intercept via jest.mock() at the same import location the component uses to load the module.

Modules built on a NativeEventEmitter, for example for continuous location updates, additionally need a mocked emitter implementation that correctly simulates addListener and removeListener. A simple approach is a test double that collects registered callbacks in an array and triggers them deliberately via a test helper function like emitMockLocationUpdate(coords), so a test can control exactly when and with what data a location event arrives.


// __mocks__/react-native-geolocation-module.ts
type LocationCallback = (coords: { lat: number; lng: number }) => void;

const listeners: LocationCallback[] = [];

export const addLocationListener = jest.fn((callback: LocationCallback) => {
  listeners.push(callback);
  return { remove: () => {
    const idx = listeners.indexOf(callback);
    if (idx !== -1) listeners.splice(idx, 1);
  }};
});

// Test helper, only available in the mock
export const __emitMockLocationUpdate = (coords: { lat: number; lng: number }) => {
  listeners.forEach((cb) => cb(coords));
};

// In the test:
import { __emitMockLocationUpdate } from "react-native-geolocation-module";

it("updates the displayed coordinates on a location event", async () => {
  render(<LocationScreen />);
  __emitMockLocationUpdate({ lat: 52.52, lng: 13.405 });
  expect(await screen.findByText("52.52, 13.405")).toBeTruthy();
});

5. Registering Mocks Centrally in jest.config and Setup Files

Instead of repeating jest.mock() in every single test file, frequently used native modules can be registered centrally in a setupFilesAfterEach or setupFiles file, which Jest automatically loads on every test run. This meaningfully reduces duplication and ensures a newly added native module does not need to be replicated across twenty different test files, but is maintained in one central place.

For modules from the React Native core package itself, say AsyncStorage or NetInfo, official Jest preset mocks often already exist, wired up via jest-config-preset react-native, and already cover most standard cases sensibly. Custom native modules and modules from less common third-party libraries, on the other hand, almost always need a hand-written mock, since no official preset exists.

6. Systematically Covering Error Cases

A common mistake when mocking native modules is testing only the success case and ignoring error paths, even though native modules in practice bring many failure sources: missing permission, unavailable hardware, timeout on a location request, capture cancelled by the user. Each of these cases should be modeled as its own test case with an appropriately configured mock, so the UI logic can be proven correct for every error case.

A systematic approach is to keep a small table of possible error codes for each native module and write at least one test per code that verifies the component shows a meaningful user-facing message. This discipline pays off particularly for camera and location functionality, since users can revoke permissions at any time through system settings, and the app needs to react robustly to that, not just at the very first access.

7. Limits of Pure Unit Tests for Native Functionality

No matter how carefully a mock is designed, it never verifies that the real native module actually works, only that the JavaScript logic reacts correctly to defined return values. Whether a camera permission is actually correctly requested on a real Android 14 device, whether the native module crashes on a specific device model, or whether bridge serialization fails on very large image data remains fundamentally invisible to unit tests.

That gap can only be closed with end-to-end tests on real devices or simulators, for example with Detox or Maestro, which actually interact with real native code. A sensible test setup therefore deliberately combines both layers: many fast, isolated Jest tests for JavaScript logic and its error handling, complemented by a few, but targeted, E2E tests for the most critical native interactions, such as the actual camera permission dialog on a real device.

8. When an E2E Test Is Needed Instead of a Mock

A good rule-of-thumb test is asking whether a bug could actually lie in the interaction between JavaScript and native code itself, rather than exclusively in the JavaScript logic. An incorrectly formatted error message in the UI is a classic case for a unit test with a mock, while a crash serializing a large camera image over the bridge is only detectable through a real run on a device or simulator, since actual native memory and serialization mechanisms are involved there.

Cases where native APIs change between operating system versions are especially critical, for example new permission models in recent Android or iOS releases. A mock always reflects the contract known at the time it was written and does not automatically notice when the real native API has changed. A periodic E2E test run on current operating system versions is therefore the only reliable safeguard against such silent contract breaks between mock and reality.

9. Maintaining Mocks: Avoiding Drift Between Mock and Real Module

A mock that never gets updated alongside changes to the real module drifts away from the actual API unnoticed over time, for example when a new required field gets added to the return value while the mock keeps returning the old, incomplete structure. Tests stay green in that case, even though the real code in the app would already be failing, which creates a deceptive sense of safety that is worse than having no tests at all.

A proven countermeasure is explicitly checking the mock's TypeScript type against the real module's type, for example via a satisfies constraint or an explicit type import from the real module definition. If the real interface changes, the mock's TypeScript compilation fails, instead of the discrepancy only surfacing at runtime in production. This coupling between the mock type and the real module type is the most effective protection against unnoticed drift.


import type { CameraModuleSpec } from "react-native-camera-module";

// satisfies ensures the mock exactly matches the real module signature
export const takePhoto = jest.fn(async () => ({
  uri: "file:///mock/photo.jpg",
})) satisfies CameraModuleSpec["takePhoto"];
Test Layer Verifies Tool Suited For
Pure unit test with mock JavaScript logic and error handling Jest, Testing Library UI reaction to defined return values
Mock with satisfies type check Consistency between mock and real API TypeScript, Jest Prevents silent drift on API changes
E2E test on simulator/emulator Real bridge communication Detox, Maestro Serialization, permission dialogs
E2E test on real device Hardware behavior and OS versions Detox with real device farm Camera, GPS, biometrics on real hardware
Manual testing Edge cases, UX nuances Test device, beta testers Rare device models, new OS versions

Mironsoft

React Native app development and Magento integration

A mobile app for the Magento shop that actually runs smoothly?

We build React Native apps cleanly connected to the Magento REST or GraphQL API, from the first line of code to publishing on the App Store and Google Play.

App Concept

Plan the architecture and feature scope of a Magento-connected app together.

Magento API Integration

Cleanly connect product catalog, cart, and checkout to the shop API.

Store Publishing

Guide the App Store and Google Play release process without pitfalls.

10. Summary

Mocking Native Modules: The Essentials at a Glance

Why mock

Jest runs in Node.js without a native bridge, native module calls must be replaced with JavaScript stand-ins.

Central __mocks__ files

One maintained mock per module prevents inconsistencies across many test files.

Testing error cases

Permission errors, hardware failures, and timeouts need their own test cases, not just the success path.

Boundary to E2E

Mocks only verify JavaScript logic, real bridge interaction and hardware need additional E2E tests.

11. FAQ: Mocking Native Modules: The Essentials at a Glance

1Why does my Jest test fail even though the app works on device?
A matching mock for a native module is probably missing, causing the call to hit nothing in the Node.js test environment. A central __mocks__ entry for the affected module usually fixes this reliably.
2Do I need to write a separate mock for every native module?
For modules from React Native core, such as AsyncStorage, official preset mocks often already exist. For custom and less common third-party modules, a hand-written mock is almost always necessary.
3How do I mock a module that returns a promise?
Using jest.fn() with mockResolvedValueOnce for the success case and mockRejectedValueOnce for the error case reproduces the real module's asynchronous behavior precisely, including correct await handling in the test code.
4Does a good mock remove the need for E2E tests?
No, a mock only verifies the JavaScript-side logic, never the actual native implementation. Critical native interactions like camera permission dialogs or bridge serialization still require real end-to-end tests.
5How do I notice my mock has drifted from the real API?
Most reliably through a satisfies type check against the real module type in TypeScript, which immediately produces a compile error on any deviation. Without that coupling, drift often stays unnoticed for a long time.
6Can I meaningfully mock NativeEventEmitter-based modules like GPS updates?
Yes, via a mocked addListener implementation that collects registered callbacks, combined with a test helper function that triggers events deliberately. This lets you control exactly when and with what data an event arrives in the test.
7Where should I put native module mocks in the project?
A common location is a __mocks__ directory next to the mocked module, or at the top project level, which Jest picks up automatically. Frequently used mocks can additionally be registered in a central setupFiles file.
8Are TurboModules harder to mock than the old bridge model?
Not significantly, since both ultimately expose the same JavaScript interface. The main difference lies in the import structure via the generated codegen layer, which the mock has to intercept at the same location.
9How many error cases should I test per native module?
At least one test case per documented error code of the module, especially for permission denial, hardware failure, and timeout. These cases occur regularly in production and should not remain untested.
10Is mocking worth the effort for a small project with only one native module?
Yes, even a single native module without a mock can cause entire test files to fail or need to be skipped. The effort for a clean mock is small compared to the benefit of reliable tests.