Testing Expo Apps: Jest Unit Tests, Expo Router Testing, and E2E with Detox
Expo adds a layer of abstraction over React Native that requires specific testing strategies. The jest-expo preset handles most of the configuration complexity, but mocking expo-* modules, testing Expo Router's file-based navigation, and connecting Detox to the bare workflow each have their own patterns. This guide covers unit testing, navigation testing, and E2E setup for both managed and bare Expo workflows.
Key Takeaways
jest-expo preset is not optional — use it. It handles module resolution, transform rules, and platform-specific mocking for the entire Expo SDK ecosystem. Rolling your own Jest config with Expo is a maintenance burden with no upside. Expo Router tests render the entire router, not individual screens. Import from expo-router/testing-library to get a render function that initializes the file-based routing system before rendering your screen under test. Mock expo- modules at the package level, not file by file.* Create a mocks directory entry for each expo-* module you use to avoid repetitive mock definitions across test files. Detox works with Expo only in bare workflow. Managed workflow apps cannot embed Detox's native client — run expo prebuild to access the native projects required for Detox setup. EAS Build's test profile is the correct way to build Detox binaries in CI. It handles Xcode and Gradle configuration in a reproducible cloud environment without requiring macOS agents with local Xcode setups.
Expo is the most popular way to start a React Native project in 2025, and for good reason — it eliminates weeks of native toolchain configuration and provides a curated SDK of common mobile capabilities. But the same abstractions that make Expo fast to develop with create specific testing challenges. This guide addresses them head-on.
Setting Up Jest with jest-expo
The jest-expo preset configures Jest for the Expo environment, handling module transforms, asset mocking, and platform-specific resolution.
npx expo install jest-expo jest @testing-library/react-native @testing-library/jest-nativeConfigure Jest in package.json:
{
"jest": {
"preset": "jest-expo",
"setupFilesAfterFramework": [
"@testing-library/jest-native/extend-expect"
],
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)"
]
}
}The transformIgnorePatterns entry is longer for Expo than vanilla React Native because the Expo SDK ships more packages as ES modules that Babel must transform.
Mocking expo-* Modules
expo-camera
// __mocks__/expo-camera.js
const React = require('react');
const { View } = require('react-native');
module.exports = {
CameraView: ({ children, ...props }) =>
React.createElement(View, { testID: 'camera-view', ...props }, children),
useCameraPermissions: jest.fn(() => [
{ granted: true, status: 'granted' },
jest.fn(),
]),
Camera: {
getCameraPermissionsAsync: jest.fn().mockResolvedValue({ granted: true }),
requestCameraPermissionsAsync: jest.fn().mockResolvedValue({ granted: true }),
},
};expo-location
// __mocks__/expo-location.js
module.exports = {
requestForegroundPermissionsAsync: jest.fn().mockResolvedValue({
status: 'granted',
}),
getCurrentPositionAsync: jest.fn().mockResolvedValue({
coords: {
latitude: 51.5074,
longitude: -0.1278,
accuracy: 10,
},
timestamp: Date.now(),
}),
watchPositionAsync: jest.fn().mockReturnValue({
remove: jest.fn(),
}),
Accuracy: {
Balanced: 3,
High: 4,
Highest: 5,
},
};expo-notifications
// __mocks__/expo-notifications.js
module.exports = {
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
getPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
getExpoPushTokenAsync: jest.fn().mockResolvedValue({ data: 'ExponentPushToken[test]' }),
scheduleNotificationAsync: jest.fn().mockResolvedValue('notification-id'),
cancelScheduledNotificationAsync: jest.fn().mockResolvedValue(undefined),
addNotificationReceivedListener: jest.fn().mockReturnValue({ remove: jest.fn() }),
addNotificationResponseReceivedListener: jest.fn().mockReturnValue({ remove: jest.fn() }),
};expo-secure-store
// __mocks__/expo-secure-store.js
const store = new Map();
module.exports = {
setItemAsync: jest.fn(async (key, value) => { store.set(key, value); }),
getItemAsync: jest.fn(async (key) => store.get(key) ?? null),
deleteItemAsync: jest.fn(async (key) => { store.delete(key); }),
};This mock maintains state within a test run, matching the real module's behavior without requiring native code.
Testing Expo Router Navigation
Expo Router introduces file-based routing, where the file system structure defines the navigation tree. Testing it requires rendering the router itself, not just individual screen components.
Install the testing utilities:
npx expo install expo-routerExpo Router 3+ ships with expo-router/testing-library:
// app/(tabs)/profile.tsx
import { Text, View, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
export default function ProfileScreen() {
const router = useRouter();
return (
<View>
<Text>Profile</Text>
<TouchableOpacity testID="edit-button" onPress={() => router.push('/profile/edit')}>
<Text>Edit Profile</Text>
</TouchableOpacity>
</View>
);
}// __tests__/profile.test.tsx
import { renderRouter, screen } from 'expo-router/testing-library';
describe('ProfileScreen', () => {
it('renders profile screen', () => {
renderRouter({
'index': () => <Text>Home</Text>,
'(tabs)/profile': require('../app/(tabs)/profile').default,
'profile/edit': () => <Text>Edit Profile Screen</Text>,
}, {
initialUrl: '/(tabs)/profile',
});
expect(screen.getByText('Profile')).toBeTruthy();
});
it('navigates to edit screen on button press', async () => {
renderRouter({
'(tabs)/profile': require('../app/(tabs)/profile').default,
'profile/edit': () => <Text>Edit Profile Screen</Text>,
}, {
initialUrl: '/(tabs)/profile',
});
fireEvent.press(screen.getByTestId('edit-button'));
expect(await screen.findByText('Edit Profile Screen')).toBeTruthy();
});
});The renderRouter function accepts a map of route patterns to screen components, initializes the file-based routing system, and renders the specified initialUrl. This tests the full navigation flow without a real device.
Testing Route Parameters
// app/product/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { Text } from 'react-native';
export default function ProductScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return <Text testID="product-id">Product: {id}</Text>;
}it('renders product with correct ID from URL params', () => {
renderRouter({
'product/[id]': require('../app/product/[id]').default,
}, {
initialUrl: '/product/42',
});
expect(screen.getByTestId('product-id')).toHaveTextContent('Product: 42');
});Testing Expo API Routes
Expo Router 3+ supports API routes — serverless functions inside your Expo app. Test them like any Express-style handler:
// app/api/user+api.ts
export async function GET(request: Request) {
const userId = new URL(request.url).searchParams.get('id');
if (!userId) {
return Response.json({ error: 'Missing user ID' }, { status: 400 });
}
const user = await db.users.find(userId);
return Response.json(user);
}// __tests__/api/user.test.ts
import { GET } from '../../app/api/user+api';
jest.mock('../../db', () => ({
users: {
find: jest.fn(),
},
}));
describe('GET /api/user', () => {
it('returns 400 when user ID is missing', async () => {
const request = new Request('http://localhost/api/user');
const response = await GET(request);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe('Missing user ID');
});
it('returns user data for valid ID', async () => {
const mockUser = { id: '42', name: 'Alice' };
require('../../db').users.find.mockResolvedValueOnce(mockUser);
const request = new Request('http://localhost/api/user?id=42');
const response = await GET(request);
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual(mockUser);
});
});Managed vs Bare Workflow Differences
Managed Workflow Testing
In managed workflow, all native modules are provided by Expo. Mocking is straightforward because you're always mocking the JavaScript interface, never native code directly.
The constraint: you cannot add custom native modules, and Detox cannot be installed (it requires embedding a native SDK).
Bare Workflow Testing
After running npx expo prebuild, you have access to the ios/ and android/ directories. All Jest-based tests work identically — the only change is that custom native modules can now be added and their mocks written to match their actual JavaScript interface.
Detox becomes available in bare workflow.
Detox with Expo Bare Workflow
Run prebuild to generate native projects:
npx expo prebuildInstall and configure Detox following the standard React Native setup. The key Expo-specific consideration is the build command:
{
"detox": {
"apps": {
"ios.debug": {
"type": "ios.app",
"binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/YourApp.app",
"build": "xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build"
},
"android.debug": {
"type": "android.apk",
"binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk",
"build": "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug"
}
}
}
}One Expo-specific gotcha: if you're using Expo's development client (expo-dev-client), the bundle URL differs from standard React Native. Ensure your Detox configuration points to the release bundle for test builds, not the dev server:
{
"apps": {
"ios.release": {
"type": "ios.app",
"binaryPath": "ios/build/Build/Products/Release-iphonesimulator/YourApp.app",
"build": "xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build"
}
}
}EAS Build for CI Testing
EAS Build can produce test binaries without requiring macOS agents in CI:
// eas.json
{
"build": {
"test": {
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleDebug :app:assembleAndroidTest"
},
"ios": {
"simulator": true,
"buildConfiguration": "Debug"
}
}
}
}Build with:
eas build --platform ios --profile test
eas build --platform android --profile <span class="hljs-built_in">testDownload the resulting binary and pass it to Detox via the binaryPath configuration. This approach decouples binary building from test execution — build once, test in multiple environments.
A pragmatic Expo testing stack uses jest-expo for unit tests (fast, no device needed), expo-router/testing-library for navigation tests (medium speed, full routing), and Detox in bare workflow for the critical user journeys that must be verified end-to-end. Each layer catches a different class of bug, and together they give confidence that the app works before it reaches users.