Перейти к основному содержимому
Мобилка43ГЛАВА 43Тестирование: MockWebServer, фейки и TDDруками · 3тестом · 14RED → GREEN21% глазами

Тестирование: MockWebServer, фейки и TDD

Прочитано0%
Квизы0/7
Тренажёрыне пройден0/1
Уровень 1 · Новичок

«Я же проверил руками» — почему этого не хватает​

Модуль Е в конкурсном задании сформулирован в двух предложениях:

Создайте тесты состояний в библиотеке UIKit к следующим компонентам: Поле для ввода, Селект, Кнопки, BottomTabBar, Карточка. Создайте тесты на библиотеку сетевого слоя на каждый из запросов, по тест-кейсам.

Весит он 4,7 балла — немного. Но большинство участников теряют на нём не баллы, а время: тесты пишут в последний час, впопыхах, и половина из них не компилируется. А ещё это единственный модуль, где эксперт видит не «работает ли», а «понимает ли участник, что именно должно работать».

Главный аргумент против тестов звучит одинаково у всех: «я запустил, нажал, увидел — работает». Ниже — почему этого недостаточно, на живом примере с валидацией email из этого же задания.

Не один пример, а набор кейсов​

Требование к email из задания: «name@domenname.ru», только строчные латинские буквы и цифры. Три реализации, которые пишут участники — от наивной до правильной. Проверим их так, как проверяют руками: три адреса. А потом — так, как проверяет тест: четырнадцать.

// Три реализации одной и той же проверки email из КЗ: «name@domenname.ru», // только строчные латинские буквы и цифры. fun naive(email: String) = email.contains("@") && email.contains(".") val pattern = Regex("[a-z0-9]+@[a-z0-9]+\\.ru") fun search(email: String) = pattern.containsMatchIn(email) // ищет вхождение fun strict(email: String) = pattern.matches(email) // требует совпадения целиком // Набор тест-кейсов: строка → каким должен быть ответ. val eyeball = listOf( "ivan@mail.ru" to true, "ivan@mail" to false, "ivanmail.ru" to false ) val full = eyeball + listOf( "ivan2024@shop.ru" to true, "a@b.ru" to true, "@mail.ru" to false, "ivan@.ru" to false, "Ivan@mail.ru" to false, "ivan@mail.com" to false, "ivan@mail.ru " to false, " ivan@mail.ru" to false, "Иван <ivan@mail.ru> и ещё текст" to false, "ivan@@mail.ru" to false, "" to false ) // Мини-JUnit: ровно то, что делает настоящий раннер, в семь строк. fun run(name: String, f: (String) -> Boolean, cases: List<Pair<String, Boolean>>): List<String> { val fails = mutableListOf<String>() for ((input, expected) in cases) if (f(input) != expected) fails += input val mark = if (fails.isEmpty()) "ЗЕЛЁНЫЙ" else "КРАСНЫЙ" println(" ${name.padEnd(16)} ${"${cases.size - fails.size}/${cases.size}".padEnd(6)} $mark") return fails } fun main() { println("── три кейса, которые человек проверяет руками ──") run("contains(\"@\")", ::naive, eyeball) run("containsMatchIn", ::search, eyeball) run("matches", ::strict, eyeball) println(" все три реализации неразличимы: руками разницы не видно") println() println("── тот же код против ${full.size} тест-кейсов ──") val f1 = run("contains(\"@\")", ::naive, full) val f2 = run("containsMatchIn", ::search, full) val f3 = run("matches", ::strict, full) println() println("что пропустила наивная проверка (${f1.size}):") f1.forEach { println(" «$it»") } println("что пропустил регекс без якорей (${f2.size}):") f2.forEach { println(" «$it»") } println("строгая проверка: ${f3.size} провалов") println() val eyePct = eyeball.size * 100 / full.size println("руками проверено кейсов: ${eyeball.size} из ${full.size} = $eyePct% покрытия") }

Живой вывод:

── три кейса, которые человек проверяет руками ──
contains("@") 3/3 ЗЕЛЁНЫЙ
containsMatchIn 3/3 ЗЕЛЁНЫЙ
matches 3/3 ЗЕЛЁНЫЙ
все три реализации неразличимы: руками разницы не видно

── тот же код против 14 тест-кейсов ──
contains("@") 6/14 КРАСНЫЙ
containsMatchIn 10/14 КРАСНЫЙ
matches 14/14 ЗЕЛЁНЫЙ

что пропустил регекс без якорей (4):
«Ivan@mail.ru»
«ivan@mail.ru »
« ivan@mail.ru»
«Иван <ivan@mail.ru> и ещё текст»

руками проверено кейсов: 3 из 14 = 21% покрытия
ТРИ РЕАЛИЗАЦИИ ОДНОЙ ПРОВЕРКИ3 кейса, набранных рукамите же реализации против 14 кейсовcontains("@")3/3 · ЗЕЛЁНЫЙ6/14 · КРАСНЫЙcontainsMatchIn3/3 · ЗЕЛЁНЫЙ10/14 · КРАСНЫЙmatches3/3 · ЗЕЛЁНЫЙ14/14 · ЗЕЛЁНЫЙруками все три неразличимы — разницу показал только набор кейсовруками проверено 3 из 14 = 21% покрытия
На трёх кейсах все три реализации зелёные и неразличимы; разницу показал только набор из четырнадцати

Наивная проверка через contains("@") — ошибка, которую все ждут. Неожиданное здесь другое: почти правильный регекс с containsMatchIn пропускает четыре адреса. Он ищет вхождение образца в строку, а не совпадение целиком — поэтому «Иван <ivan@mail.ru> и ещё текст» для него валидный email. Это та же ловушка, что Pattern.matcher().find() вместо .matches() в Java.

Глазом эту разницу не поймать: на трёх адресах, которые приходят в голову, обе реализации зелёные. Разницу видит только таблица «вход → ожидаемый выход» — и ценность её ровно в тех строках, которые руками никто не набирает: пробел по краям, заглавная буква, пустая строка, мусор вокруг правильного значения.

Тест — это и есть такая таблица, которая отрабатывает заново после каждой правки, а не один раз перед сдачей.

Куда кладут тесты и как устроен один тест​

В Android-проекте две папки для тестов, и это две разные вселенные:

ПапкаЧто этоКак запускатьСкорость
src/test/чистая JVM, без Android./gradlew testсекунды
src/androidTest/инструментальные, нужен эмулятор./gradlew connectedAndroidTestминуты

Валидаторы, мапперы, ViewModel, сетевой слой через заглушку — всё это идёт в src/test/. В src/androidTest/ остаётся только то, чему без эмулятора никак: тесты Composable.

Один тест внутри:

class EmailValidatorTest {

@Test
fun `заглавная буква не проходит`() {
// Arrange — подготовка
val input = "Ivan@mail.ru"

// Act — действие
val result = isValidEmail(input)

// Assert — проверка
assertEquals(false, result)
}
}
Нажми на часть выражения
Отвечено вопросов: 0 из 2
Почему регекс через containsMatchIn пропустил «Иван <ivan@mail.ru> и ещё текст»?
Написали тесты в src/androidTest/, запустили ./gradlew test. Результат: BUILD SUCCESSFUL. Тесты прошли?
Отвечено верно: 0 из 2

Два первых юнит-теста: валидатор из задания и маппер​

Самые быстрые баллы — тесты на то, что уже сформулировано в задании. Email по паттерну и пароль не короче восьми символов — это готовые тест-кейсы, их пишут первыми:

class ValidatorsTest {

@Test
fun `корректный email проходит`() = assertTrue(isValidEmail("ivan2024@shop.ru"))

@Test
fun `заглавная буква не проходит`() = assertFalse(isValidEmail("Ivan@mail.ru"))

@Test
fun `пробел по краям не проходит`() = assertFalse(isValidEmail(" ivan@mail.ru"))

@Test
fun `домен не ru не проходит`() = assertFalse(isValidEmail("ivan@mail.com"))

@Test
fun `пароль из 7 символов не проходит`() = assertFalse(isValidPassword("1234567"))

@Test
fun `пароль из 8 символов проходит`() = assertTrue(isValidPassword("12345678"))
}

Второй кандидат — маппер ProductDto → Product из главы про слои. Он тоже чистая функция без Android внутри:

@Test
fun `цена из копеек превращается в рубли`() {
val dto = ProductDto(id = 1, title = "Кофе", priceKopecks = 25000)

val product = dto.toDomain()

assertEquals(250.0, product.price, 0.001)
}

Оба класса — образец для всех остальных: функция принимает данные, возвращает результат, тест сравнивает результат с ожиданием. Ничего не мокается, ничего не поднимается.

Совет

assertEquals для Double принимает третий аргумент — допуск. Без него 0.1 + 0.2 не равно 0.3, и тест упадёт на арифметике с плавающей точкой, а не на вашей логике.

Что мешает тесту: сеть, время, база — и чем их заменяют​

Валидатор тестируется напрямую. А Repository, который ходит в сеть? Если тест по-настоящему сходит на сервер, он будет медленным, будет падать при отсутствии интернета и не сможет проверить ветку «сервер вернул 500», потому что сервер её просто не вернёт по заказу.

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

  • Фейк — упрощённая рабочая реализация. Вместо сети — список в памяти. Обычный Kotlin-класс, никаких библиотек.
  • Стаб — отдаёт заготовленные ответы, логики нет.
  • Мок — записывает вызовы, чтобы потом проверить: «метод вызван один раз с такими аргументами». Обычно через библиотеку.
  • Шпион — настоящий объект, обёрнутый записью вызовов.

Google в документации по тестовым дублям прямо пишет: фейки предпочтительнее — не требуют библиотеки моков и остаются обычными классами. Для чемпионата это ещё и практический довод: библиотеку моков нужно помнить наизусть, а фейк — это class с override.

Фейк вместо мока: подмена дефолтным параметром​

Один и тот же тест репозитория — против «настоящей» сети (задержка 300 мс и 15 % случайных отказов) и против фейка. Двадцать прогонов:

import kotlinx.coroutines.* import kotlin.random.Random // Зависимость описана интерфейсом — этого достаточно, чтобы её подменить. interface ProductApi { suspend fun load(): List<String> } // «Настоящая» реализация: ходит по сети. Медленно и иногда падает. class RealProductApi(private val rnd: Random) : ProductApi { override suspend fun load(): List<String> { delay(300) // сеть отвечает не мгновенно if (rnd.nextInt(100) < 15) throw RuntimeException("timeout") return listOf("Кофе", "Чай", "Какао") } } // Фейк: та же роль, поведение задано вручную. Ещё и считает обращения. class FakeProductApi( private val answer: List<String> = listOf("Кофе", "Чай", "Какао"), private val fail: Boolean = false ) : ProductApi { var calls = 0 private set override suspend fun load(): List<String> { calls++ if (fail) throw RuntimeException("нет соединения") return answer } } // Граф собирается дефолтными параметрами конструктора. // В приложении пишут ProductRepository() — подставится настоящая реализация. // В тесте передают фейк, и больше ничего менять не надо. class ProductRepository(private val api: ProductApi = RealProductApi(Random)) { private var cache: List<String>? = null suspend fun products(): List<String> { cache?.let { return it } return api.load().also { cache = it } } } fun measure(times: Int, block: suspend () -> Unit): Pair<Long, Int> { var failed = 0 val start = System.nanoTime() runBlocking { repeat(times) { try { block() } catch (e: Exception) { failed++ } } } return (System.nanoTime() - start) / 1_000 to failed // микросекунды } fun main() { val runs = 20 println("── один и тот же тест против настоящей реализации ($runs прогонов) ──") val seeded = Random(2026) val (usReal, failReal) = measure(runs) { val list = ProductRepository(RealProductApi(seeded)).products() check(list.size == 3) } println(" время: ${usReal / 1000} мс") println(" упало: $failReal из $runs ← код не менялся, «моргнула» сеть") println() println("── тот же тест против фейка ($runs прогонов) ──") val fake = FakeProductApi() val (usFake, failFake) = measure(runs) { val list = ProductRepository(fake).products() check(list.size == 3) } println(" время: $usFake мкс (${usFake / 1000} мс)") println(" упало: $failFake из $runs") println(" разница по времени: в ${usReal / maxOf(usFake, 1)} раз") println() println("── что фейк умеет, а настоящая реализация нет ──") runBlocking { val counting = FakeProductApi() val repo = ProductRepository(counting) repeat(5) { repo.products() } println(" 5 обращений к репозиторию → вызовов api: ${counting.calls} (работает кэш)") val broken = ProductRepository(FakeProductApi(fail = true)) val text = try { broken.products().toString() } catch (e: Exception) { "ошибка: ${e.message}" } println(" сценарий «нет сети» воспроизведён на заказ → $text") } }

Живой вывод:

── один и тот же тест против настоящей реализации (20 прогонов) ──
время: 6099 мс
упало: 2 из 20 ← код не менялся, «моргнула» сеть

── тот же тест против фейка (20 прогонов) ──
время: 183 мкс (0 мс)
упало: 0 из 20
разница по времени: в 33330 раз

── что фейк умеет, а настоящая реализация нет ──
5 обращений к репозиторию → вызовов api: 1 (работает кэш)
сценарий «нет сети» воспроизведён на заказ → ошибка: нет соединения
ОДИН ТЕСТ, ДВЕ РЕАЛИЗАЦИИ ЗАВИСИМОСТИProductRepositoryapi: ProductApiRealProductApi — настоящая сеть20 прогонов: 6099 мсупало 2 из 20 — код не менялся, моргнула сетьFakeProductApi — обычный класс20 прогонов: 183 мксупало 0 из 20 · умеет «нет сети» на заказподмена — дефолтным параметром конструктора: в приложении Repository(), в тесте Repository(FakeApi())
Одна зависимость, две реализации: настоящая — шесть секунд и два случайных падения, фейк — микросекунды и ноль

Три числа. Шесть секунд против 183 микросекунд — в тридцать три тысячи раз. Два падения из двадцати на коде, который не менялся — вот откуда берутся «мигающие» тесты, которым перестают верить и которые потом отключают. И счётчик вызовов: фейк знает, сколько раз его дёрнули, и тест проверяет, что кэш работает — пять обращений к репозиторию, один поход в «сеть».

Как фейк попадает внутрь без DI-фреймворка — дефолтным параметром конструктора:

class ProductRepository(private val api: ProductApi = RealProductApi())

В приложении пишут ProductRepository() — подставится настоящая реализация. В тесте — ProductRepository(FakeProductApi()). Ничего больше менять не нужно. Это работает, потому что зависимость объявлена интерфейсом, а не конкретным классом: и настоящая реализация, и фейк — просто два ProductApi.

Отвечено вопросов: 0 из 2
Тест репозитория проходит 18 раз из 20, код не менялся. Что это?
Как подставить фейк в репозиторий без DI-фреймворка?
Отвечено верно: 0 из 2

Тест ViewModel: runTest и главный диспетчер​

ViewModel тестируется так же — с фейком репозитория. Но у неё две особенности, из-за которых первый тест почти всегда падает.

Первая: код в ней асинхронный. Тест оборачивают в runTest — он перематывает delay мгновенно и ждёт завершения корутин:

@Test
fun `после загрузки в состоянии список товаров`() = runTest {
val vm = ProductsViewModel(ProductRepository(FakeProductApi()))

vm.load()
advanceUntilIdle()

assertEquals(listOf("Кофе", "Чай", "Какао"), vm.state.value.products)
}

Вторая: viewModelScope живёт на Dispatchers.Main, а в JVM-тесте главного потока нет. Падение выглядит так:

IllegalStateException: Module with the Main dispatcher is missing.
Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android'

Совет в сообщении — ложный след. Зависимость добавлять не нужно; нужно подменить главный диспетчер тестовым:

class MainDispatcherRule : TestWatcher() {
private val dispatcher = StandardTestDispatcher()
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}

class ProductsViewModelTest {
@get:Rule
val mainRule = MainDispatcherRule()
// ...
}

Правило пишется один раз и подключается в каждый тестовый класс ViewModel. Его набирают по памяти — на чемпионате интернета десять минут.

Важно

runBlockingTest, TestCoroutineDispatcher и TestCoroutineScope устарели. В руководствах 2021–2023 годов они везде — не копируйте. Актуально: runTest с StandardTestDispatcher. У runTest таймаут 60 секунд по умолчанию: если тест «висит минуту и падает», скорее всего кто-то ждёт ответа, который не был поставлен в очередь.

MockWebServer: настоящий HTTP, ненастоящий сервер​

Фейк проверяет репозиторий. Но сам сетевой слой — Retrofit, конвертер JSON, интерсептор с токеном — фейком не проверишь: фейк его обходит. Для этого есть MockWebServer из OkHttp: локальный HTTP-сервер, который отвечает тем, что вы положили в очередь.

class ProductApiTest {
private val server = MockWebServer()
private lateinit var api: ProductApi

@Before
fun setUp() {
server.start()
api = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ProductApi::class.java)
}

@After
fun tearDown() = server.shutdown()

@Test
fun `успешный ответ разбирается в список`() = runTest {
server.enqueue(MockResponse().setResponseCode(200).setBody("""[{"id":1,"title":"Кофе"}]"""))

val products = api.getProducts()

assertEquals(1, products.size)
assertEquals("Кофе", products[0].title)
}
}

Запрос идёт по настоящему HTTP на localhost — значит, проверяется весь слой целиком: как Retrofit построил URL, как конвертер разобрал JSON, что интерсептор добавил заголовок.

Интересный факт

MockWebServer живёт в двух артефактах: старый com.squareup.okhttp3:mockwebserver (пакет okhttp3.mockwebserver, MockResponse().setResponseCode(200).setBody(...)) и новый mockwebserver3 (неизменяемый MockResponse(body = ...), тело запроса — ByteString?). API у них разное, и копипаста из руководства 2023 года под новый артефакт не соберётся. В главе — старый, он проще и по-прежнему выпускается.

Проверять не только ответ, но и тело запроса​

Заглушка отвечает заготовкой на что угодно. Отсюда ловушка, из-за которой на чемпионате теряют баллы молча:

// Модель MockWebServer: заготовленные ответы в очереди и журнал принятых запросов. data class Recorded(val method: String, val path: String, val body: String) class MockServer { private val answers = ArrayDeque<String>() private val received = ArrayDeque<Recorded>() fun enqueue(json: String) { answers.addLast(json) } fun handle(method: String, path: String, body: String): String { received.addLast(Recorded(method, path, body)) return answers.removeFirstOrNull() ?: error("очередь ответов пуста") } fun takeRequest(): Recorded = received.removeFirstOrNull() ?: error("запросов не было") } data class User(val id: Int, val name: String) fun field(json: String, key: String): String = json.substringAfter("\"$key\":").substringBefore(",").substringBefore("}").trim().trim('"') // Клиент с ошибкой: сервер ждёт поля email/password, клиент шлёт login/pass. class AuthClient(private val server: MockServer) { fun login(email: String, password: String): User { val body = "{\"login\":\"$email\",\"pass\":\"$password\"}" val json = server.handle("POST", "/api/auth", body) return User(field(json, "id").toInt(), field(json, "name")) } } fun verdict(ok: Boolean) = if (ok) "ЗЕЛЁНЫЙ" else "КРАСНЫЙ" fun main() { val expectedBody = "{\"email\":\"ivan@mail.ru\",\"password\":\"12345678\"}" println("── тест №1: проверяем только разобранный ответ ──") val s1 = MockServer() s1.enqueue("{\"id\":7,\"name\":\"Иван\",\"token\":\"abc\"}") val user = AuthClient(s1).login("ivan@mail.ru", "12345678") println(" клиент разобрал: $user") println(" тест: ${verdict(user.id == 7 && user.name == "Иван")}") println() println("── что при этом ушло на сервер ──") val sent = s1.takeRequest() println(" ${sent.method} ${sent.path}") println(" тело: ${sent.body}") println(" ожидали: $expectedBody") println(" на живом сервере это 400, а тест был зелёный") println() println("── тест №2: тот же тест плюс проверка тела запроса ──") val s2 = MockServer() s2.enqueue("{\"id\":7,\"name\":\"Иван\",\"token\":\"abc\"}") val u2 = AuthClient(s2).login("ivan@mail.ru", "12345678") val req = s2.takeRequest() val ok = u2.id == 7 && req.path == "/api/auth" && req.body == expectedBody println(" тест: ${verdict(ok)}") println() println("── заглушка отвечает на что угодно ──") val s3 = MockServer() s3.enqueue("{\"id\":7,\"name\":\"Иван\"}") println(" ответ на GET /чепуха: ${s3.handle("GET", "/чепуха", "")}") println() println("── очередь ответов: строгий порядок ──") val s4 = MockServer() s4.enqueue("{\"page\":1}") s4.enqueue("{\"page\":2}") println(" первый запрос: ${s4.handle("GET", "/items?page=1", "")}") println(" второй запрос: ${s4.handle("GET", "/items?page=2", "")}") val third = try { s4.handle("GET", "/items?page=3", "") } catch (e: IllegalStateException) { "упало: ${e.message}" } println(" третий запрос: $third") }

Живой вывод:

── тест №1: проверяем только разобранный ответ ──
клиент разобрал: User(id=7, name=Иван)
тест: ЗЕЛЁНЫЙ

── что при этом ушло на сервер ──
POST /api/auth
тело: {"login":"ivan@mail.ru","pass":"12345678"}
ожидали: {"email":"ivan@mail.ru","password":"12345678"}
на живом сервере это 400, а тест был зелёный

── тест №2: тот же тест плюс проверка тела запроса ──
тест: КРАСНЫЙ

── заглушка отвечает на что угодно ──
ответ на GET /чепуха: {"id":7,"name":"Иван"}

── очередь ответов: строгий порядок ──
первый запрос: {"page":1}
второй запрос: {"page":2}
третий запрос: упало: очередь ответов пуста
ЗАГЛУШКА ОТВЕЧАЕТ НА ЧТО УГОДНОклиент{"login":…,"pass":…}MockWebServerочередь ответов: {"id":7,"name":"Иван"}журнал запросов: POST /api/auth + телотест №1: проверил только ответЗЕЛЁНЫЙ — а на живом сервере это 400тест №2: плюс takeRequest() и сверка телаКРАСНЫЙ — ошибка найдена до сдачизаглушка отдаёт заготовку на любой запрос — проверять надо и ответ, и то, что ушлометод, путь, тело, заголовок Authorization
Клиент шлёт login/pass, сервер ждёт email/password — тест зелёный, пока не проверено тело запроса

Клиент отправляет {"login":…,"pass":…}, сервер ждёт {"email":…,"password":…}. Тест №1 зелёный: заглушка отдала заготовленный JSON, клиент его разобрал, ассерт прошёл. На живом сервере это 400 Bad Request — уже после сдачи.

Красным тест становится только после takeRequest():

@Test
fun `логин отправляет email и password`() = runTest {
server.enqueue(MockResponse().setBody("""{"token":"abc"}"""))

api.login(LoginRequest("ivan@mail.ru", "12345678"))

val request = server.takeRequest()
assertEquals("POST", request.method)
assertEquals("/api/auth", request.path)
assertEquals("""{"email":"ivan@mail.ru","password":"12345678"}""", request.body.readUtf8())
}

Проверять надо и ответ, и то, что ушло: метод, путь, тело, заголовок Authorization. Именно заголовок чаще всего ломается: интерсептор не подключили, токен не подставился — и все защищённые запросы на живом сервере дают 401, а тесты зелёные.

Последний блок вывода — про очередь. Ответы отдаются строго по порядку; забыли enqueue() перед третьим запросом — тест не упадёт с внятной ошибкой, а повиснет до таймаута runTest.

Отвечено вопросов: 0 из 2
Тест сетевого слоя проверяет разобранный ответ и зелёный. Клиент шлёт поля login/pass вместо email/password. Что покажет тест?
Тест с MockWebServer висит минуту и падает по таймауту. Что вероятнее всего?
Отвечено верно: 0 из 2

Тест-кейсы на каждый запрос​

Формулировка задания — «на каждый из запросов, по тест-кейсам». Это буквально: пять методов в интерфейсе ApiService — минимум пять тестовых методов. Эксперт считает соответствие.

А «по тест-кейсам» означает, что у одного запроса не одна ветка. Минимальный набор на каждый:

Тест-кейсЧто кладём в очередьЧто проверяем
Успех200 + корректный JSONразобранная модель, тело запроса
Не авторизован401клиент бросает ожидаемое исключение или возвращает ошибку
Сервер упал500то же — приложение не падает
Битый JSON200 + {"id":ошибка разбора обработана, не crash
@Test
fun `401 превращается в ошибку авторизации`() = runTest {
server.enqueue(MockResponse().setResponseCode(401))

val result = repository.loadProducts()

assertTrue(result is Result.Error)
assertEquals(AppError.Unauthorized, (result as Result.Error).error)
}

Это тот же перевод кодов в понятные ошибки из главы про сетевые ошибки — только теперь каждая ветка закреплена тестом, и при рефакторинге сетевого слоя её невозможно сломать незаметно.

Тесты Composable: семантическое дерево​

Тесты интерфейса живут в src/androidTest/ и работают через createComposeRule(). Тест видит не пиксели, а дерево семантики — то же, что читает экранный диктор:

class PrimaryButtonTest {
@get:Rule
val composeRule = createComposeRule()

@Test
fun `отключённая кнопка не реагирует на нажатие`() {
var clicks = 0
composeRule.setContent {
PrimaryButton(text = "Войти", enabled = false, onClick = { clicks++ })
}

composeRule.onNodeWithText("Войти").assertIsNotEnabled()
composeRule.onNodeWithText("Войти").performClick()

assertEquals(0, clicks)
}
}

Инструменты, которые нужны на практике:

  • Найти узел — onNodeWithText, onNodeWithTag, onNodeWithContentDescription. Тег ставится модификатором Modifier.testTag("email_field") и нужен там, где текст меняется.
  • Проверить — assertIsDisplayed, assertIsEnabled / assertIsNotEnabled, assertTextEquals, assertIsSelected.
  • Действовать — performClick, performTextInput, performScrollTo. Каждое действие — отдельный вызов, объединять нельзя.
  • Отладить — onRoot().printToLog("TAG") печатает всё дерево в Logcat.
Важно

onNodeWithTag не находит узел, хотя testTag стоит: Expected exactly '1' node but could not find any. Причина — родитель (Row с clickable, Button, ListItem) слил семантику детей, и поиск идёт по слитому дереву. Лечится параметром useUnmergedTree = true; найти виновника помогает printToLog.

Тесты состояний UI Kit по формулировке задания​

«Тесты состояний» — не «компонент отрисовался». Проверяется каждое состояние из главы про UI Kit:

КомпонентСостояния, на которые нужен тест
Поле для вводапустое, заполненное, с ошибкой, отключённое
Селектзакрытый, раскрытый, с выбранным значением
Кнопкаобычная, нажатие вызывает onClick, отключённая не вызывает
BottomTabBarвыбранная вкладка подсвечена, нажатие переключает
Карточкас картинкой и без, нажатие вызывает обработчик

Один компонент — один тестовый класс, одно состояние — один метод. Для поля ввода:

@Test
fun `ошибка показывается под полем`() {
composeRule.setContent {
AppTextField(value = "ivan@mail", onValueChange = {}, error = "Неверный формат")
}

composeRule.onNodeWithText("Неверный формат").assertIsDisplayed()
}

@Test
fun `без ошибки подписи нет`() {
composeRule.setContent {
AppTextField(value = "ivan@mail.ru", onValueChange = {}, error = null)
}

composeRule.onNodeWithText("Неверный формат").assertDoesNotExist()
}

Пять компонентов по три–четыре состояния — пятнадцать-двадцать коротких тестов. Каждый — пять строк. Это час работы, если UI Kit собран как отдельный модуль с чистыми параметрами; и нерешаемая задача, если компоненты тянут ViewModel внутрь.

Отвечено вопросов: 0 из 2
Что проверяет тест Composable — пиксели или что-то другое?
Что означает «тесты состояний» для компонента Кнопка?
Отвечено верно: 0 из 2

TDD: три коммита RED / GREEN / REFACTOR​

В части заданий есть отдельное требование — разработка через тестирование с тремя коммитами:

  1. RED — тест написан, код ещё нет; тест падает. Коммит.
  2. GREEN — минимальный код, чтобы тест позеленел. Коммит.
  3. REFACTOR — уборка без единой правки теста. Коммит.

И ограничение, написанное в задании прямо: «в случае изменения теста на данном этапе тест не будет учитываться в оценке». Тест из коммита RED обязан дословно совпадать с тестом в GREEN и REFACTOR. Переписали ассерт под получившийся код — тест обнулён. Ошибки нет, сборка зелёная, оценка ноль.

Если тест из RED оказался неверным — честнее оставить его красным и написать рядом новый, чем править.

git log --oneline
a1b2c3d REFACTOR: вынести регекс email в константу
d4e5f6a GREEN: isValidEmail по паттерну задания
b7c8d9e RED: тесты валидатора email

Практический смысл TDD для четырёхчасового модуля — не философия, а порядок: тест на валидатор пишется за две минуты, и он сразу фиксирует, что именно требуется. Код, написанный под готовый тест, редко приходится переделывать.

Комментарии и логирование в тестах​

Множитель из главы про чистый код — половина оценки за отсутствие комментариев и логирования — покрывает 101 аспект из 149. Тестовые классы не исключение: это такие же классы в проекте.

/**
* Тесты сетевого слоя: запросы товаров и авторизации через MockWebServer.
* Создан: 12-03-2026, участник 07.
*/
class ProductApiTest {

@Before
fun setUp() {
server.start()
Log.d(TAG, "[ProductApiTest]: MockWebServer запущен — ${server.url("/")}")
}
}

Дата в формате ДД-ММ-ГГГГ и номер участника — те же, что в боевом коде. Логирование в тестах не обязано быть подробным, но setUp и tearDown — естественные точки.

Типичные ошибки​

Проверен только ответ, takeRequest() не вызван. Тест зелёный на неправильном запросе. На живом API — 400, после сдачи.

Тест поправлен после RED. По правилам задания не учитывается. Сборка зелёная, оценка ноль.

Тесты в src/androidTest/, гоняют ./gradlew test. BUILD SUCCESSFUL, тесты ни разу не запускались.

assertEquals(получили, ожидали). Работает, но сообщение об ошибке врёт наоборот — отладка идёт не туда.

ViewModel без MainDispatcherRule. Module with the Main dispatcher is missing — и ложный совет в тексте ошибки.

onNodeWithTag не находит узел. Родитель слил семантику; нужен useUnmergedTree = true.

Один фейк на весь класс. Счётчик или кэш дожил из предыдущего теста; падает то один тест, то другой. Фейк создаётся в @Before.

Тестовый класс с опечаткой. Не компилируется один — не компилируется весь sourceSet, модуль обнуляется целиком. Перед сдачей — ./gradlew test, не только сборка приложения.

Слова главы​

  • src/test/ — юнит-тесты на чистой JVM; ./gradlew test.
  • src/androidTest/ — инструментальные тесты; нужен эмулятор.
  • Arrange / Act / Assert — три шага одного теста.
  • assertEquals(ожидали, получили) — порядок аргументов.
  • Тестовый дубль — замена настоящей зависимости: фейк, стаб, мок, шпион.
  • Фейк — упрощённая рабочая реализация; предпочтителен, библиотек не требует.
  • runTest — запуск асинхронного теста с виртуальным временем.
  • MainDispatcherRule — подмена Dispatchers.Main для тестов ViewModel.
  • MockWebServer — локальный HTTP-сервер с очередью ответов.
  • enqueue / takeRequest — положить ответ; забрать принятый запрос для проверки.
  • createComposeRule — правило для тестов Composable.
  • Дерево семантики — то, что видит тест интерфейса; testTag, useUnmergedTree.
  • RED / GREEN / REFACTOR — три коммита TDD; тест после RED не меняется.

Тренажёр: набрать вслепую​

Цель: скорость ≥ 100 зн/мин, точность ≥ 88%

fun `заглавная буква не проходит`() = assertFalse(isValidEmail("Ivan@mail.ru"))

На чемпионате​

Как эксперт проверяет модуль Е:

  • Считает. Пять методов в ApiService — ищет пять тестовых методов. Пять компонентов UI Kit — пять тестовых классов. Несоответствие видно за минуту.
  • Запускает. ./gradlew test — если один класс не компилируется, весь модуль обнуляется. Проверьте это сами перед сдачей.
  • Смотрит на тест-кейсы. Один тест «успех» на запрос — минимум. Ветки 401 и 500 показывают, что участник понимает, зачем тесты.
  • Сверяет коммиты TDD, где они требуются: тест в RED и в GREEN — один и тот же файл, git diff между коммитами по тестовому классу пустой.
  • Читает шапку класса — комментарий с датой и номером участника, как в боевом коде.

Порядок работы на четыре часа: валидаторы и мапперы (десять минут, готовые тест-кейсы из задания) → MockWebServer на каждый запрос (успех, 401, 500) → состояния UI Kit. Тесты ViewModel — если осталось время.

Интернета десять минут на человека за сессию. Скелет теста, MainDispatcherRule, обвязка MockWebServer с @Before/@After — набираются по памяти. Тренажёр выше — ровно для этого.

Отвечено вопросов: 0 из 2
В коммите RED тест ждал результат «Кофе», в GREEN исправили на «Кофе, Чай». Что с оценкой?
Девять тестовых классов из десяти написаны верно, в десятом опечатка в имени метода. Результат?
Отвечено верно: 0 из 2

Что дальше​

Приложение собрано и доказано тестами — это конец основного маршрута мобильного трека. Дальше — две главы кругозора: чем Kotlin отличается от Java, из которой он вырос, и как Android дошёл от Java до Compose. Их читают не для баллов, а чтобы ответить эксперту на вопрос «а почему именно так».

Отвечено вопросов: 0 из 1
В каком порядке писать тесты на четырёхчасовом модуле?
Отвечено верно: 0 из 1

Углубиться​

  • Robolectric — тесты Composable на чистой JVM без эмулятора: @RunWith(RobolectricTestRunner::class) и isIncludeAndroidResources = true. Заметная экономия времени, но как приём, а не основной путь.
  • Turbine — библиотека для тестов Flow: flow.test { assertEquals(x, awaitItem()) } вместо ручного сбора.
  • Параметризованные тесты — одна функция, таблица входов: тот же принцип, что в модели с четырнадцатью адресами, но средствами JUnit.
  • MockK — когда фейка не хватает и нужно проверить, что метод вызван с конкретными аргументами.
  • Скриншот-тесты — сравнение отрисовки с эталонной картинкой; ловят то, что семантическое дерево не видит.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоЮнит-тестирование на примере Android — простым языкомBoris Pro IT
ВидеоЮнит-тест для ViewModel в AndroidТимофей Коваленко
ВидеоNetwork API в Android и тестирование через MockWebServerSweet Android
ВидеоWrite your first Compose UI testAndroid Developers
ВидеоTDD LiveCoding: юнит-тесты и код в прямом эфиреeasyCodeRu

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.