Claude Code for Mobile Testing: Detox, React Native, and Device Automation — Claude Skills 360 Blog
Blog / Testing / Claude Code for Mobile Testing: Detox, React Native, and Device Automation
Testing

Claude Code for Mobile Testing: Detox, React Native, and Device Automation

Published: May 30, 2026
Read time: 9 min read
By: Claude Skills 360

Mobile E2E tests are notoriously difficult to maintain. Simulators are slow, tests are flaky, and debugging failures requires understanding the test runner, the app, and the OS simultaneously. Claude Code helps by generating tests from user flows, diagnosing simulator crashes from error output, and writing the setup boilerplate that most developers spend a day figuring out.

This guide covers mobile testing with Claude Code: Detox setup, writing tests for React Native, CI integration, and debugging flaky test patterns.

Setting Up Detox

Set up Detox for this React Native project.
I need: iOS and Android, TypeScript configuration, Jest runner,
and a working first test for the login screen.

Claude Code installs and configures Detox:

// .detoxrc.json
{
  "testRunner": {
    "args": {
      "$0": "jest",
      "config": "e2e/jest.config.ts"
    },
    "jest": {
      "setupTimeout": 120000
    }
  },
  "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"
    }
  },
  "devices": {
    "simulator": {
      "type": "ios.simulator",
      "device": { "type": "iPhone 15", "os": "iOS 17.0" }
    },
    "emulator": {
      "type": "android.emulator",
      "device": { "avdName": "Pixel_7_API_33" }
    }
  },
  "configurations": {
    "ios.sim.debug": {
      "device": "simulator",
      "app": "ios.debug"
    },
    "android.emu.debug": {
      "device": "emulator",
      "app": "android.debug"
    }
  }
}
// e2e/jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  testEnvironment: 'node',
  testTimeout: 120_000,
  rootDir: '..',
  testMatch: ['<rootDir>/e2e/**/*.test.ts'],
  transform: {
    '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'e2e/tsconfig.json' }],
  },
  reporters: ['detox/runners/jest/reporter'],
  globalSetup: 'detox/runners/jest/globalSetup',
  globalTeardown: 'detox/runners/jest/globalTeardown',
  testEnvironmentOptions: {
    runner: 'detox/runners/jest/testEnvironment/index',
  },
};

export default config;

Required testID Setup in React Native

Add testID props to the login screen components so Detox can find them.
// LoginScreen.tsx
export function LoginScreen() {
  return (
    <View testID="login-screen">
      <TextInput
        testID="login-email-input"
        placeholder="Email"
        keyboardType="email-address"
        autoCapitalize="none"
      />
      <TextInput
        testID="login-password-input"
        placeholder="Password"
        secureTextEntry
      />
      <TouchableOpacity testID="login-submit-button" onPress={handleLogin}>
        <Text>Log In</Text>
      </TouchableOpacity>
      <TouchableOpacity testID="forgot-password-link" onPress={handleForgotPassword}>
        <Text>Forgot password?</Text>
      </TouchableOpacity>
    </View>
  );
}

testID is the Detox selector. Never use text-based selectors for anything other than error messages — text changes break tests. Role-based queries (like Playwright’s getByRole) aren’t available in Detox; testID is the reliable mechanism.

Writing Detox Tests

Write tests for the login flow:
- Successful login with valid credentials
- Error message for invalid credentials
- Deep link to forgot password
// e2e/auth/login.test.ts
import { device, element, by, expect as detoxExpect } from 'detox';

describe('Login Flow', () => {
  beforeAll(async () => {
    await device.launchApp({ newInstance: true });
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('navigates to home screen after successful login', async () => {
    await element(by.id('login-email-input')).typeText('[email protected]');
    await element(by.id('login-password-input')).typeText('testpassword123');

    // Dismiss keyboard before tapping button
    await element(by.id('login-password-input')).tapReturnKey();

    await element(by.id('login-submit-button')).tap();

    // Wait for navigation — home screen should appear
    await detoxExpect(element(by.id('home-screen'))).toBeVisible();
    await detoxExpect(element(by.id('login-screen'))).not.toBeVisible();
  });

  it('shows error for invalid credentials', async () => {
    await element(by.id('login-email-input')).typeText('[email protected]');
    await element(by.id('login-password-input')).typeText('wrongpassword');
    await element(by.id('login-submit-button')).tap();

    // Error message should appear
    await detoxExpect(element(by.text('Invalid email or password'))).toBeVisible();

    // Should stay on login screen
    await detoxExpect(element(by.id('login-screen'))).toBeVisible();
  });

  it('clears password field after failed login', async () => {
    await element(by.id('login-email-input')).typeText('[email protected]');
    await element(by.id('login-password-input')).typeText('wrongpassword');
    await element(by.id('login-submit-button')).tap();

    // Password should be cleared (security best practice)
    await detoxExpect(element(by.id('login-password-input'))).toHaveText('');
  });
});
Test that push notifications deep link to the correct screen.
The notification payload contains a route and resource ID.
// e2e/navigation/deepLinks.test.ts
describe('Deep Links', () => {
  it('opens notification deep link to specific order', async () => {
    await device.launchApp({
      newInstance: true,
      // Simulate notification tap with payload
      userNotification: {
        trigger: { type: 'push' },
        title: 'Order shipped',
        body: 'Your order #12345 has shipped',
        payload: {
          screen: 'OrderDetail',
          orderId: '12345',
        },
      },
    });

    // App should navigate directly to order detail
    await detoxExpect(element(by.id('order-detail-screen'))).toBeVisible();
    await detoxExpect(element(by.id('order-id-label'))).toHaveText('#12345');
  });

  it('handles URL scheme deep link', async () => {
    await device.launchApp({ newInstance: false });

    await device.openURL({ url: 'myapp://orders/12345' });

    await detoxExpect(element(by.id('order-detail-screen'))).toBeVisible();
  });
});

Handling Flaky Tests

This test fails intermittently — sometimes the button tap doesn't register.
The test says the button is visible, but tap doesn't trigger the action.

Common causes and fixes Claude Code diagnoses:

Animation not finished (most common):

// Brittle — button animates in, tap fires before animation completes
await element(by.id('submit-button')).tap();

// Fixed — wait for animation to complete
await waitFor(element(by.id('submit-button')))
  .toBeVisible()
  .withTimeout(5000);
await element(by.id('submit-button')).tap();

Keyboard blocking the element:

// Button is behind the keyboard
await element(by.id('email-input')).typeText('[email protected]');
await element(by.id('submit-button')).tap(); // Keyboard is covering it!

// Fixed — dismiss keyboard first
await element(by.id('email-input')).tapReturnKey(); // or
await element(by.id('submit-button')).tap({ x: 0, y: 0 }); // careful with coords

Platform-specific timing differences:

// iOS and Android have different animation speeds
await waitFor(element(by.id('loading-indicator')))
  .not.toBeVisible()
  .withTimeout(device.getPlatform() === 'ios' ? 3000 : 5000);

ScrollView needing a scroll:

// Element exists but is off-screen
await element(by.id('checkout-form')).scrollTo('bottom');
await element(by.id('place-order-button')).tap();

Mocking Native Modules in Tests

The app uses a biometric authentication module.
Tests fail because the simulator doesn't have real biometrics.
How do I mock it?
// e2e/support/mocks.ts — Set up before app launch
export async function mockBiometrics(shouldSucceed: boolean) {
  await device.setURLBlacklist(['.*auth.biometric.*']);
  
  // Detox can enroll/unenroll biometrics on simulator
  if (shouldSucceed) {
    await device.setBiometricEnrollment(true);
  } else {
    await device.setBiometricEnrollment(false);
  }
}

// In your test
it('falls back to PIN when biometrics fail', async () => {
  await mockBiometrics(false); // Biometrics not enrolled
  
  await element(by.id('biometric-auth-button')).tap();
  
  // Should show PIN fallback
  await detoxExpect(element(by.id('pin-input-screen'))).toBeVisible();
});

it('unlocks with biometric success', async () => {
  await device.setBiometricEnrollment(true);
  await element(by.id('biometric-auth-button')).tap();
  
  // Simulate successful biometric match
  await device.matchFace(); // iOS FaceID
  // or: await device.matchFinger(); // iOS TouchID / Android Fingerprint
  
  await detoxExpect(element(by.id('home-screen'))).toBeVisible();
});

CI Integration for Mobile Tests

Set up Detox tests in GitHub Actions.
Build the iOS simulator app and run tests.
# .github/workflows/detox-ios.yml
name: iOS E2E Tests

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: macos-14  # M1 mac — required for iOS simulator

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install iOS pods
        run: cd ios && pod install --repo-update

      # Cache the built app between runs
      - name: Cache iOS build
        uses: actions/cache@v4
        with:
          path: ios/build
          key: ios-build-${{ hashFiles('ios/**') }}

      - name: Build Detox (iOS)
        run: npx detox build --configuration ios.sim.debug

      - name: Run Detox Tests
        run: npx detox test --configuration ios.sim.debug --cleanup --headless
        env:
          TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
          TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}

      - name: Upload test artifacts on failure
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: detox-artifacts
          path: artifacts/

Android CI requires an emulator, which needs hardware virtualization:

# For Android — needs an emulator
- name: Enable KVM group perms
  run: |
    echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
    sudo udevadm control --reload-rules
    sudo udevadm trigger --name-match=kvm

- name: AVD cache
  uses: actions/cache@v4
  with:
    path: ~/.android/avd/*
    key: avd-api33

- name: Create AVD
  if: steps.avd-cache.outputs.cache-hit != 'true'
  run: echo "no" | avdmanager create avd -n Pixel_7_API_33 -k "system-images;android-33;google_apis;x86_64"

- name: Run Android tests
  uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: 33
    script: npx detox test --configuration android.emu.debug

Mobile tests are slower than web tests — a full Detox suite on iOS typically takes 8-15 minutes. Parallelize across simulators using Detox’s --maxWorkers flag and shard by feature area. For React Native component-level testing (without simulators), see the React Native guide which covers React Native Testing Library. For the broader E2E testing strategy, see the Playwright/Cypress guide for the web testing complement. The Claude Skills 360 bundle includes mobile testing skill sets for Detox, Maestro, and Appium. Start with the free tier to try mobile test generation.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free