Introduction

Manual testing simply cannot keep pace with modern mobile release cycles. When you are shipping updates weekly or even daily, spending hours clicking through your app before each release is not sustainable. Testing automation has become essential infrastructure for any serious mobile development team.

The mobile testing landscape has evolved significantly over the past few years. While tools like Appium dominated earlier, 2026 has seen Detox and Maestro emerge as the preferred choices for most development teams. Each brings unique strengths that address different testing needs.

This guide covers everything you need to implement effective testing automation using both tools. We will walk through real configuration, practical examples, and the patterns that work in production. By the end, you will have actionable knowledge to significantly improve your app’s reliability.

Why Mobile Testing Automation Matters

Why Mobile Testing Automation Matters Infographic

Before diving into tools, let us establish why testing automation is worth the investment. Mobile apps face challenges that web applications do not:

Device fragmentation: Your app runs on thousands of device configurations with different screen sizes, OS versions, and hardware capabilities.

Platform-specific behaviour: iOS and Android handle gestures, navigation, and background states differently.

Complex user journeys: Mobile apps often involve multi-step flows like authentication, payments, and media handling.

Regression risk: Changes to shared components can break functionality across multiple screens.

Automated testing catches these issues before your users do. Teams with comprehensive test suites ship faster with fewer production bugs. The initial investment in automation pays dividends throughout the app lifecycle.

Unde

Understanding Detox Infographic rstanding Detox

Detox is a grey-box end-to-end testing framework developed by Wix specifically for React Native applications. It runs tests on actual devices and simulators, interacting with your app the way real users do.

Why Detox Excels for React Native

Detox integrates deeply with React Native’s architecture. It understands when your app is idle, when animations complete, and when network requests finish. This synchronisation eliminates the flaky tests that plague other frameworks.

The grey-box approach means Detox has visibility into your app’s internal state. It automatically waits for the JavaScript thread to become idle before proceeding with test actions. This eliminates arbitrary waits and makes tests reliable.

Setting Up Detox

First, install Detox and its dependencies:

# Install Detox CLI globally
npm install -g detox-cli

# Add Detox to your project
npm install --save-dev detox

# For iOS, ensure you have the required tools
brew tap wix/brew
brew install applesimutils

Configure Detox in your project’s .detoxrc.js:

/** @type {Detox.DetoxConfig} */
module.exports = {
  testRunner: {
    args: {
      '$0': 'jest',
      config: 'e2e/jest.config.js'
    },
    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'
    },
    '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'
    },
    'android.debug': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
      build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..',
      reversePorts: [8081]
    },
    'android.release': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
      build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..'
    }
  },
  devices: {
    simulator: {
      type: 'ios.simulator',
      device: {
        type: 'iPhone 15 Pro'
      }
    },
    emulator: {
      type: 'android.emulator',
      device: {
        avdName: 'Pixel_7_API_34'
      }
    }
  },
  configurations: {
    'ios.sim.debug': {
      device: 'simulator',
      app: 'ios.debug'
    },
    'ios.sim.release': {
      device: 'simulator',
      app: 'ios.release'
    },
    'android.emu.debug': {
      device: 'emulator',
      app: 'android.debug'
    },
    'android.emu.release': {
      device: 'emulator',
      app: 'android.release'
    }
  }
};

Create a Jest configuration for your e2e tests at e2e/jest.config.js:

/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  rootDir: '..',
  testMatch: ['<rootDir>/e2e/**/*.test.js'],
  testTimeout: 120000,
  maxWorkers: 1,
  globalSetup: 'detox/runners/jest/globalSetup',
  globalTeardown: 'detox/runners/jest/globalTeardown',
  reporters: ['detox/runners/jest/reporter'],
  testEnvironment: 'detox/runners/jest/testEnvironment',
  verbose: true
};

Writing Detox Tests

Detox tests use a declarative syntax for finding elements and performing actions:

// e2e/login.test.js
describe('Login Flow', () => {
  beforeAll(async () => {
    await device.launchApp();
  });

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

  it('should display login screen on app launch', async () => {
    await expect(element(by.id('login-screen'))).toBeVisible();
    await expect(element(by.id('email-input'))).toBeVisible();
    await expect(element(by.id('password-input'))).toBeVisible();
    await expect(element(by.id('login-button'))).toBeVisible();
  });

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

    await expect(element(by.id('error-message'))).toBeVisible();
    await expect(element(by.text('Invalid email or password'))).toBeVisible();
  });

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

    await expect(element(by.id('home-screen'))).toBeVisible();
    await expect(element(by.id('welcome-message'))).toBeVisible();
  });

  it('should handle forgot password flow', async () => {
    await element(by.id('forgot-password-link')).tap();
    await expect(element(by.id('forgot-password-screen'))).toBeVisible();

    await element(by.id('reset-email-input')).typeText('[email protected]');
    await element(by.id('send-reset-button')).tap();

    await expect(element(by.text('Reset email sent'))).toBeVisible();
  });
});

Add testIDs to your React Native components to enable element selection:

// LoginScreen.tsx
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, Text } from 'react-native';

export function LoginScreen() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleLogin = async () => {
    try {
      await authenticateUser(email, password);
      navigation.navigate('Home');
    } catch (err) {
      setError('Invalid email or password');
    }
  };

  return (
    <View testID="login-screen">
      <TextInput
        testID="email-input"
        value={email}
        onChangeText={setEmail}
        placeholder="Email"
        keyboardType="email-address"
        autoCapitalize="none"
      />
      <TextInput
        testID="password-input"
        value={password}
        onChangeText={setPassword}
        placeholder="Password"
        secureTextEntry
      />
      {error ? (
        <Text testID="error-message">{error}</Text>
      ) : null}
      <TouchableOpacity testID="login-button" onPress={handleLogin}>
        <Text>Log In</Text>
      </TouchableOpacity>
      <TouchableOpacity
        testID="forgot-password-link"
        onPress={() => navigation.navigate('ForgotPassword')}
      >
        <Text>Forgot Password?</Text>
      </TouchableOpacity>
    </View>
  );
}

Advanced Detox Patterns

Testing complex interactions requires more sophisticated approaches:

// e2e/checkout.test.js
describe('Checkout Flow', () => {
  beforeAll(async () => {
    await device.launchApp({ newInstance: true });
    // Login before running checkout tests
    await loginAsTestUser();
  });

  it('should complete purchase with saved card', async () => {
    // Navigate to a product
    await element(by.id('product-list')).scroll(200, 'down');
    await element(by.id('product-item-123')).tap();

    // Add to cart
    await element(by.id('add-to-cart-button')).tap();
    await expect(element(by.id('cart-badge'))).toHaveText('1');

    // Proceed to checkout
    await element(by.id('view-cart-button')).tap();
    await element(by.id('checkout-button')).tap();

    // Select saved payment method
    await element(by.id('saved-card-visa-4242')).tap();
    await element(by.id('pay-now-button')).tap();

    // Verify order confirmation
    await waitFor(element(by.id('order-confirmation-screen')))
      .toBeVisible()
      .withTimeout(10000);

    await expect(element(by.id('order-number'))).toBeVisible();
  });

  it('should handle network failure during payment', async () => {
    // Add item and proceed to checkout
    await element(by.id('product-item-456')).tap();
    await element(by.id('add-to-cart-button')).tap();
    await element(by.id('view-cart-button')).tap();
    await element(by.id('checkout-button')).tap();

    // Simulate network failure before payment
    await device.setURLBlacklist(['.*api.stripe.com.*']);

    await element(by.id('saved-card-visa-4242')).tap();
    await element(by.id('pay-now-button')).tap();

    // Verify error handling
    await expect(element(by.text('Payment failed. Please try again.'))).toBeVisible();

    // Restore network
    await device.setURLBlacklist([]);
  });
});

async function loginAsTestUser() {
  await element(by.id('email-input')).typeText('[email protected]');
  await element(by.id('password-input')).typeText('testpassword123');
  await element(by.id('login-button')).tap();
  await waitFor(element(by.id('home-screen'))).toBeVisible().withTimeout(5000);
}

Unders

Understanding Maestro Infographic tanding Maestro

Maestro is a mobile UI testing framework that takes a different approach. Created by mobile.dev, it uses a YAML-based syntax that makes tests readable and maintainable without deep programming knowledge.

Why Maestro Appeals to Teams

Maestro’s declarative YAML syntax is its standout feature. Product managers and QA engineers can write and understand tests without JavaScript knowledge. This democratises testing across the team.

Maestro also works with any mobile framework, not just React Native. Native iOS (Swift/SwiftUI), native Android (Kotlin/Compose), Flutter, and React Native apps all work equally well.

Setting Up Maestro

Install Maestro with a single command:

# macOS/Linux
curl -Ls "https://get.maestro.mobile.dev" | bash

# Verify installation
maestro --version

No project configuration is required. Maestro uses the app directly from your device or simulator.

Writing Maestro Tests

Create test files in a maestro folder at your project root:

# maestro/login-flow.yaml
appId: com.yourcompany.yourapp
---
- launchApp

# Verify login screen elements
- assertVisible: "Email"
- assertVisible: "Password"
- assertVisible: "Log In"

# Test invalid login
- tapOn: "Email"
- inputText: "[email protected]"
- tapOn: "Password"
- inputText: "wrongpassword"
- tapOn: "Log In"
- assertVisible: "Invalid email or password"

# Clear and test valid login
- clearState
- launchApp
- tapOn: "Email"
- inputText: "[email protected]"
- tapOn: "Password"
- inputText: "correctpassword"
- tapOn: "Log In"
- assertVisible: "Welcome"

Run your test:

maestro test maestro/login-flow.yaml

Advanced Maestro Features

Maestro supports variables, conditionals, and reusable flows:

# maestro/config/test-users.yaml
TEST_USER_EMAIL: [email protected]
TEST_USER_PASSWORD: correctpassword123
# maestro/flows/login.yaml
appId: com.yourcompany.yourapp
---
- tapOn: "Email"
- inputText: ${TEST_USER_EMAIL}
- tapOn: "Password"
- inputText: ${TEST_USER_PASSWORD}
- tapOn: "Log In"
- assertVisible: "Welcome"
# maestro/checkout-flow.yaml
appId: com.yourcompany.yourapp
env:
  TEST_USER_EMAIL: [email protected]
  TEST_USER_PASSWORD: correctpassword123
---
- launchApp

# Include login flow
- runFlow: flows/login.yaml

# Navigate to products
- tapOn: "Shop"
- scrollUntilVisible:
    element: "Premium Headphones"
    direction: DOWN
- tapOn: "Premium Headphones"

# Add to cart
- tapOn: "Add to Cart"
- assertVisible: "Added to cart"

# Proceed to checkout
- tapOn: "Cart"
- assertVisible: "Premium Headphones"
- assertVisible: "$299.00"
- tapOn: "Checkout"

# Complete payment
- tapOn: "Pay with saved card"
- tapOn: "Confirm Payment"

# Verify success
- assertVisible: "Order Confirmed"
- takeScreenshot: order-confirmation

Handling Complex Interactions in Maestro

# maestro/media-upload.yaml
appId: com.yourcompany.yourapp
---
- launchApp
- runFlow: flows/login.yaml

# Navigate to profile
- tapOn: "Profile"
- tapOn: "Edit Profile"

# Test photo upload
- tapOn: "Change Photo"
- tapOn: "Choose from Library"
# Maestro can interact with system dialogs
- tapOn: "Allow Access"
- tapOn:
    index: 0 # Select first photo

# Wait for upload
- assertVisible:
    text: "Photo updated"
    timeout: 10000

# Verify change persisted
- tapOn: "Save"
- assertVisible: "Profile saved"

# Re-open profile to verify
- tapOn: "Profile"
- assertVisible: "Edit Profile" # Confirms we're on profile screen

Detox vs Maestro: Making the Choice

Both tools are excellent, but they serve different needs.

Choose Detox When

You are building with React Native: Detox’s deep integration with React Native provides synchronisation that eliminates flaky tests.

You need programmatic flexibility: Complex test logic, custom assertions, and integration with existing JavaScript tooling.

CI/CD integration is critical: Detox integrates seamlessly with Jest, which works with most CI systems.

Your team is comfortable with JavaScript: Detox tests are JavaScript, so developer buy-in is higher.

Choose Maestro When

Cross-platform native apps: Maestro works equally well with Swift, Kotlin, Flutter, and React Native.

Non-developers will write tests: YAML syntax is accessible to QA engineers and product managers.

Rapid test development: Maestro’s interactive mode lets you record tests by performing actions.

You prioritise simplicity: No project configuration, no build step, just write YAML and run.

Using Both Tools

Many teams use both. Detox for complex flows requiring programmatic control, Maestro for smoke tests and flows that non-developers maintain:

/e2e
  /detox           # Complex integration tests
    login.test.js
    checkout.test.js
    payments.test.js
  /maestro         # Smoke tests and simple flows
    smoke-test.yaml
    onboarding.yaml
    settings.yaml

Test Strategy Best Practices

Structure Your Test Pyramid

Not every test needs to be an end-to-end test. Follow the testing pyramid:

Unit tests (70%): Fast, isolated tests for business logic. Use Jest for JavaScript/TypeScript.

Integration tests (20%): Test component interactions. Use React Native Testing Library.

End-to-end tests (10%): Full user journeys with Detox or Maestro. Focus on critical paths.

Identify Critical Paths

Not every feature needs E2E coverage. Focus on:

  • Authentication flows: Login, registration, password reset
  • Revenue-generating flows: Checkout, subscription, payments
  • Core value proposition: The main feature users come for
  • Onboarding: First-time user experience

Handle Test Data

Tests need consistent, isolated data:

// e2e/utils/testData.js
export const testUsers = {
  standard: {
    email: '[email protected]',
    password: 'TestPassword123!'
  },
  premium: {
    email: '[email protected]',
    password: 'TestPassword123!'
  }
};

export async function seedTestData() {
  // Call your API to set up test data
  await fetch('https://api.yourapp.com/test/seed', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.TEST_API_KEY}`
    }
  });
}

export async function cleanupTestData() {
  await fetch('https://api.yourapp.com/test/cleanup', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.TEST_API_KEY}`
    }
  });
}

CI/CD Integration

Run tests automatically on every pull request:

# .github/workflows/e2e-tests.yml
name: E2E Tests

on:
  pull_request:
    branches: [main, develop]

jobs:
  detox-ios:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Detox CLI
        run: npm install -g detox-cli

      - name: Install pods
        run: cd ios && pod install

      - name: Build app for testing
        run: detox build --configuration ios.sim.release

      - name: Run Detox tests
        run: detox test --configuration ios.sim.release --cleanup

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

  maestro-smoke:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Install Maestro
        run: curl -Ls "https://get.maestro.mobile.dev" | bash

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build iOS app
        run: |
          cd ios
          xcodebuild -workspace YourApp.xcworkspace \
            -scheme YourApp \
            -configuration Release \
            -sdk iphonesimulator \
            -derivedDataPath build

      - name: Boot simulator
        run: |
          xcrun simctl boot "iPhone 15 Pro"

      - name: Install app on simulator
        run: |
          xcrun simctl install booted ios/build/Build/Products/Release-iphonesimulator/YourApp.app

      - name: Run Maestro smoke tests
        run: ~/.maestro/bin/maestro test maestro/smoke-test.yaml

Debugging Failed Tests

Detox Debugging

Enable verbose logging when tests fail:

# Run with debug logging
detox test --configuration ios.sim.debug --loglevel trace

# Record video of test run
detox test --configuration ios.sim.debug --record-videos failing

Take screenshots on failure:

// e2e/utils/testHelpers.js
export async function takeScreenshotOnFailure(testName) {
  try {
    await device.takeScreenshot(testName);
  } catch (error) {
    console.error('Failed to take screenshot:', error);
  }
}

// In your test
afterEach(async function() {
  if (this.currentTest.state === 'failed') {
    await takeScreenshotOnFailure(this.currentTest.title);
  }
});

Maestro Debugging

Use Maestro Studio for interactive debugging:

# Launch interactive mode
maestro studio

# Record a test by performing actions in the app
maestro record maestro/new-flow.yaml

View test execution in real-time:

# Run with verbose output
maestro test --debug-output maestro/checkout.yaml

Performance Considerations

End-to-end tests are slower than unit tests. Optimise execution time:

Parallelise test execution: Run iOS and Android tests simultaneously in CI.

Use release builds: Debug builds are significantly slower.

Minimise app state resets: Group related tests that can share state.

Skip animations in tests: Disable animations to speed up execution.

// Disable animations for Detox tests
// In your app's entry point
if (typeof process.env.DETOX_TESTING !== 'undefined') {
  // Disable layout animations
  LayoutAnimation.configureNext = () => {};
  // Disable Animated animations
  jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
}

Conclusion

Testing automation is not optional for teams shipping quality mobile apps in 2026. The tools have matured to the point where setup is straightforward and the benefits are immediate.

Detox provides deep React Native integration with programmatic power. Maestro offers simplicity and accessibility for diverse teams. Many organisations use both to cover different testing needs.

Start with your critical paths. Get those automated first, then expand coverage incrementally. The goal is not 100% coverage but confidence in your most important functionality.

Your future self, responding to production issues at 2 AM, will thank you for investing in automation today.


Building a mobile app that needs reliable testing automation? Our team has implemented testing strategies for apps serving millions of users. Contact us to discuss your testing needs.