От ручного тестирования к автоматизированному: пишем compose UI-тест под Android


Автоматизация тестирования — это один из ключевых инструментов для улучшения качества продукта. Она не только упрощает рутинные процессы и экономит время, но и позволяет проверять приложение на десятках и сотнях устройств за считанные минуты. Однако настроить ее бывает не так просто. Разные версии Android, кастомные оболочки вендоров, особенности экранов — все это часто становится неприятным сюрпризом после нажатия кнопки Deploy.
Меня зовут Максим, я QA-специалист команды развития мобильной фермы Selectel. В этой статье я проведу вас от уже привычного многим ручного тестирования до полноценной автоматизации с доступом к коллекции из ~400 разных устройств и интеграцией в CI/CD. В качестве примера приведу простую инструкцию по созданию Compose UI-теста под Android-устройство для проверки логики и верстки в вашем приложении.
Этап 1. Создание первого теста и запуск на устройстве
Начнем с написания первого автотеста и ручного запуска на одном из девайсов фермы. А дальше пройдем весь путь — от одиночных мануальных запусков до полностью автоматизированных прогонов на десятках устройств.
Схематично этот процесс можно представить так:

Шаг 1. Добавляем ADB-ключ
Для начала добавьте свой ADB-ключ в панели управления Selectel. Он поможет вам подключаться к девайсу и запускать тесты. Для этого перейдите в раздел Профиль → Доступ → ADB-ключи. Подробнее о том, как подключиться к устройству мобильной фермы по ADB, рассказываем в документации.

Шаг 2. Пишем тестовое приложение
Теперь приступим к написанию теста. Для этого будем использовать Android Studio Quail | 2026.1.1.
Создаем новый пустой проект (Empty Activity) с названием MobileFarmComposeUITest.


В файле /app/src/main/java/com/example/mobilefarmcomposeuitest/MainActivity.kt прописываем следующий код.
Это пример простого приложения с текстом «Привет, мир!», который по нажатию кнопки меняется на «Кнопка нажата!».
package com.example.mobilefarmcomposeuitest
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.mobilefarmcomposeuitest.ui.theme.MobileFarmComposeUITestTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MobileFarmComposeUITestTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
MyScreen(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
@Composable
fun MyScreen(modifier: Modifier = Modifier) {
val textState = remember { mutableStateOf("Привет, мир!") }
Column(modifier = modifier.padding(16.dp)) {
Text(
text = textState.value,
modifier = Modifier.padding(bottom = 16.dp)
)
Button(
onClick = { textState.value = "Кнопка нажата!" }
) {
Text("Нажми меня")
}
}
}Шаг 3. Пишем автотест
Теперь сделаем базовый автотест, который будет открывать наше приложение, анализировать текст в шапке, нажимать на кнопку и проверять, что текст изменился.
Для этого создаем файл /app/src/androidTest/java/com/example/mobilefarmcomposeuitest/MyFirstAutotest.kt со следующим содержимым.
package com.example.mobilefarmcomposeuitest
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.example.mobilefarmcomposeuitest.ui.theme.MobileFarmComposeUITestTheme
import org.junit.Rule
import org.junit.Test
class MyFirstAutotest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun greeting_displaysHelloAndroid() {
composeTestRule.setContent {
MobileFarmComposeUITestTheme {
MyScreen()
}
}
// Проверяем корректность текста до нажатия кнопки
composeTestRule.onNodeWithText("Привет, мир!")
.assertIsDisplayed()
// Нажимаем кнопку с указанным текстом на ней
composeTestRule.onNodeWithText("Нажми меня")
.performClick()
// Проверяем, что текст изменился
composeTestRule.onNodeWithText("Кнопка нажата!")
.assertIsDisplayed()
}
}Шаг 4. Подключаемся к устройству фермы
Далее возьмем под контроль один из девайсов фермы. Для этого переходим в панель управления и нажимаем на Продукты → Мобильная ферма → Добавить устройство.
Для примера я выбрал TECNO-KJ6 — типичного представителя средней ценовой категории. Впрочем, для демонстрации модель не принципиальна: описанный ниже подход будет работать с любым Android-устройством, доступным в рамках фермы.

Нажимаем на кнопку Начать использование.
Подключаемся к устройству по ADB, используя строку формата adb connect, которая отображается в интерфейсе фермы под названием устройства.
adb.mobfarm.selectel.ru:<port>Проверяем, что устройство подключилось. Пишем в консоли:
adb devicesВ случае успеха мы увидим ответ вроде:
List of devices attached
adb.mobfarm.selectel.ru:port deviceШаг 5. Запускаем тест
Возвращаемся в Android Studio. В шапке должен появиться новый доступный девайс.

Теперь запускаем наш тест: либо кнопкой Play, либо командой ./gradlew connectedAndroidTest. Она запустит тест сразу на всех подключенных устройствах.
Смотрим результат:

Чудесно! Первые шаги сделаны. Но что если мы не хотим вручную подключаться к каждому устройству? Как узнать порт каждого из десятка девайсов в проекте и запускать тесты одним кликом?
Для этого предлагаю воспользоваться открытым API мобильной фермы.
Мобильная ферма совместима с большинством популярных инструментов для автоматизации обычных автотестов (Appium, UIAutomator, Espresso, Compose UI), а также скриншот-тестов (Paparazzi, Shot). А главное — позволяет работать с ними, даже если вы новичок в этом деле. Один раз написанный тест запустится без изменений на любом из наших устройств, достаточно лишь указать его адрес.

Мобильная ферма Selectel
Начните тестировать на реальных устройствах за 2 минуты — откуда угодно.
Этап 2. Автоматизация подключения к устройствам с помощью API
Предлагаю упростить всю рутину, которую мы выполняли руками на предыдущем этапе. Сделаем запуск тестов полностью автоматическим.

Шаг 0. Создаем сервисного пользователя в панели управления
Сервисный пользователь нужен для авторизации API-запросов. Создать его достаточно один раз. Сделать это можно в панели управления в разделе IAM → Сервисные пользователи.
Для этого нажимаем на кнопку Добавить сервисного пользователя и назначаем ему роль mobile_farm.user или mobile_farm.admin с областью доступа «Аккаунт» или «Проект», если хотите разграничить доступ.
mobile_farm.user имеет минимальный набор прав, необходимый для работы с устройствами. Его будет достаточно для большинства сценариев автоматизации.
mobile_farm.admin помимо управления устройствами может заказывать новые девайсы и удалять их из проекта. Это пригодится, если в будущем планируете динамически управлять парком устройств — например, добавлять свежие модели под новые задачи и убирать устаревшие по завершении тестов.
Подробнее про области доступа читайте в документации Selectel.
После создания сервисного пользователя не забудьте скопировать его имя и пароль. Они понадобятся нам чуть позже.

Шаг 1. Добавляем ADB-ключ
Для нового только что созданного сервисного пользователя не забудьте указать ADB-ключ в разделе IAM → Сервисные пользователи → Пользователь → Доступ.
Помните, что ADB-ключ должен быть уникальным для каждого пользователя в панели. Если вы уже привязывали свой ключ на первом этапе при ручном подключении к устройству, его потребуется либо отвязать от вашего личного пользователя, либо сгенерировать новый уникальный ключ специально для автотестов.
Таким образом мы получим пользователя с доступом к API, который будет брать девайсы под контроль и подключаться к ним по adb-строке.
Шаг 2. Пишем скрипт для работы с API
Сейчас мы реорганизуем доступ к системе по REST API. По мере завершения миграции эндпоинты будут переведены на новую версию — /v3.
Теперь создадим Python-скрипт, который будет:
авторизовываться в API,
получать список всех устройств в проекте,
захватывать их и переводить в использование,
выводить необходимые данные для подключения через ADB.
import argparse
import requests
import json
# Константы
AUTH_URL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens"
BASE_API_URL = "https://api.selectel.ru/mobfarm/api"
ASSIGN_DEVICES_URL = "/v1/user/devices"
REMOTE_CONNECT_URL = "/v1/user/devices/{serial}/remoteConnect"
DEVICES_URL = "/v3/devices"
def get_auth_token(username: str, password: str, domain_name: str, project_name: str) -> str:
"""Получение токена авторизации"""
payload = json.dumps({
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": username,
"domain": {"name": domain_name},
"password": password
}
}
},
"scope": {
"project": {
"name": project_name,
"domain": {"name": domain_name}
}
}
}
})
headers = {'Content-Type': 'application/json'}
response = requests.post(AUTH_URL, headers=headers, data=payload)
# Токен возвращается в заголовке x-subject-token
return response.headers.get('x-subject-token')
def assign_device(auth_token: str, device_serial: str, timeout: int = 60000) -> dict:
"""Перевод девайса в Использование"""
url = f"{BASE_API_URL}{ASSIGN_DEVICES_URL}"
payload = json.dumps({
"serial": device_serial,
"timeout": timeout
})
headers = {
'Content-Type': 'application/json',
'X-Auth-Token': auth_token
}
response = requests.post(url, headers=headers, data=payload)
return response.json()
def start_adb_session(auth_token: str, device_serial: str) -> str:
"""Запуск ADB-сессии и получение строки для подключения"""
url = f"{BASE_API_URL}{REMOTE_CONNECT_URL.format(serial=device_serial)}"
headers = {'X-Auth-Token': auth_token}
response = requests.post(url, headers=headers)
if response.status_code != 200: print(f'Ошибка с устройством {device_serial}! {response.json()}')
return response.json()['remoteConnectUrl']
def get_all_devices(auth_token: str) -> list:
"""Получение списка всех устройств в проекте"""
url = f"{BASE_API_URL}{DEVICES_URL}"
headers = {'X-Auth-Token': auth_token}
response = requests.get(url, headers=headers)
return [device["serial"] for device in response.json() if device["platform"] == "Android"]
def connect_to_all_devices(auth_token: str, device_serials: list, timeout: int) -> list:
"""Подключение всех устройств"""
adb_connections = []
for serial in device_serials:
assign_device(auth_token, serial, timeout)
adb_url = start_adb_session(auth_token, serial)
adb_connections.append(adb_url)
print(f"~ Подключено: {serial} → {adb_url}")
return adb_connections
Шаг 3. Добавляем модуль для освобождения устройств
Нужно заранее позаботиться о том, чтобы после завершения тестов устройства вернулись в общий пул. Добавим функции для их освобождения: сначала завершим ADB-сессию, затем выведем устройство из-под своего контроля через API.
Таким образом освобожденные устройства станут доступны для других автотестов или пользователей проекта. Если пропустить этот шаг, телефон будет занят пользователем вплоть до истечения таймаута, даже после завершения работы теста.
def remove_device_from_user(auth_token: str, device_serial: str):
"""Вывод девайса из использования"""
url = f"{BASE_API_URL}{ASSIGN_DEVICES_URL}/{device_serial}"
headers = {
'Content-Type': 'application/json',
'X-Auth-Token': auth_token
}
requests.request("DELETE", url, headers=headers)
def end_adb_session(auth_token: str, device_serial: str):
"""Завершение ADB-сессии"""
url = f"{BASE_API_URL}{REMOTE_CONNECT_URL.format(serial=device_serial)}"
headers = {'X-Auth-Token': auth_token}
requests.delete(url, headers=headers)
def release_all_devices(auth_token: str, device_serials: list):
"""Вывод всех устройств из использования"""
for serial in device_serials:
end_adb_session(auth_token, serial)
remove_device_from_user(auth_token, serial)
print(f"~ Больше не используется: {serial}")Шаг 4. Добавляем парсер аргументов
Чтобы не запускать скрипт каждый раз с разными параметрами вручную, мы добавим ему интерфейс командной строки (CLI). Это позволит управлять устройствами одной короткой командой, например python3 device_control.py use или python3 device_control.py release.
def main():
parser = argparse.ArgumentParser(description="Управление устройствами мобильной фермы")
parser.add_argument("command", choices=["use", "release"],
help="Команда: use - взять все доступные устройства под контроль, release - освободить все доступные устройства")
args = parser.parse_args()
# Здесь подставьте свои данные или возьмите их из переменных окружения
USERNAME = "username"
PASSWORD = 'password'
DOMAIN_NAME = "domain_name"
PROJECT_NAME = "project_name"
TIMEOUT = 60000 # Время, в течение которого будет происходить захват устройства в ms
# Получаем токен
token = get_auth_token(USERNAME, PASSWORD, DOMAIN_NAME, PROJECT_NAME)
print("Авторизация успешна!")
# Получаем список Android-устройств в проекте
devices = get_all_devices(token)
print(f"Найдено устройств: {len(devices)}")
# Выполняем команды
if args.command == "use":
connections = connect_to_all_devices(token, devices, TIMEOUT)
print(f"Готово к работе: {len(connections)} устройств\n")
elif args.command == "release":
release_all_devices(token, devices)
print(f"Все устройства успешно освобождены!\n")
if __name__ == "__main__":
main()Параметры USERNAME, PASSWORD, DOMAIN_NAMEи PROJECT_NAME можно легко получить из панели управления. USERNAME — это имя нашего сервисного пользователя. Например, MOBILE_FARM_ADMIN из первого шага. DOMAIN_NAME отображается в правом верхнем углу панели как идентификатор аккаунта.
PROJECT_NAME — это название проекта, в котором лежат ваши устройства. Например, «My First Project», который создается по умолчанию при регистрации нового аккаунта.
Шаг 5. Проверяем работу скрипта
Далее запускаем команду захвата устройств аналогично тому, как если бы мы вручную подключались к каждому устройству в проекте и запускали на нем ADB-сервер.
Выполним в терминале:
python3 device_control.py useТеперь каждое устройство, захваченное скриптом, будет отображаться в панели управления как занятое текущим пользователем. Бронь будет до тех пор, пока мы не откажемся от него или не истечет таймаут.
При успешном выполнении мы получим список серийников всех Android-устройств в проекте, а также уникальную строку для adb-подключения к каждому из них.
Авторизация успешна!
Найдено устройств: 4
~ Подключено: a3f8c2b1e4d5467c9e12f3a5b8c6d9e0 → adb.mobfarm.selectel.ru:52341
~ Подключено: d4e9f7a2b3c5416d8f0e1a2b3c4d5e6f → adb.mobfarm.selectel.ru:49178
~ Подключено: b8c7d9e0f1a2465b8c9d0e1f2a3b4c5d → adb.mobfarm.selectel.ru:61234
~ Подключено: e1f2a3b4c5d6478e9f0a1b2c3d4e5f6a → adb.mobfarm.selectel.ru:57892
Готово к работе: 4 устройствТеперь все устройства захвачены и готовы к выполнению тестов.
Шаг 6. Запускаем тесты на всех устройствах
Осталось подключиться к каждому устройству по ADB и запустить gradle-тесты. Это можно сделать двумя способами: вручную и автоматически.
Способ 1. Ручной
В этом случае мы вручную копируем строки подключения, полученные от скрипта на предыдущем шаге, и поочередно вводим их в терминал:
adb connect adb.mobfarm.selectel.ru:12345
adb connect adb.mobfarm.selectel.ru:12346После того как все устройства подключены, выполним команду для запуска тестов.
./gradlew connectedAndroidTestСпособ 2. Автоматический
Добавим в функцию connect_to_all_devices() автоматическое выполнение adb connect.
def connect_to_all_devices(auth_token: str, device_serials: list, timeout: int) -> list:
"""Подключение всех устройств"""
adb_connections = []
for serial in device_serials:
assign_device(auth_token, serial, timeout)
adb_url = start_adb_session(auth_token, serial)
adb_connections.append(adb_url)
#Добавили автоподключение по adb к девайсу
subprocess.run(["adb", "connect", adb_url], capture_output=True)
print(f"~ Подключено: {serial} → {adb_url}")
return adb_connectionsЗапускаем CI/CD
Будут ли все API-вызовы из приведенного выше скрипта выполняться через curl или запускаться в качестве Python-скриптов — не имеет значения. Главное, что мы на практике разобрались, как создать работающий инструмент, который легко можно встроить в любой процесс.
К сожалению, как показывает практика, во многих проектах запуска тестов через CI/CD вовсе нет. Как и инфраструктуры, которая настраивается специально под команду QA. Однако даже если вы пока не используете пайплайны, подобный подход позволит значительно сократить трудозатраты на тестирование, упростить жизнь и подарить уверенность во время релиза новых фичей.
Для тех, кто готов идти дальше, приведу пример .gitlab-ci.yml-файла, который можно интегрировать для автоматического запуска тестов на всех девайсах проекта. Он будет работать сразу после нажатия на кнопку Deploy.
image: python:3.11-slim
stages:
- setup # Подключаем устройства
- test # Запускаем тесты
- cleanup # Освобождаем устройства
# --- ПОДКЛЮЧЕНИЕ УСТРОЙСТВ ---
setup_devices:
stage: setup
before_script:
- pip install -r requirements.txt
# Устанавливаем ADB
- apt-get update && apt-get install -y android-tools-adb android-tools-fastboot
- mkdir -p ~/.android
# Подтягиваем приватный adb ключ
- cp $ADB_KEY_FILE ~/.android/adbkey
- chmod 600 ~/.android/adbkey
# Добавляем публичный adb-ключ
- cp $ADB_PUBLIC_KEY ~/.android/adbkey.pub
- chmod 644 ~/.android/adbkey.pub
script:
# Берем устройства в проекте под контроль
- python3 device_control.py use
after_script:
- adb devices -l
# --- ЗАПУСК ТЕСТОВ ---
run_android_tests:
stage: test
# Добавляем образ с Android SDK для запуска тестов
image: $CI_REGISTRY_IMAGE/android-sdk:36
script:
# Прогоняем Compose-UI тесты
- ./gradlew connectedAndroidTest
needs:
- setup_devices
# --- ОСВОБОЖДЕНИЕ УСТРОЙСТВ ---
release_devices:
stage: cleanup
before_script:
- pip install -r requirements.txt
script:
# Освобождаем устройства скриптом
- python3 device_control.py release
when: always
needs:
- run_android_tests
after_script:
# Очищаем все adb-соединения с телефонами
- adb disconnect Заключение
Мы проделали большой путь: от идеи и ручного запуска тестов до рабочего инструмента, который управляет устройствами и помогает запускать автотесты на любых Android-устройствах.
Будет ли приведенный пример кода запускаться единожды вручную или интегрирован в пайплайны — решать вам. Но я надеюсь, что этот материал сделает ваш процесс тестирования проще и поможет перейти на новую ступень качества продукта.
От себя скажу: не бойтесь пробовать что-то новое. Один написанный скрипт или автотест может легко сэкономить вам десятки, а то и сотни часов рутины. Начинайте с малого — с основного функционала и ключевых проверок, постепенно расширяя покрытие, пока автотестами не будет покрыта большая часть кейсов.
Качество продукта строится шаг за шагом. И проверка своего приложения на капризных вендорах, кишащих всевозможными ошибками — один из таких шагов.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.