Detox Best Practices and Debugging: Fixing Flaky Tests and Hard-to-Find Failures
Flaky Detox tests almost always come from one of three sources: broken synchronization (the app isn't idle when Detox thinks it is), poor test isolation (state from one test bleeds into the next), or incorrect element selectors (the testID you're targeting doesn't exist in the rendered tree). This guide covers how to diagnose and fix all three.
Key Takeaways
- Synchronization failures are the #1 cause of flakiness — long-running timers, infinite animations, and background WebSocket connections prevent Detox from detecting idle state.
- Restart the app in
beforeEach, notbeforeAll— sharing app state across tests creates invisible dependencies that produce non-deterministic failures. - Use
device.disableSynchronization()sparingly — disabling it globally hides real issues; disable only the specific operation causing the hang. jestExpectfrom Detox wraps Jest matchers — use it for non-visual assertions (value checks, counts) without breaking Detox's async model.- Log the element hierarchy when an element isn't found —
element(by.id('target')).getAttributes()or the UI hierarchy artifact shows what's actually in the tree.
Running a Detox test suite successfully on your machine is one milestone. Keeping it green in CI across hundreds of commits is another. The difference between these two states is almost entirely about test isolation, synchronization configuration, and systematic debugging practices.
Synchronization: The Root Cause of Most Flakiness
Detox's synchronization mechanism is its most powerful feature and the source of most debugging sessions. Understanding exactly how it works — and why it sometimes gets stuck — is the foundation of a reliable test suite.
What Detox Tracks
Detox considers the app "idle" when all of the following are true:
- No pending
fetchorXMLHttpRequestcalls - No scheduled JavaScript timers (
setTimeout,setInterval) - No React Native animations in progress
- No pending native operations on the main thread
When any of these are active, Detox waits. This is what eliminates the timing-based failures that plague black-box testing.
When Synchronization Gets Stuck
Some app patterns prevent the app from ever reaching idle state:
Background polling timers:
// This prevents Detox from detecting idle — timer fires every 30s forever
useEffect(() => {
const timer = setInterval(fetchNotifications, 30000);
return () => clearInterval(timer);
}, []);Infinite animations:
// Looping animations keep the animation scheduler busy
Animated.loop(Animated.timing(spinValue, { /* ... */ })).start();Persistent WebSocket connections:
// Open WebSocket prevents idle detection
const ws = new WebSocket('wss://realtime.example.com');Diagnosing Synchronization Hangs
When a Detox test hangs indefinitely (no timeout error, just waiting), synchronization is almost certainly the cause.
Enable verbose logging to see what Detox is waiting for:
detox test --configuration ios.sim.debug --loglevel verbose 2>&1 | grep -i "synchroniz\|waiting\|idle"Look for log lines like:
[Detox] SYNCING - Waiting for: 1 setTimeout timer(s)
[Detox] SYNCING - Waiting for: 1 network request(s)This tells you exactly what's blocking idle detection.
Fixing Synchronization Issues
Option 1: Disable the specific offending operation
Blacklist URLs for analytics, crash reporting, and monitoring endpoints that fire background requests:
// In your test setup (e2e/setup.js)
beforeAll(async () => {
await device.setURLBlacklist([
'.*\\.google-analytics\\.com.*',
'.*crashlytics\\.com.*',
'.*segment\\.io.*',
'.*sentry\\.io.*',
]);
});This tells Detox to ignore network requests matching these patterns when determining idle state.
Option 2: Gate polling timers behind a test flag
// In your app code
const POLLING_INTERVAL = global.__DETOX_DISABLE_POLLING__ ? 0 : 30000;
useEffect(() => {
if (POLLING_INTERVAL === 0) return;
const timer = setInterval(fetchNotifications, POLLING_INTERVAL);
return () => clearInterval(timer);
}, []);Set __DETOX_DISABLE_POLLING__ in your test build via a launch argument.
Option 3: Temporarily disable synchronization
await device.disableSynchronization();
await element(by.id('infiniteScrollList')).scroll(500, 'down');
await device.enableSynchronization();Use this only for specific interactions, not globally. Disabling synchronization globally defeats the purpose of Detox.
Test Isolation
Inter-test state contamination is the second major source of flakiness. A test that passes in isolation fails when run as part of a suite because the previous test left the app in an unexpected state.
The Golden Rule: Restart Before Each Test
describe('Purchase Flow', () => {
beforeEach(async () => {
await device.launchApp({ newInstance: true });
});
it('should add item to cart', async () => { /* ... */ });
it('should proceed to checkout', async () => { /* ... */ });
});newInstance: true terminates the app and relaunches it. This guarantees a clean state regardless of what previous tests did.
For test suites where full relaunches are too slow, device.reloadReactNative() reloads the JS bundle without restarting the native shell. This is faster but doesn't clear native state (keychain, AsyncStorage, SQLite).
When to use each:
| Scenario | Use |
|---|---|
| Testing initial app launch | launchApp({ newInstance: true }) |
| Testing auth flows | launchApp({ newInstance: true }) |
| Testing UI flows with no persistent state | reloadReactNative() |
Standard beforeEach for fast iteration |
reloadReactNative() |
Clearing AsyncStorage Between Tests
If your app uses AsyncStorage and you need clean state:
// In your app, add a testing helper
if (__DEV__) {
global.clearAsyncStorage = async () => {
const AsyncStorage = require('@react-native-async-storage/async-storage').default;
await AsyncStorage.clear();
};
}// In your test setup
beforeEach(async () => {
await device.reloadReactNative();
await device.executeScript('clearAsyncStorage');
});Shared Authentication State
For tests that require a logged-in user, don't log in on every test — it's slow and adds a dependency on your auth flow working correctly.
Instead, use launch arguments to bypass authentication in the test build:
// tests that need auth
beforeAll(async () => {
await device.launchApp({
newInstance: true,
launchArgs: {
testUserId: 'test-user-123',
skipAuth: 'true'
},
});
});// In your app's root component
if (__DEV__ && global.__E2E_TEST__) {
const { testUserId, skipAuth } = NativeModules.LaunchArgs;
if (skipAuth && testUserId) {
// inject mock auth state without network call
AuthStore.setUser({ id: testUserId, email: 'test@example.com' });
}
}This pattern requires cooperation from your app code, but it's worth the investment — auth-dependent tests become 5x faster.
Element Detection Issues
"Element not found" errors are the third major category of Detox failures. The element exists visually, but Detox can't locate it.
Verify testID Props
The most common cause: the testID prop isn't set, is misspelled, or is on a parent component that doesn't pass it down.
// Wrong: testID on a View that wraps a custom component but doesn't pass it
<View testID="loginButton">
<Button title="Log in" onPress={handleLogin} />
</View>
// Better: testID on the interactive element
<TouchableOpacity testID="loginButton" onPress={handleLogin}>
<Text>Log in</Text>
</TouchableOpacity>For custom components, verify that testID is spread:
// In your Button component
const Button = ({ testID, ...props }) => (
<TouchableOpacity testID={testID} {...props}>
{/* ... */}
</TouchableOpacity>
);Debug Element Hierarchy
When an element can't be found, print the current UI hierarchy:
// Detox 20+
const attrs = await element(by.id('someElement')).getAttributes();
console.log(JSON.stringify(attrs, null, 2));Or enable UI hierarchy artifacts in your Detox config:
// detox.config.js
artifacts: {
plugins: {
uiHierarchy: 'enabled',
},
},With this enabled, every failed test generates a .viewhierarchy file that you can open in Xcode's Debug View Hierarchy viewer.
Elements in ScrollView
Elements outside the visible viewport aren't "visible" to Detox even if they exist in the component tree. Scroll to them first:
await waitFor(element(by.id('checkoutButton')))
.toBeVisible()
.whileElement(by.id('productDetails'))
.scroll(300, 'down');
await element(by.id('checkoutButton')).tap();Animated Elements
Elements animating into position may not match matchers until the animation completes. Detox usually handles this via synchronization, but if an element is scrolling or fading in:
await waitFor(element(by.id('toast')))
.toBeVisible()
.withTimeout(3000);Debugging Specific Failure Types
Test Hangs (Never Times Out)
- Enable verbose logging and look for
SYNCINGmessages - Check for background timers and WebSocket connections
- Add URL blacklisting for analytics/monitoring endpoints
- Use
device.disableSynchronization()around the problematic step to confirm it's a sync issue
Test Times Out
Timeout means Detox gave up waiting. Default timeout is 120 seconds. Common causes:
- Element genuinely doesn't appear (logic bug in app)
- Wrong testID (selector never matches)
- Network request never completes (staging API down, mock not configured)
Increase timeout for specific tests that legitimately take longer:
jest.setTimeout(180000); // 3 minutes for this describe blockTest Passes Locally, Fails in CI
Usually caused by:
- Different simulator speed (CI is slower)
- Animations enabled on CI simulator (disable them)
- Different app build (CI uses cached build; rebuild)
- Network-dependent tests hitting an unavailable staging environment
For CI-specific debugging, enable screenshots on every step (not just failures) temporarily:
detox test --take-screenshots all --record-logs allUpload the artifacts and inspect what the app looked like at each step.
Screenshots Show Wrong State
If screenshots show the previous screen when the test expects to be on the next screen, it's a synchronization issue — the navigation animation hadn't completed when Detox took the screenshot.
Check if you're testing immediately after a navigation action without waiting for the destination screen:
// Problematic
await element(by.id('nextButton')).tap();
await expect(element(by.id('detailScreen'))).toBeVisible(); // may fail if nav is still animating
// Better
await element(by.id('nextButton')).tap();
await waitFor(element(by.id('detailScreen')))
.toBeVisible()
.withTimeout(3000);Performance Best Practices
Skip animations in tests: Animations slow down tests and can interfere with synchronization. Add this to your test build configuration:
// In App.js or a test-specific entry point
if (global.__E2E_TEST__) {
// Disable all animations
const { NativeModules } = require('react-native');
NativeModules.AnimationsModule?.setAnimationsEnabled(false);
}Or use Detox's built-in approach via simulator settings for iOS:
// In your Detox test setup
await device.setStatusBar({ time: '12:00' }); // consistent screenshotsGroup related tests: The overhead between describe blocks is minimal. Keeping related tests together reduces the number of app launches.
Mock network requests: Tests that hit real APIs are slow, flaky, and hard to reset. Use a mock server or library like mockdate for time-dependent assertions.
// Simple mock setup with nock or msw (React Native)
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
afterEach(() => {
server.resetHandlers();
});Test Output and Reporting
Configure Detox's Jest reporter for readable output:
// e2e/jest.config.js
module.exports = {
reporters: [
'detox/runners/jest/reporter',
['jest-junit', {
outputDirectory: 'test-results',
outputName: 'detox-results.xml',
}],
],
};JUnit XML output integrates with GitHub Actions test summaries, GitLab test reports, and most CI dashboards. Add it to see per-test pass/fail in your CI interface without reading raw logs.
Creating a Debugging Checklist
When a Detox test fails and you're not sure why, work through this in order:
- Look at the screenshots — what was on screen when it failed?
- Check the device log — are there crash logs, JS errors, or network failures?
- Verify testID exists — grep your components for the testID you're targeting
- Check the UI hierarchy — is the element in the tree? Is it visible?
- Check for sync issues — is the app polling, animating, or making network requests?
- Reproduce locally — run just the failing test with
--loglevel verbose - Add a
waitFor— as a diagnostic step, not a permanent fix - Check if it's CI-specific — does it pass locally every time?
Most failures resolve at step 1, 2, or 3. The verbose output from step 6 catches the rest.