A complete setup for automated iOS testing of Expo/React Native apps using Nix for reproducible environments and Maestro for reliable UI automation.
- Reproducible Environment: Nix ensures consistent tooling across machines
- Intelligent Detection: Replaces fixed sleeps with reactive app state monitoring
- Optimized Performance: ~50s total runtime with adaptive timing
- Reliable CI Pipeline: Handles SDK conflicts and simulator management
- Comprehensive Error Handling: Graceful degradation and detailed logging
├── flake.nix # Nix development environment
├── .envrc # direnv integration
├── scripts/
│ └── ci-test.sh # Optimized CI testing script
├── packages/mobile-app/
│ ├── package.json # NPM scripts configuration
│ └── test-app.yaml # Maestro test specification
└── README.md # This documentation
# Install direnv if not already installed
# macOS: brew install direnv
# Add to your shell profile: eval "$(direnv hook bash)"
# Clone and setup
git clone <your-repo>
cd <your-repo>
direnv allow # This will install Nix dependencies automatically
# Verify installation
maestro --version
bun --version
# Local development with visible simulator
cd packages/mobile-app
bun test
# Headless CI testing
bun test:ci
# Start development server
bun start
Phase | Before | After | Improvement |
---|---|---|---|
App Loading | ~24s | ~24s | No change |
Initialization | 8s fixed | 0-6s adaptive | Up to 8s faster |
Total Runtime | ~69s | ~50s | 28% improvement |
- Reactive Detection: Replace fixed sleeps with app state monitoring
- Parallel Operations: Run server startup and app loading concurrently
- Fast Polling: Check every 2s instead of long waits
- Multi-layer Verification: UI + logs + functionality checks
The core innovation is replacing fixed sleep
commands with intelligent detection that responds immediately when the app is ready:
# Before: Fixed delay regardless of actual app state
sleep 8
# After: Reactive detection with multiple verification layers
wait_for_app_ready() {
local ready_indicators=0
# Check UI elements are present
if maestro --udid "$DEVICE_ID" query text="App Title" > /dev/null 2>&1; then
((ready_indicators++))
fi
# Check logs for initialization completion
if grep -q "INITIALIZATION_COMPLETE" expo.log 2>/dev/null; then
((ready_indicators++))
fi
# Proceed if 2+ indicators ready
if [[ $ready_indicators -ge 2 ]]; then
return 0 # App is ready!
fi
}
Handles the common issue where Nix overrides Xcode SDK paths:
# Fix SDK environment conflicts
unset SDKROOT NIX_APPLE_SDK_VERSION NIX_LDFLAGS NIX_CFLAGS_COMPILE 2>/dev/null || true
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
name: iOS Tests
on: [push, pull_request]
jobs:
test-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Nix
uses: cachix/install-nix-action@v22
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Setup direnv
uses: HatsuneMiku3939/direnv-action@v1
- name: Run iOS Tests
run: |
cd packages/mobile-app
bun test:ci
# Test the full CI pipeline locally
./scripts/ci-test.sh
# With custom simulator
SIMULATOR_NAME="MyTest" ./scripts/ci-test.sh
SDK Conflicts
# Problem: xcrun simctl fails with "unable to find sdk: 'macosx'"
# Solution: Ensure proper SDK environment in .envrc
unset SDKROOT NIX_APPLE_SDK_VERSION
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
Slow App Detection
# Problem: Fixed sleeps waste time
# Solution: Use reactive detection
app_ready() {
maestro --udid "$DEVICE_ID" query text="Ready Indicator" > /dev/null 2>&1
}
Flaky Maestro Tests
# Problem: Timing-dependent assertions fail intermittently
# Solution: Use optional assertions with multiple fallbacks
- assertVisible:
text: "Expected Element"
optional: true
- tapOn:
text: "Button|Alternative Button"
optional: true
Maestro Connection Issues
# Problem: Maestro can't connect to simulator
# Check simulator state
xcrun simctl list devices
# Restart maestro daemon
maestro stop-daemon
maestro start-daemon
- Optional Assertions: Use
optional: true
for reliability - Multiple Selectors: Provide fallback text patterns
- Progressive Enhancement: Start simple, add complexity gradually
- Parallel Execution: Run independent operations concurrently
- Smart Polling: Use 2s intervals instead of long waits
- Fail-Fast: Detect errors early to save time
- Comprehensive Cleanup: Always cleanup simulators and processes
- Error Handling: Provide detailed error messages and debugging info
- Graceful Degradation: Continue with warnings instead of hard failures
# Customize detection for your app
wait_for_app_ready() {
# Check for your app's specific ready state
if maestro --udid "$DEVICE_ID" query text="Your App Title" > /dev/null 2>&1; then
return 0
fi
# Check logs for your initialization pattern
if grep -q "YOUR_APP_READY" expo.log 2>/dev/null; then
return 0
fi
}
# Run different test suites
maestro test test-onboarding.yaml
maestro test test-core-features.yaml
maestro test test-edge-cases.yaml
# Add timing measurements
start_time=$(date +%s)
wait_for_app_ready
end_time=$(date +%s)
echo "App ready in $((end_time - start_time))s"
This setup provides a production-ready foundation for automated Expo/React Native testing with excellent performance and reliability characteristics. The intelligent detection system alone can save 8+ seconds per test run while improving reliability through multi-layer verification.