Detox Mobile Testing Expert
Эксперт по E2E тестированию React Native приложений с Detox.
Core Testing Principles
Synchronization
Автоматическая синхронизация с React Native bridge
Синхронизация с анимациями и сетевыми запросами
waitFor()
для явных ожиданий
toBeVisible()
вместо
toExist()
для стабильности
Test Organization
AAA pattern (Arrange, Act, Assert)
Изоляция через
beforeEach()
и
afterEach()
describe()
для группировки
Page Object pattern для сложного UI
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/MyApp.app"
,
"build"
:
"xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build"
}
,
"ios.release"
:
{
"type"
:
"ios.app"
,
"binaryPath"
:
"ios/build/Build/Products/Release-iphonesimulator/MyApp.app"
,
"build"
:
"xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -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"
}
,
"android.release"
:
{
"type"
:
"android.apk"
,
"binaryPath"
:
"android/app/build/outputs/apk/release/app-release.apk"
,
"build"
:
"cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release"
}
}
,
"devices"
:
{
"simulator"
:
{
"type"
:
"ios.simulator"
,
"device"
:
{
"type"
:
"iPhone 14"
}
}
,
"emulator"
:
{
"type"
:
"android.emulator"
,
"device"
:
{
"avdName"
:
"Pixel_4_API_30"
}
}
}
,
"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"
}
}
}
Jest Config
// e2e/jest.config.js
module
.
exports
=
{
rootDir
:
'..'
,
testMatch
:
[
'/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
}
;
Basic Test Structure
describe
(
'Login Flow'
,
(
)
=>
{
beforeAll
(
async
(
)
=>
{
await
device
.
launchApp
(
)
;
}
)
;
beforeEach
(
async
(
)
=>
{
await
device
.
reloadReactNative
(
)
;
}
)
;
afterAll
(
async
(
)
=>
{
await
device
.
terminateApp
(
)
;
}
)
;
it
(
'should login with valid credentials'
,
async
(
)
=>
{
// Arrange
const
email
=
'test@example.com'
;
const
password
=
'password123'
;
// Act
await
element
(
by
.
id
(
'email-input'
)
)
.
typeText
(
email
)
;
await
element
(
by
.
id
(
'password-input'
)
)
.
typeText
(
password
)
;
await
element
(
by
.
id
(
'login-button'
)
)
.
tap
(
)
;
// Assert
await
expect
(
element
(
by
.
id
(
'home-screen'
)
)
)
.
toBeVisible
(
)
;
}
)
;
it
(
'should show error for invalid credentials'
,
async
(
)
=>
{
// Arrange
const
email
=
'wrong@example.com'
;
const
password
=
'wrongpassword'
;
// Act
await
element
(
by
.
id
(
'email-input'
)
)
.
typeText
(
email
)
;
await
element
(
by
.
id
(
'password-input'
)
)
.
typeText
(
password
)
;
await
element
(
by
.
id
(
'login-button'
)
)
.
tap
(
)
;
// Assert
await
expect
(
element
(
by
.
id
(
'error-message'
)
)
)
.
toBeVisible
(
)
;
await
expect
(
element
(
by
.
text
(
'Invalid credentials'
)
)
)
.
toBeVisible
(
)
;
}
)
;
}
)
;
Element Matchers
// By testID
element
(
by
.
id
(
'submit-button'
)
)
// By text
element
(
by
.
text
(
'Submit'
)
)
// By label (accessibility)
element
(
by
.
label
(
'Submit form'
)
)
// By type
element
(
by
.
type
(
'RCTTextInput'
)
)
// By traits (iOS)
element
(
by
.
traits
(
[
'button'
]
)
)
// Combining matchers
element
(
by
.
id
(
'item'
)
.
withAncestor
(
by
.
id
(
'list'
)
)
)
element
(
by
.
id
(
'item'
)
.
withDescendant
(
by
.
text
(
'Title'
)
)
)
// Index for multiple matches
element
(
by
.
id
(
'list-item'
)
)
.
atIndex
(
0
)
Actions
// Tap
await
element
(
by
.
id
(
'button'
)
)
.
tap
(
)
;
await
element
(
by
.
id
(
'button'
)
)
.
multiTap
(
2
)
;
await
element
(
by
.
id
(
'button'
)
)
.
longPress
(
)
;
await
element
(
by
.
id
(
'button'
)
)
.
longPress
(
2000
)
;
// 2 seconds
// Text input
await
element
(
by
.
id
(
'input'
)
)
.
typeText
(
'Hello'
)
;
await
element
(
by
.
id
(
'input'
)
)
.
replaceText
(
'New text'
)
;
await
element
(
by
.
id
(
'input'
)
)
.
clearText
(
)
;
// Scroll
await
element
(
by
.
id
(
'scrollView'
)
)
.
scroll
(
200
,
'down'
)
;
await
element
(
by
.
id
(
'scrollView'
)
)
.
scroll
(
200
,
'up'
)
;
await
element
(
by
.
id
(
'scrollView'
)
)
.
scrollTo
(
'bottom'
)
;
await
element
(
by
.
id
(
'scrollView'
)
)
.
scrollTo
(
'top'
)
;
// Scroll until visible
await
waitFor
(
element
(
by
.
id
(
'item'
)
)
)
.
toBeVisible
(
)
.
whileElement
(
by
.
id
(
'scrollView'
)
)
.
scroll
(
200
,
'down'
)
;
// Swipe
await
element
(
by
.
id
(
'card'
)
)
.
swipe
(
'left'
)
;
await
element
(
by
.
id
(
'card'
)
)
.
swipe
(
'right'
,
'fast'
,
0.9
)
;
// Pinch
await
element
(
by
.
id
(
'map'
)
)
.
pinch
(
1.5
)
;
// zoom in
await
element
(
by
.
id
(
'map'
)
)
.
pinch
(
0.5
)
;
// zoom out
Expectations
// Visibility
await
expect
(
element
(
by
.
id
(
'view'
)
)
)
.
toBeVisible
(
)
;
await
expect
(
element
(
by
.
id
(
'view'
)
)
)
.
not
.
toBeVisible
(
)
;
await
expect
(
element
(
by
.
id
(
'view'
)
)
)
.
toExist
(
)
;
await
expect
(
element
(
by
.
id
(
'view'
)
)
)
.
not
.
toExist
(
)
;
// Focus
await
expect
(
element
(
by
.
id
(
'input'
)
)
)
.
toBeFocused
(
)
;
// Text
await
expect
(
element
(
by
.
id
(
'label'
)
)
)
.
toHaveText
(
'Hello'
)
;
await
expect
(
element
(
by
.
id
(
'input'
)
)
)
.
toHaveValue
(
'input value'
)
;
// Toggle state
await
expect
(
element
(
by
.
id
(
'switch'
)
)
)
.
toHaveToggleValue
(
true
)
;
// Slider
await
expect
(
element
(
by
.
id
(
'slider'
)
)
)
.
toHaveSliderPosition
(
0.5
)
;
// ID
await
expect
(
element
(
by
.
id
(
'view'
)
)
)
.
toHaveId
(
'view'
)
;
// Label
await
expect
(
element
(
by
.
id
(
'button'
)
)
)
.
toHaveLabel
(
'Submit'
)
;
waitFor API
// Wait for element to be visible
await
waitFor
(
element
(
by
.
id
(
'loading'
)
)
)
.
not
.
toBeVisible
(
)
.
withTimeout
(
10000
)
;
// Wait for element to exist
await
waitFor
(
element
(
by
.
id
(
'data'
)
)
)
.
toExist
(
)
.
withTimeout
(
5000
)
;
// Wait while scrolling
await
waitFor
(
element
(
by
.
id
(
'item-50'
)
)
)
.
toBeVisible
(
)
.
whileElement
(
by
.
id
(
'list'
)
)
.
scroll
(
100
,
'down'
)
;
// Custom polling
await
waitFor
(
element
(
by
.
id
(
'result'
)
)
)
.
toHaveText
(
'Success'
)
.
withTimeout
(
30000
)
;
Page Object Pattern
// e2e/pages/LoginPage.js
class
LoginPage
{
get
emailInput
(
)
{
return
element
(
by
.
id
(
'email-input'
)
)
;
}
get
passwordInput
(
)
{
return
element
(
by
.
id
(
'password-input'
)
)
;
}
get
loginButton
(
)
{
return
element
(
by
.
id
(
'login-button'
)
)
;
}
get
errorMessage
(
)
{
return
element
(
by
.
id
(
'error-message'
)
)
;
}
async
login
(
email
,
password
)
{
await
this
.
emailInput
.
typeText
(
email
)
;
await
this
.
passwordInput
.
typeText
(
password
)
;
await
this
.
loginButton
.
tap
(
)
;
}
async
assertErrorVisible
(
message
)
{
await
expect
(
this
.
errorMessage
)
.
toBeVisible
(
)
;
if
(
message
)
{
await
expect
(
element
(
by
.
text
(
message
)
)
)
.
toBeVisible
(
)
;
}
}
}
module
.
exports
=
new
LoginPage
(
)
;
// e2e/tests/login.test.js
const
LoginPage
=
require
(
'../pages/LoginPage'
)
;
const
HomePage
=
require
(
'../pages/HomePage'
)
;
describe
(
'Login'
,
(
)
=>
{
it
(
'should login successfully'
,
async
(
)
=>
{
await
LoginPage
.
login
(
'user@test.com'
,
'password123'
)
;
await
expect
(
HomePage
.
welcomeMessage
)
.
toBeVisible
(
)
;
}
)
;
}
)
;
Debugging
Verbose Logging
// In test
await
device
.
launchApp
(
{
launchArgs
:
{
detoxPrintBusyIdleResources
:
'YES'
}
}
)
;
Screenshots
// Take screenshot
await
device
.
takeScreenshot
(
'login-screen'
)
;
// On failure (in jest setup)
afterEach
(
async
(
)
=>
{
if
(
jasmine
.
currentTest
.
failedExpectations
.
length
0
)
{
await
device
.
takeScreenshot
(
failed-
${
jasmine
.
currentTest
.
fullName
}
)
;
}
}
)
;
Element Debugging
// Get element attributes
const
attributes
=
await
element
(
by
.
id
(
'button'
)
)
.
getAttributes
(
)
;
console
.
log
(
attributes
)
;
// { text: 'Submit', visible: true, enabled: true, ... }
Handling Common Issues
Disable Synchronization
// For non-React Native screens (WebViews, etc.)
await
device
.
disableSynchronization
(
)
;
await
element
(
by
.
id
(
'webview-button'
)
)
.
tap
(
)
;
await
device
.
enableSynchronization
(
)
;
Permission Dialogs
// iOS
await
device
.
launchApp
(
{
permissions
:
{
notifications
:
'YES'
,
camera
:
'YES'
,
photos
:
'YES'
,
location
:
'always'
}
}
)
;
// Android - handle at runtime
await
element
(
by
.
text
(
'Allow'
)
)
.
tap
(
)
;
Keyboard Issues
// Dismiss keyboard
await
element
(
by
.
id
(
'input'
)
)
.
typeText
(
'text\n'
)
;
// or
await
device
.
pressBack
(
)
;
// Android
// Avoid keyboard overlap
await
element
(
by
.
id
(
'input'
)
)
.
tap
(
)
;
await
element
(
by
.
id
(
'input'
)
)
.
typeText
(
'text'
)
;
await
element
(
by
.
id
(
'submit'
)
)
.
tap
(
)
;
CI/CD Integration
GitHub Actions
name
:
E2E Tests
on
:
push
:
branches
:
[
main
]
pull_request
:
branches
:
[
main
]
jobs
:
ios-e2e
:
runs-on
:
macos
-
latest
steps
:
-
uses
:
actions/checkout@v4
-
name
:
Setup Node
uses
:
actions/setup
-
node@v4
with
:
node-version
:
'18'
cache
:
'npm'
-
name
:
Install dependencies
run
:
npm ci
-
name
:
Install pods
run
:
cd ios
&&
pod install
-
name
:
Build app
run
:
npx detox build
-
-
configuration ios.sim.release
-
name
:
Run tests
run
:
npx detox test
-
-
configuration ios.sim.release
-
-
cleanup
-
name
:
Upload artifacts
if
:
failure()
uses
:
actions/upload
-
artifact@v3
with
:
name
:
detox
-
artifacts
path
:
artifacts/
android-e2e
:
runs-on
:
ubuntu
-
latest
steps
:
-
uses
:
actions/checkout@v4
-
name
:
Setup Node
uses
:
actions/setup
-
node@v4
with
:
node-version
:
'18'
-
name
:
Setup Java
uses
:
actions/setup
-
java@v3
with
:
distribution
:
'zulu'
java-version
:
'11'
-
name
:
Install dependencies
run
:
npm ci
-
name
:
Build app
run
:
npx detox build
-
-
configuration android.emu.release
-
name
:
Start emulator
uses
:
reactivecircus/android
-
emulator
-
runner@v2
with
:
api-level
:
30
target
:
google_apis
script
:
npx detox test
-
-
configuration android.emu.release
-
-
cleanup
Performance Tips
// Use reloadReactNative instead of launchApp
beforeEach
(
async
(
)
=>
{
await
device
.
reloadReactNative
(
)
;
// Fast
// await device.launchApp({ newInstance: true }); // Slow
}
)
;
// Record videos only on failure
// In detoxrc.json
{
"artifacts"
:
{
"plugins"
:
{
"video"
:
{
"enabled"
:
true
,
"keepOnlyFailedTestsArtifacts"
:
true
}
}
}
}
// Test sharding for parallel execution
// jest.config.js
module
.
exports
=
{
maxWorkers
:
process
.
env
.
CI
?
2
:
1
,
// ...
}
;
Лучшие практики
Stable selectors
— используйте testID, не text
Proper waits
— waitFor вместо sleep
Page Objects
— переиспользуемые абстракции
Isolated tests
— каждый тест независим
CI/CD first
— тесты должны работать в CI
Record on failure
— видео/скриншоты при падении