Detox Best Practices and Debugging: Fixing Flaky Tests and Hard-to-Find Failures

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, not beforeAll — 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.
  • jestExpect from 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 foundelement(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 fetch or XMLHttpRequest calls
  • 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)

  1. Enable verbose logging and look for SYNCING messages
  2. Check for background timers and WebSocket connections
  3. Add URL blacklisting for analytics/monitoring endpoints
  4. 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 block

Test 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 all

Upload 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 screenshots

Group 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:

  1. Look at the screenshots — what was on screen when it failed?
  2. Check the device log — are there crash logs, JS errors, or network failures?
  3. Verify testID exists — grep your components for the testID you're targeting
  4. Check the UI hierarchy — is the element in the tree? Is it visible?
  5. Check for sync issues — is the app polling, animating, or making network requests?
  6. Reproduce locally — run just the failing test with --loglevel verbose
  7. Add a waitFor — as a diagnostic step, not a permanent fix
  8. 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.

Read more

Start now free