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.
Table of Contents
- 1. Why Native Modules Need Mocking in Jest at All
- 2. Basics: jest.mock and the __mocks__ Convention
- 3. Practical Example: Mocking a Camera Module
- 4. Mocking TurboModules and NativeEventEmitter
- 5. Registering Mocks Centrally in jest.config and Setup Files
- 6. Systematically Covering Error Cases
- 7. Limits of Pure Unit Tests for Native Functionality
- 8. When an E2E Test Is Needed Instead of a Mock
- 9. Maintaining Mocks: Avoiding Drift Between Mock and Real Module
- 10. Summary
- 11. FAQ
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.