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)
    }
}

Сравнение Платформ

Матрица Сравнения Функций

ФункцияEspressoXCUITest
СинхронизацияАвтоматическая (UI поток)Требуются ручные ожидания
СкоростьОчень быстрая (in-process)Умеренная (out-of-process)
НестабильностьНизкаяСредняя
Кривая обученияУмереннаяУмеренная
Интеграция IDEAndroid 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.

Для команд, приверженных нативной мобильной разработке, освоение этих фреймворков позволяет создавать надежные, быстрые и поддерживаемые тестовые сьюты, которые используют уникальные возможности каждой платформы. Инвестиции в платформо-специфичную экспертизу окупаются надежностью тестов и скоростью выполнения по сравнению с кроссплатформенными альтернативами.