Espresso (как обсуждается в Mobile Testing in 2025: iOS, Android and Beyond) (как обсуждается в Appium 2.0: New Architecture and Cloud Integration for Modern Mobile Testing) (как обсуждается в Detox: Grey-Box Testing for React Native Applications) и XCUITest представляют официальные подходы Google и Apple к нативному мобильному тестированию, обеспечивая глубокую интеграцию со своими платформами. Эти фреймворки предлагают превосходную производительность, надежность и доступ к специфичным для платформы функциям, которые кроссплатформенные инструменты не могут обеспечить.
Espresso: Нативный Фреймворк Тестирования для Android
Архитектура и Основные Концепции
Espresso работает напрямую на слое инструментации Android, обеспечивая автоматическую синхронизацию с UI потоком:
// build.gradle (модуль app)
dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:runner:1.5.2'
}
android {
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
Структура Тестов Espresso
Базовая анатомия теста используя ViewMatchers, ViewActions и ViewAssertions:
@RunWith(AndroidJUnit4::class)
@LargeTest
class LoginActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Test
fun validLogin_navigatesToDashboard() {
// ViewMatchers - найти UI элементы
onView(withId(R.id.email_input))
.perform(typeText("user@example.com"), closeSoftKeyboard())
onView(withId(R.id.password_input))
.perform(typeText("password123"), closeSoftKeyboard())
// ViewActions - взаимодействовать с элементами
onView(withId(R.id.login_button))
.perform(click())
// ViewAssertions - проверить результаты
onView(withId(R.id.dashboard_layout))
.check(matches(isDisplayed()))
onView(withText("Добро пожаловать, Пользователь"))
.check(matches(isDisplayed()))
}
@Test
fun invalidEmail_showsValidationError() {
onView(withId(R.id.email_input))
.perform(typeText("неверный-email"), closeSoftKeyboard())
onView(withId(R.id.login_button))
.perform(click())
onView(withText("Неверный формат email"))
.check(matches(isDisplayed()))
}
}
Продвинутые ViewMatchers
Комбинирование матчеров для точного выбора элементов:
class AdvancedMatchersTest {
@Test
fun complexViewMatching() {
// Комбинирование матчеров с allOf
onView(allOf(
withId(R.id.submit_button),
withText("Отправить"),
isEnabled()
)).perform(click())
// Отрицание с not()
onView(allOf(
withId(R.id.username_input),
not(withText(""))
)).check(matches(isDisplayed()))
// Совпадение потомка
onView(allOf(
withId(R.id.error_text),
withParent(withId(R.id.email_container))
)).check(matches(withText("Обязательное поле")))
// Совпадение наследника
onView(allOf(
withText("Заголовок Элемента"),
isDescendantOfA(withId(R.id.recycler_view))
)).perform(click())
}
}
Тестирование RecyclerView
Тестирование сложных взаимодействий со списком:
class RecyclerViewTest {
@Test
fun scrollToItemAndClick() {
// Прокрутка к позиции
onView(withId(R.id.products_recycler))
.perform(scrollToPosition<RecyclerView.ViewHolder>(50))
// Прокрутка к элементу, соответствующему критерию
onView(withId(R.id.products_recycler))
.perform(scrollTo<RecyclerView.ViewHolder>(
hasDescendant(withText("Продукт 50"))
))
// Клик по конкретному элементу
onView(withId(R.id.products_recycler))
.perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(
50, click()
))
// Кастомный ViewAction на совпадающем элементе
onView(withId(R.id.products_recycler))
.perform(actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText("Продукт 50")),
click()
))
}
@Test
fun verifyRecyclerViewContent() {
// Подсчет элементов
onView(withId(R.id.products_recycler))
.check(matches(hasChildCount(100)))
// Проверка элемента на позиции
onView(withId(R.id.products_recycler))
.perform(scrollToPosition<RecyclerView.ViewHolder>(25))
.check(matches(atPosition(25, hasDescendant(withText("Продукт 25")))))
}
}
// Кастомный матчер для RecyclerView
fun atPosition(position: Int, itemMatcher: Matcher<View>): Matcher<View> {
return object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
override fun describeTo(description: Description) {
description.appendText("имеет элемент на позиции $position: ")
itemMatcher.describeTo(description)
}
override fun matchesSafely(view: RecyclerView): Boolean {
val viewHolder = view.findViewHolderForAdapterPosition(position)
?: return false
return itemMatcher.matches(viewHolder.itemView)
}
}
}
Тестирование Intent с Espresso-Intents
Валидация и мокирование intents:
@RunWith(AndroidJUnit4::class)
class IntentsTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@get:Rule
val intentsRule = IntentsTestRule(MainActivity::class.java)
@Test
fun shareButton_sendsShareIntent() {
// Настроить проверку intent
intending(hasAction(Intent.ACTION_SEND))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
// Запустить sharing
onView(withId(R.id.share_button)).perform(click())
// Проверить intent был отправлен
intended(allOf(
hasAction(Intent.ACTION_SEND),
hasType("text/plain"),
hasExtra(Intent.EXTRA_TEXT, "Посмотри это приложение!")
))
}
@Test
fun mockCameraIntent() {
// Создать mock результат
val resultData = Intent().apply {
putExtra("imagePath", "/mock/path/image.jpg")
}
// Замокировать intent камеры
intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, resultData))
onView(withId(R.id.camera_button)).perform(click())
// Проверить UI обновлен с mock данными
onView(withId(R.id.image_preview))
.check(matches(isDisplayed()))
}
}
Idling Resources
Обработка асинхронных операций:
class NetworkIdlingResource : IdlingResource {
private var callback: IdlingResource.ResourceCallback? = null
@Volatile
private var requestCount = 0
fun incrementRequests() {
requestCount++
}
fun decrementRequests() {
requestCount--
if (requestCount == 0) {
callback?.onTransitionToIdle()
}
}
override fun getName(): String = "NetworkIdlingResource"
override fun isIdleNow(): Boolean = requestCount == 0
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
this.callback = callback
}
}
// Использование в тестах
class NetworkTest {
private lateinit var idlingResource: NetworkIdlingResource
@Before
fun setup() {
idlingResource = NetworkIdlingResource()
IdlingRegistry.getInstance().register(idlingResource)
}
@After
fun teardown() {
IdlingRegistry.getInstance().unregister(idlingResource)
}
@Test
fun loadData_displaysResults() {
onView(withId(R.id.load_button)).perform(click())
// Espresso ждет idling resource
onView(withId(R.id.results_list))
.check(matches(isDisplayed()))
}
}
XCUITest: Нативный Фреймворк Тестирования для iOS
Настройка и Структура XCTest
XCUITest интегрируется напрямую с Xcode и iOS симулятором:
import XCTest
class LoginTests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["UI-TESTING"]
app.launch()
}
override func tearDownWithError() throws {
app = nil
}
func testValidLogin() throws {
// Запросы элементов
let emailField = app.textFields["emailInput"]
let passwordField = app.secureTextFields["passwordInput"]
let loginButton = app.buttons["loginButton"]
// Взаимодействия
emailField.tap()
emailField.typeText("user@example.com")
passwordField.tap()
passwordField.typeText("password123")
loginButton.tap()
// Утверждения
let dashboardLabel = app.staticTexts["Добро пожаловать"]
XCTAssertTrue(dashboardLabel.waitForExistence(timeout: 5))
XCTAssertTrue(dashboardLabel.isHittable)
}
func testInvalidEmailValidation() throws {
let emailField = app.textFields["emailInput"]
let loginButton = app.buttons["loginButton"]
emailField.tap()
emailField.typeText("неверный-email")
loginButton.tap()
let errorLabel = app.staticTexts["Неверный формат email"]
XCTAssertTrue(errorLabel.exists)
}
}
Продвинутые Запросы Элементов
XCUITest предоставляет мощные механизмы запросов:
class AdvancedQueriesTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
app = XCUIApplication()
app.launch()
}
func testComplexQueries() {
// Запрос по типу и предикату
let buttons = app.buttons.matching(
NSPredicate(format: "label CONTAINS[c] 'отправить'")
)
XCTAssertEqual(buttons.count, 1)
buttons.firstMatch.tap()
// Запросы потомков
let tableView = app.tables["productList"]
let cell = tableView.cells.element(boundBy: 5)
let cellButton = cell.buttons["addToCart"]
cellButton.tap()
// Комбинирование запросов
let enabledButtons = app.buttons.matching(
NSPredicate(format: "isEnabled == true")
)
// Доступ по индексу
let thirdButton = app.buttons.element(boundBy: 2)
thirdButton.tap()
// Уникальный идентификатор
let specificButton = app.buttons["uniqueIdentifier"]
XCTAssertTrue(specificButton.exists)
}
func testTableViewInteractions() {
let table = app.tables["productList"]
// Прокрутка к элементу
let cell50 = table.cells.element(boundBy: 50)
while !cell50.isHittable {
app.swipeUp()
}
// Альтернатива: использовать scrollToElement (кастомное расширение)
table.scrollToElement(element: cell50)
cell50.tap()
// Проверить содержимое ячейки
let cellTitle = cell50.staticTexts["Продукт 50"]
XCTAssertTrue(cellTitle.exists)
}
}
// Кастомное расширение для прокрутки
extension XCUIElement {
func scrollToElement(element: XCUIElement) {
while !element.isHittable {
swipeUp()
}
}
}
Взаимодействия с Жестами
Комплексная поддержка жестов:
class GestureTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
app = XCUIApplication()
app.launch()
}
func testVariousGestures() {
let imageView = app.images["photoImage"]
// Жесты tap
imageView.tap()
imageView.doubleTap()
imageView.twoFingerTap()
// Жесты press
imageView.press(forDuration: 2.0)
// Жесты swipe
imageView.swipeLeft()
imageView.swipeRight()
imageView.swipeUp()
imageView.swipeDown()
// Жесты pinch
imageView.pinch(withScale: 2.0, velocity: 1.0)
imageView.pinch(withScale: 0.5, velocity: -1.0)
// Жест поворота
imageView.rotate(CGFloat.pi/2, withVelocity: 1.0)
}
func testCoordinateBasedInteractions() {
let view = app.otherElements["customView"]
// Tap в конкретной координате
let coordinate = view.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
coordinate.tap()
// Перетащить от точки к точке
let startCoordinate = view.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
let endCoordinate = view.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
startCoordinate.press(forDuration: 0.1, thenDragTo: endCoordinate)
}
}
Обработка Оповещений и Системных Диалогов
class AlertTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
app = XCUIApplication()
app.launch()
}
func testLocationPermissionAlert() {
// Запустить разрешение местоположения
app.buttons["enableLocationButton"].tap()
// Обработать системное оповещение
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let allowButton = springboard.buttons["Разрешить При Использовании"]
if allowButton.waitForExistence(timeout: 5) {
allowButton.tap()
}
// Проверить разрешение предоставлено
XCTAssertTrue(app.staticTexts["Местоположение Включено"].exists)
}
func testNotificationPermission() {
app.buttons["enableNotificationsButton"].tap()
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let allowButton = springboard.buttons["Разрешить"]
if allowButton.waitForExistence(timeout: 5) {
allowButton.tap()
}
}
func testAppAlert() {
app.buttons["showAlertButton"].tap()
let alert = app.alerts["Подтверждение"]
XCTAssertTrue(alert.exists)
let confirmButton = alert.buttons["Подтвердить"]
confirmButton.tap()
XCTAssertFalse(alert.exists)
}
}
Скриншоты и Запись Активности
class ScreenshotTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
app = XCUIApplication()
app.launch()
}
func testLoginFlowWithScreenshots() {
// Сделать скриншот в начале
let screenshot1 = app.screenshot()
let attachment1 = XCTAttachment(screenshot: screenshot1)
attachment1.name = "Экран Входа"
attachment1.lifetime = .keepAlways
add(attachment1)
// Выполнить вход
app.textFields["emailInput"].tap()
app.textFields["emailInput"].typeText("user@example.com")
let screenshot2 = app.screenshot()
let attachment2 = XCTAttachment(screenshot: screenshot2)
attachment2.name = "Email Введен"
add(attachment2)
app.secureTextFields["passwordInput"].tap()
app.secureTextFields["passwordInput"].typeText("password123")
app.buttons["loginButton"].tap()
// Ждать dashboard
XCTAssertTrue(app.staticTexts["Добро пожаловать"].waitForExistence(timeout: 5))
let screenshot3 = app.screenshot()
let attachment3 = XCTAttachment(screenshot: screenshot3)
attachment3.name = "Dashboard Загружен"
add(attachment3)
}
}
Сравнение Платформ
Матрица Сравнения Функций
Функция | Espresso | XCUITest |
---|---|---|
Синхронизация | Автоматическая (UI поток) | Требуются ручные ожидания |
Скорость | Очень быстрая (in-process) | Умеренная (out-of-process) |
Нестабильность | Низкая | Средняя |
Кривая обучения | Умеренная | Умеренная |
Интеграция IDE | Android Studio | Только Xcode |
Поддержка CI/CD | Отличная | Отличная |
Мокирование сети | Через OkHttp Interceptor | Через URLProtocol |
Тестирование доступности | espresso-accessibility | Встроенная |
Поддержка скриншотов | Ручная реализация | Нативный XCTAttachment |
Скорость Выполнения Тестов
// Espresso - запускается в том же процессе что и приложение
@Test
fun measureEspressoSpeed() {
val startTime = System.currentTimeMillis()
repeat(100) {
onView(withId(R.id.button)).perform(click())
onView(withId(R.id.text)).check(matches(isDisplayed()))
}
val duration = System.currentTimeMillis() - startTime
println("Espresso 100 итераций: ${duration}ms") // ~2-3 секунды
}
// XCUITest - запускается в отдельном процессе
func testXCUITestSpeed() {
let startTime = Date()
for _ in 0..<100 {
app.buttons["button"].tap()
XCTAssertTrue(app.staticTexts["text"].exists)
}
let duration = Date().timeIntervalSince(startTime)
print("XCUITest 100 итераций: \(duration)s") // ~10-15 секунд
}
Интеграция CI/CD
Espresso с GitHub Actions
name: Android Espresso Тесты
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
espresso-tests:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Настроить JDK 11
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '11'
- name: Дать разрешение на выполнение gradlew
run: chmod +x gradlew
- name: Запустить тесты Espresso
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
profile: Nexus 6
script: ./gradlew connectedAndroidTest
- name: Загрузить результаты тестов
if: always()
uses: actions/upload-artifact@v3
with:
name: espresso-test-results
path: app/build/reports/androidTests/
XCUITest с Fastlane
# Fastfile
default_platform(:ios)
platform :ios do
desc "Запустить XCUITests"
lane :ui_tests do
scan(
scheme: "MyApp",
devices: ["iPhone 14 Pro"],
clean: true,
code_coverage: true,
output_directory: "./test_output",
output_types: "html,junit",
fail_build: false
)
end
desc "Запустить XCUITests на нескольких устройствах"
lane :ui_tests_multi_device do
scan(
scheme: "MyApp",
devices: [
"iPhone 14 Pro",
"iPhone SE (3rd generation)",
"iPad Pro (12.9-inch) (6th generation)"
],
clean: true
)
end
end
XCUITest GitHub Actions
name: iOS XCUITest
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
xcuitest:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Выбрать версию Xcode
run: sudo xcode-select -s /Applications/Xcode_14.3.app
- name: Установить зависимости
run: |
gem install bundler
bundle install
- name: Запустить XCUITests
run: |
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 14 Pro,OS=16.4' \
-resultBundlePath TestResults.xcresult \
-enableCodeCoverage YES
- name: Загрузить результаты тестов
if: always()
uses: actions/upload-artifact@v3
with:
name: xcuitest-results
path: TestResults.xcresult
Лучшие Практики
Page Object Model в Espresso
class LoginPage {
private val emailInput = onView(withId(R.id.email_input))
private val passwordInput = onView(withId(R.id.password_input))
private val loginButton = onView(withId(R.id.login_button))
private val errorMessage = onView(withId(R.id.error_message))
fun enterEmail(email: String): LoginPage {
emailInput.perform(typeText(email), closeSoftKeyboard())
return this
}
fun enterPassword(password: String): LoginPage {
passwordInput.perform(typeText(password), closeSoftKeyboard())
return this
}
fun clickLogin(): DashboardPage {
loginButton.perform(click())
return DashboardPage()
}
fun verifyErrorMessage(message: String) {
errorMessage.check(matches(withText(message)))
}
}
// Использование
@Test
fun testLoginFlow() {
LoginPage()
.enterEmail("user@example.com")
.enterPassword("password123")
.clickLogin()
.verifyDashboardDisplayed()
}
Page Object Model в XCUITest
class LoginPage {
private let app: XCUIApplication
private var emailField: XCUIElement {
app.textFields["emailInput"]
}
private var passwordField: XCUIElement {
app.secureTextFields["passwordInput"]
}
private var loginButton: XCUIElement {
app.buttons["loginButton"]
}
init(app: XCUIApplication) {
self.app = app
}
@discardableResult
func enterEmail(_ email: String) -> Self {
emailField.tap()
emailField.typeText(email)
return self
}
@discardableResult
func enterPassword(_ password: String) -> Self {
passwordField.tap()
passwordField.typeText(password)
return self
}
func tapLogin() -> DashboardPage {
loginButton.tap()
return DashboardPage(app: app)
}
}
// Использование
func testLoginFlow() {
LoginPage(app: app)
.enterEmail("user@example.com")
.enterPassword("password123")
.tapLogin()
.verifyDashboardDisplayed()
}
Заключение
Espresso и XCUITest обеспечивают непревзойденную производительность и надежность для платформо-специфичного мобильного тестирования. Модель выполнения in-process у Espresso обеспечивает исключительную скорость для тестирования Android, в то время как интеграция XCUITest с Xcode предоставляет комплексные инструменты для разработки iOS.
Для команд, приверженных нативной мобильной разработке, освоение этих фреймворков позволяет создавать надежные, быстрые и поддерживаемые тестовые сьюты, которые используют уникальные возможности каждой платформы. Инвестиции в платформо-специфичную экспертизу окупаются надежностью тестов и скоростью выполнения по сравнению с кроссплатформенными альтернативами.