Detox vs Maestro vs Appium: React Native E2E Testing 2026
Detox vs Maestro vs Appium: React Native E2E Testing 2026
TL;DR
End-to-end testing on mobile is notoriously painful — simulators are slow, tests are flaky, and the feedback loop is long. The three leading options for React Native in 2026 each take a fundamentally different approach. Detox is the gray-box testing framework built specifically for React Native — it synchronizes with the JS thread to eliminate timing-based flakiness, writes tests in JavaScript/TypeScript, and has the deepest React Native integration. Maestro takes a YAML-based declarative approach — no code required, extremely fast to write tests, and smart enough to retry flaky assertions automatically; ideal for quickly covering critical user flows. Appium is the established cross-platform automation standard — WebDriver protocol, supports any language (JS, Python, Java), covers native iOS and Android without React Native-specific knowledge, but requires more setup and is generally slower. For React Native apps where test stability is paramount: Detox. For fast coverage of user flows without writing code: Maestro. For cross-platform teams already invested in WebDriver or multi-framework testing: Appium.
Key Takeaways
- Detox uses gray-box synchronization — waits for React Native JS thread to idle before acting
- Maestro uses YAML — readable, codeless test files; no Jest/TypeScript required
- Appium uses WebDriver protocol — language-agnostic; works with any test runner
- Detox requires simulator build — compiled
.appor.apk, not Metro dev server - Maestro auto-retries assertions — built-in flakiness tolerance without explicit waits
- Appium supports real-device farms — BrowserStack, Sauce Labs, AWS Device Farm
- All three integrate with CI — GitHub Actions, Bitrise, CircleCI
Comparison at a Glance
React Native focused, JS tests, best stability → Detox
YAML tests, zero code, fastest to write → Maestro
Cross-platform, any language, WebDriver → Appium
Flakiness tolerance:
Detox → gray-box sync (eliminates most flakiness at source)
Maestro → auto-retry (tolerates flakiness by retrying)
Appium → manual waits/retries (must handle yourself)
Speed:
Maestro → fastest test execution
Detox → mid (sync overhead)
Appium → slowest (WebDriver round-trips)
CI difficulty:
Detox → Medium (build step required)
Maestro → Easy (binary + test file)
Appium → Hard (Appium server + driver config)
Detox: Gray-Box React Native Testing
Detox was purpose-built for React Native by Wix Engineering. It hooks into the React Native runtime to know exactly when the app is idle — no sleep(2000) hacks needed.
Installation
npm install --save-dev detox @types/detox
npm install --save-dev jest jest-circus @types/jest
# Install Detox CLI
npm install -g detox-cli
Configuration
// .detoxrc.json
{
"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"
},
"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" }
},
"emulator": {
"type": "android.emulator",
"device": { "avd": "Pixel_6_API_34" }
}
},
"configurations": {
"ios.sim.debug": {
"device": "simulator",
"app": "ios.debug"
},
"android.emu.debug": {
"device": "emulator",
"app": "android.debug"
}
}
}
// e2e/jest.config.js
module.exports = {
rootDir: "..",
testMatch: ["<rootDir>/e2e/**/*.test.ts"],
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 Tests
// e2e/login.test.ts
import { device, element, by, expect } from "detox";
describe("Login Flow", () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
beforeEach(async () => {
await device.reloadReactNative();
});
it("should show login screen", async () => {
await expect(element(by.id("login-screen"))).toBeVisible();
await expect(element(by.text("Sign In"))).toBeVisible();
});
it("should login with valid credentials", async () => {
// Type in email field
await element(by.id("email-input")).tap();
await element(by.id("email-input")).typeText("user@example.com");
// Type in password field
await element(by.id("password-input")).tap();
await element(by.id("password-input")).typeText("password123");
// Submit
await element(by.id("login-button")).tap();
// Detox waits for app idle automatically — no sleep needed
await expect(element(by.id("dashboard-screen"))).toBeVisible();
});
it("should show error for invalid credentials", async () => {
await element(by.id("email-input")).typeText("wrong@example.com");
await element(by.id("password-input")).typeText("wrongpass");
await element(by.id("login-button")).tap();
await expect(element(by.text("Invalid credentials"))).toBeVisible();
});
});
Scroll and Swipe
// e2e/product-list.test.ts
describe("Product List", () => {
it("should scroll and find a product", async () => {
// Scroll a FlatList/ScrollView
await element(by.id("product-list")).scroll(500, "down");
// Scroll until element is visible
await waitFor(element(by.id("product-apple-watch")))
.toBeVisible()
.whileElement(by.id("product-list"))
.scroll(200, "down");
await element(by.id("product-apple-watch")).tap();
await expect(element(by.id("product-detail-screen"))).toBeVisible();
});
it("should swipe to dismiss", async () => {
await element(by.id("modal")).swipe("down", "fast", 0.8);
await expect(element(by.id("modal"))).not.toBeVisible();
});
});
Device Actions
// e2e/deep-link.test.ts
describe("Deep Links", () => {
it("should handle deep link navigation", async () => {
await device.launchApp({
newInstance: true,
url: "myapp://products/123",
});
await expect(element(by.id("product-detail-123"))).toBeVisible();
});
it("should handle background/foreground", async () => {
await element(by.id("start-upload")).tap();
// Send app to background
await device.sendToHome();
await device.launchApp({ newInstance: false });
// Upload should have continued
await expect(element(by.text("Upload complete"))).toBeVisible();
});
it("should handle push notification", async () => {
await device.sendUserNotification({
trigger: { type: "push" },
title: "New Message",
body: "You have a new message",
payload: { screen: "chat", chatId: "456" },
});
await expect(element(by.id("chat-screen-456"))).toBeVisible();
});
});
Build and Run
# Build app (required before first test run)
detox build --configuration ios.sim.debug
# Run all tests
detox test --configuration ios.sim.debug
# Run specific test file
detox test --configuration ios.sim.debug e2e/login.test.ts
# Run with verbose output
detox test --configuration ios.sim.debug --loglevel verbose
# Android
detox build --configuration android.emu.debug
detox test --configuration android.emu.debug
Maestro: YAML-Based Mobile Testing
Maestro takes a completely different approach — tests are YAML files that describe user interactions declaratively. No TypeScript, no build step, no test runner configuration.
Installation
# macOS
curl -Ls "https://get.maestro.mobile.dev" | bash
# Verify
maestro --version
Basic Test (YAML)
# flows/login.yaml
appId: com.yourapp.bundle
---
- launchApp
- assertVisible: "Sign In"
- tapOn:
id: "email-input"
- inputText: "user@example.com"
- tapOn:
id: "password-input"
- inputText: "password123"
- tapOn:
id: "login-button"
- assertVisible: "Welcome back!"
- assertVisible:
id: "dashboard-screen"
Selectors
# flows/selectors.yaml
appId: com.yourapp.bundle
---
- launchApp
# Tap by text
- tapOn: "Add to Cart"
# Tap by accessibility ID (testID in React Native)
- tapOn:
id: "add-to-cart-button"
# Tap by index (when multiple elements match)
- tapOn:
text: "Buy Now"
index: 0
# Assert by text
- assertVisible: "Product added to cart"
# Assert not visible
- assertNotVisible: "Loading..."
# Assert by accessibility ID
- assertVisible:
id: "success-banner"
Scrolling and Navigation
# flows/product-browse.yaml
appId: com.yourapp.bundle
---
- launchApp
- tapOn: "Shop"
# Scroll down
- scroll
# Scroll until element visible
- scrollUntilVisible:
element:
text: "Running Shoes"
direction: DOWN
timeout: 15000
- tapOn: "Running Shoes"
- assertVisible:
id: "product-detail"
# Swipe
- swipe:
direction: LEFT
duration: 500
Input and Forms
# flows/checkout.yaml
appId: com.yourapp.bundle
---
- launchApp
- tapOn: "Cart"
- tapOn: "Checkout"
# Clear and type
- clearTextField:
id: "card-number"
- inputText:
text: "4242424242424242"
id: "card-number"
# Dismiss keyboard
- hideKeyboard
- tapOn: "Place Order"
- assertVisible: "Order confirmed!"
Subflows (Reusable)
# flows/_login.yaml — reusable subflow
appId: com.yourapp.bundle
---
- tapOn:
id: "email-input"
- inputText: ${EMAIL:-user@example.com}
- tapOn:
id: "password-input"
- inputText: ${PASSWORD:-password123}
- tapOn:
id: "login-button"
- assertVisible:
id: "dashboard"
# flows/checkout.yaml — uses login subflow
appId: com.yourapp.bundle
---
- launchApp
- runFlow: _login.yaml
- tapOn: "Shop"
- tapOn: "Running Shoes"
- tapOn: "Add to Cart"
- tapOn: "Checkout"
- assertVisible: "Order Summary"
Running Tests
# Run single flow
maestro test flows/login.yaml
# Run all flows in directory
maestro test flows/
# Run with device (real device via USB)
maestro test --device <udid> flows/login.yaml
# Interactive studio (visual test recorder)
maestro studio
# Cloud (Maestro Cloud — parallel execution)
maestro cloud flows/
CI Integration
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
maestro:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install Maestro
run: curl -Ls "https://get.maestro.mobile.dev" | bash
- name: Boot iOS Simulator
run: |
xcrun simctl boot "iPhone 15"
xcrun simctl list | grep Booted
- name: Install app on simulator
run: |
xcrun simctl install booted ios/build/Debug-iphonesimulator/YourApp.app
- name: Run Maestro tests
run: ~/.maestro/bin/maestro test flows/
Appium: WebDriver Protocol
Appium implements the WebDriver protocol for mobile — it exposes iOS (XCUITest) and Android (UIAutomator2) via standard WebDriver commands, letting you use any language or test framework.
Installation
# Install Appium
npm install -g appium
# Install drivers
appium driver install xcuitest # iOS
appium driver install uiautomator2 # Android
# Start Appium server
appium
TypeScript Setup (WebdriverIO)
npm install --save-dev @wdio/cli @wdio/appium-service @wdio/mocha-framework
npx wdio config
// wdio.conf.ts
import type { Options } from "@wdio/types";
export const config: Options.Testrunner = {
runner: "local",
specs: ["./test/**/*.test.ts"],
framework: "mocha",
mochaOpts: { timeout: 60000 },
reporters: ["spec"],
services: ["appium"],
appium: {
args: {
address: "localhost",
port: 4723,
relaxedSecurity: true,
},
},
capabilities: [
{
platformName: "iOS",
"appium:deviceName": "iPhone 15",
"appium:platformVersion": "17.0",
"appium:automationName": "XCUITest",
"appium:app": "/path/to/YourApp.app",
"appium:bundleId": "com.yourapp.bundle",
},
],
};
Writing Tests (WebdriverIO + Mocha)
// test/login.test.ts
describe("Login Flow", () => {
it("should login successfully", async () => {
// Find by accessibility ID (testID in React Native)
const emailInput = await $("~email-input");
const passwordInput = await $("~password-input");
const loginButton = await $("~login-button");
await emailInput.setValue("user@example.com");
await passwordInput.setValue("password123");
await loginButton.click();
// Wait for navigation
const dashboard = await $("~dashboard-screen");
await dashboard.waitForDisplayed({ timeout: 10000 });
await expect(dashboard).toBeDisplayed();
});
it("should show error for invalid login", async () => {
const emailInput = await $("~email-input");
await emailInput.setValue("wrong@example.com");
const passwordInput = await $("~password-input");
await passwordInput.setValue("wrongpass");
await $("~login-button").click();
// Wait for error message
const errorMsg = await $("//XCUIElementTypeStaticText[@name='Invalid credentials']");
await errorMsg.waitForDisplayed({ timeout: 5000 });
await expect(errorMsg).toBeDisplayed();
});
});
Scrolling in Appium
// Scroll using mobile scroll gesture (iOS)
async function scrollDown(times: number = 1) {
for (let i = 0; i < times; i++) {
await browser.execute("mobile: scroll", {
direction: "down",
distance: 0.5,
});
}
}
// Scroll to element
async function scrollToElement(selector: string) {
await browser.execute("mobile: scroll", {
direction: "down",
predicateString: `name == '${selector}'`,
});
}
// Swipe gesture
async function swipeLeft() {
const { width, height } = await browser.getWindowSize();
await browser.touchAction([
{ action: "press", x: width * 0.8, y: height * 0.5 },
{ action: "moveTo", x: width * 0.2, y: height * 0.5 },
{ action: "release" },
]);
}
Page Object Model
// test/pages/LoginPage.ts
export class LoginPage {
get emailInput() {
return $("~email-input");
}
get passwordInput() {
return $("~password-input");
}
get loginButton() {
return $("~login-button");
}
get errorMessage() {
return $("~error-message");
}
async login(email: string, password: string) {
await (await this.emailInput).setValue(email);
await (await this.passwordInput).setValue(password);
await (await this.loginButton).click();
}
}
// test/login.test.ts
import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
describe("Login", () => {
const loginPage = new LoginPage();
const dashboardPage = new DashboardPage();
it("should navigate to dashboard on valid login", async () => {
await loginPage.login("user@example.com", "password123");
await expect(await dashboardPage.screen).toBeDisplayed();
});
});
Real Device Cloud Integration
// wdio.conf.browserstack.ts — BrowserStack Appium
export const config = {
user: process.env.BROWSERSTACK_USER,
key: process.env.BROWSERSTACK_KEY,
hostname: "hub.browserstack.com",
capabilities: [
{
platformName: "iOS",
"appium:deviceName": "iPhone 15 Pro",
"appium:platformVersion": "17",
"appium:app": `bs://your-app-id`,
"bstack:options": {
projectName: "My App E2E",
buildName: `Build ${process.env.GITHUB_RUN_NUMBER}`,
sessionName: "Login Tests",
video: true,
networkLogs: true,
},
},
],
};
Feature Comparison
| Feature | Detox | Maestro | Appium |
|---|---|---|---|
| Test language | TypeScript/JavaScript | YAML | Any (JS, Python, Java...) |
| RN integration | ✅ Native gray-box | ✅ Good | ✅ Via UIAutomator/XCUITest |
| Flakiness handling | ✅ Sync with JS thread | ✅ Auto-retry | ❌ Manual waits |
| Setup complexity | Medium | Low | High |
| Build required | ✅ Must build first | ✅ Must build first | ✅ Must build first |
| Cross-platform | iOS + Android | iOS + Android | iOS + Android + Web |
| Real device cloud | ✅ | ✅ Maestro Cloud | ✅ BrowserStack/Sauce Labs |
| Interactive recorder | ❌ | ✅ Maestro Studio | ❌ |
| TypeScript types | ✅ Excellent | ❌ (YAML) | ✅ (via WebdriverIO) |
| CI difficulty | Medium | Easy | Hard |
| Community | Large (RN-focused) | Growing fast | Very large (cross-platform) |
| npm weekly | 150k | N/A (CLI) | 2M+ (WebdriverIO) |
| GitHub stars | 10.5k | 4k | 9k (Appium core) |
When to Use Each
Choose Detox if:
- React Native is your primary platform and test stability is the top priority
- Gray-box synchronization eliminates the class of flakiness from async React Native operations
- TypeScript test code is preferred (IDE support, refactoring, type safety)
- Running on the same CI infrastructure already building the RN app
Choose Maestro if:
- Getting test coverage fast — QA team writes YAML without coding skills
- Covering critical user paths (onboarding, login, checkout) without complex setup
- Test flakiness is a problem and auto-retry is preferred over deep synchronization
- Interactive test recording (
maestro studio) speeds up test authoring
Choose Appium if:
- Testing native iOS/Android apps alongside React Native (one test infrastructure)
- Your team uses Java/Python/Ruby for testing (not JS-only)
- Real device cloud testing (BrowserStack, Sauce Labs) is required with full WebDriver support
- Existing Selenium/WebDriver expertise in the team
Methodology
Data sourced from official Detox documentation (wix.github.io/Detox), Maestro documentation (maestro.mobile.dev), Appium documentation (appium.io), npm download statistics as of February 2026, GitHub star counts as of February 2026, and community discussions from the Detox GitHub discussions, React Native Discord, and r/reactnative.
Related: Expo EAS vs Fastlane vs Bitrise for the CI/CD pipelines that run these E2E tests, or React Native MMKV vs AsyncStorage vs Expo SecureStore for the storage abstractions often tested in E2E flows.