Тестирование
Тестирование кода на Kotlin — фреймворки JUnit и Kotest, runTest для suspend-кода, MockK против final-классов и функций-расширений, fake против mock, Turbine для эмиссий Flow, snapshot-тесты Compose, инструментальные и локальные JVM-тесты, commonTest в KMP и property-based тестирование.
11 вопросов
JuniorТеорияОчень частоЧем локальные JVM-тесты из src/test отличаются от инструментальных из src/androidTest?
Чем локальные JVM-тесты из src/test отличаются от инструментальных из src/androidTest?
src/test идёт на вашей JVM — быстро и без устройства, — но фреймворк Android там заглушен, поэтому его вызовы падают без мока или Robolectric. src/androidTest идёт на реальном устройстве с настоящим фреймворком: медленно, зато только там UI и Context настоящие.
Типичные ошибки
- ✗Дёргать API фреймворка из
src/testи удивляться ошибке заглушки вместо настоящего поведения - ✗Класть весь набор тестов в
src/androidTestради реализма и платить запуском эмулятора за каждый чисто логический тест - ✗Считать, что Robolectric на JVM идентичен реальному устройству, а не имитирует фреймворк
Уточняющие вопросы
- →Что меняет Robolectric в прогоне
src/testи где заканчивается его реалистичность? - →Какому поведению Room вы не поверите за пределами
src/androidTest?
JuniorТеорияОчень частоПочему suspend-тест запускают через runTest, а не через runBlocking?
Почему suspend-тест запускают через runTest, а не через runBlocking?
runTest выполняет тело теста на виртуальных часах, которые сами продвигаются вперёд: delay(10_000) завершается сразу, поэтому тест проходит за миллисекунды. runBlocking работает на реальных часах и честно заблокирует поток на десять секунд.
Типичные ошибки
- ✗Оборачивать suspend-тест в
runBlocking, а затем укорачивать боевойdelayтолько ради скорости прогона - ✗Считать, что
runTestэкономит время за счёт параллельного запуска корутин, а не за счёт виртуальных часов - ✗Ждать, что
Thread.sleepвнутри тела теста пропустится так же, какdelay
Уточняющие вопросы
- →Когда вы отключите авто-продвижение и станете двигать виртуальные часы вручную?
- →Что сделает
runTestв конце тела, если запущенная вами корутина всё ещё жива?
JuniorТеорияЧастоЧем fake отличается от mock, когда вы тестируете repository?
Чем fake отличается от mock, когда вы тестируете repository?
Fake — лёгкая рабочая реализация, например repository в памяти, и вы проверяете состояние, к которому он пришёл. Mock — сгенерированная подстановка, которую вы настраиваете, а затем проверяете взаимодействия с ней. Fake переживает рефакторинг; mock доказывает вызов.
Типичные ошибки
- ✗Замокать каждый метод repository и затем проверять возвращённые данные, тем самым перепроверяя лишь заглушку
- ✗Думать, что fake обязан генерироваться библиотекой, а не писаться руками как маленькая реализация в памяти
- ✗Проверять взаимодействия с подстановкой repository там, где устойчивой проверкой была бы проверка состояния
Уточняющие вопросы
- →Что ломается в наборе тестов с обилием mock, когда вы переименуете метод repository?
- →Когда проверка взаимодействия — единственная честная проверка, которую можно написать?
JuniorТеорияЧастоЧто изменилось от JUnit 4 к JUnit 5 и какое место занимает Kotlin-нативный фреймворк Kotest?
Что изменилось от JUnit 4 к JUnit 5 и какое место занимает Kotlin-нативный фреймворк Kotest?
JUnit 5 — переписанный фреймворк: @Test берётся из нового пакета, @BeforeEach/@AfterEach заменяют @Before/@After, extensions заменяют runners и rules, параметризованные тесты встроены. Kotest — Kotlin-нативный фреймворк со спец-DSL Given-When-Then.
Типичные ошибки
- ✗Смешивать импорт
@Testиз JUnit 4 с@BeforeEachиз JUnit 5 в одном классе, из-за чего setup молча не вызывается - ✗Ждать, что
@Ruleиз JUnit 4 сработает на движке JUnit 5, вместо переноса его в extension - ✗Считать
Kotestлишь библиотекой проверок и не замечать, что у него есть свои стили спецификаций
Уточняющие вопросы
- →Какая точка расширения JUnit 5 заменяет
@Ruleиз JUnit 4 и куда она встраивается? - →Что даёт стиль Given-When-Then в
Kotestпо сравнению с плоским списком методов@Test?
MiddleДебаггингЧастоТест ViewModel падает с Module with the Main dispatcher had failed to initialize
Тест ViewModel падает с Module with the Main dispatcher had failed to initialize
viewModelScope работает на Dispatchers.Main, за которым стоит главный looper Android, а его на локальной JVM нет. Подставьте тестовый диспетчер: Dispatchers.setMain(StandardTestDispatcher()) в @Before и Dispatchers.resetMain() в @After.
Типичные ошибки
- ✗Винить в падении fake-repository или
runTestвместо отсутствующего главного looper заDispatchers.Main - ✗Вызывать
Dispatchers.setMain, но неresetMain, из-за чего диспетчер утекает в следующий тестовый класс - ✗Переносить чисто логический тест ViewModel в
src/androidTestтолько ради настоящегоDispatchers.Main
Уточняющие вопросы
- →Чем
StandardTestDispatcherотличается здесь отUnconfinedTestDispatcher? - →Почему
Dispatchers.resetMain()в@Afterне является необязательным, когда классов в наборе много?
MiddleТеорияЧастоЗачем в Kotlin своя библиотека моков MockK рядом с Java-библиотекой Mockito?
Зачем в Kotlin своя библиотека моков MockK рядом с Java-библиотекой Mockito?
Классы в Kotlin по умолчанию final, поэтому Mockito не мокает их без inline mock maker. MockK написан под Kotlin: он мокает final-классы, функции-расширения и функции верхнего уровня, а также даёт coEvery/coVerify для suspend-функций.
Типичные ошибки
- ✗Считать, что
Mockitoзамокает класс Kotlin без включения inline mock maker, и винить в падении сам Kotlin - ✗Помечать боевые классы
openтолько ради мока в тесте, вместо выбора библиотеки, знающей про Kotlin - ✗Подменять
suspend-функцию черезeveryвместоcoEveryи получать ошибку контекста корутины
Уточняющие вопросы
- →Как библиотека моков
MockKперехватывает функцию верхнего уровня или расширение, у которых нет экземпляра для прокси? - →Что умеет
coEvery, чего не можетevery, если подменяемая функция объявленаsuspend?
MiddleКодЧастоПроверка последовательности эмиссий Flow библиотекой тестирования Turbine
Проверка последовательности эмиссий Flow библиотекой тестирования Turbine
Соберите поток внутри блока test { } из Turbine и вытягивайте элементы по порядку: flow.test { assertEquals(UiState.Loading, awaitItem()); assertEquals(UiState.Done, awaitItem()); awaitComplete() }. Именно awaitComplete() доказывает, что лишних эмиссий не было.
Типичные ошибки
- ✗Проверять только элементы и не закрывать блок, из-за чего лишняя эмиссия после
Doneпроходит незамеченной - ✗Хвататься за
first()илиtoList()на бесконечном потоке, из-за чего тест виснет, а не падает - ✗Считать
awaitItem()подглядыванием в последнее значение, а не вытягиванием следующей эмиссии по порядку
Уточняющие вопросы
- →Как изменится тест, если поток никогда не завершается, как это делает
StateFlow? - →Что сообщит библиотека тестирования Flow
Turbine, если поток выдаст на один элемент больше, чем вы ждали?
MiddleТеорияИногдаЧто ловит snapshot-тестирование (скриншот-тестирование) Compose и чего оно вам стоит?
Что ловит snapshot-тестирование (скриншот-тестирование) Compose и чего оно вам стоит?
Оно рендерит composable и сравнивает снимок с сохранённым эталоном; любое расхождение пикселей роняет тест, ловя визуальные регрессии, которых не видят проверки. Эталоны перегенерируют при каждом задуманном изменении; они зависят от устройства, шрифтов и плотности.
Типичные ошибки
- ✗Перегенерировать эталоны на каждом красном тесте, превращая набор тестов в штамп, который одобряет регрессии
- ✗Прогонять эталоны на устройстве, масштабе шрифта или плотности, отличных от тех, где их сняли
- ✗Ждать, что проверки семантики или кликов поймают чисто визуальную регрессию вроде цвета или отступа
Уточняющие вопросы
- →Как удержать эталоны стабильными между машинами, если доверяют только образу CI?
- →Какие визуальные изменения заслуживают эталона, а какие лучше проверять по семантике?
MiddleТеорияИногдаНа чём в действительности выполняется тест из source set commonTest в Kotlin Multiplatform?
На чём в действительности выполняется тест из source set commonTest в Kotlin Multiplatform?
commonTest пишут поверх мультиплатформенного API kotlin.test, поэтому один набор тестов компилируется и выполняется для каждого объявленного таргета — а на JVM-таргете его под капотом запускает JUnit. Невыразимое общим кодом уходит в jvmTest или iosTest.
Типичные ошибки
- ✗Тащить в
commonTestбиблиотеку проверок только для JVM, из-за чего он перестаёт компилироваться под native-таргеты - ✗Верить, что зелёный
commonTestдоказывает поведение на iOS, когда на деле прогнали только JVM-таргет - ✗Заталкивать платформенное поведение в
commonTestвместо платформенного source set, которому оно принадлежит
Уточняющие вопросы
- →Как проверить поведение, существующее только на iOS, оставив общий набор тестов общим?
- →Какая проверка
kotlin.testложится на проверку JUnit и где это соответствие заканчивается?
MiddleТеорияИногдаЧто даёт property-based тестирование в Kotlin-фреймворке Kotest по сравнению с тестами на примерах?
Что даёт property-based тестирование в Kotlin-фреймворке Kotest по сравнению с тестами на примерах?
Вы формулируете инвариант — decode(encode(x)) возвращает x — и checkAll проверяет его на сотнях сгенерированных входов, а не на горстке примеров. Он ужимает падение до наименьшего ломающего контрпримера. Тесты на примерах фиксируют бизнес-случаи.
Типичные ошибки
- ✗Проверять внутри
checkAllконкретное ожидаемое значение вместо инварианта, который обязан держаться на любом входе - ✗Принимать прошедшее свойство за доказательство корректности, а не за свидетельство на сгенерированных входах
- ✗Выбрасывать тесты на примерах, как только появилось свойство, и терять зафиксированные ими бизнес-случаи
Уточняющие вопросы
- →Почему ужатый контрпример важнее, чем сырой упавший вход, найденный генератором?
- →Какие инварианты своего кода вы напишете как свойство, а какие — как пример?
SeniorКодИногдаПротестируйте ViewModel, чей uiState: StateFlow собран через combine из нескольких Flow repository
Протестируйте ViewModel, чей uiState: StateFlow собран через combine из нескольких Flow repository
Подмените Dispatchers.Main на StandardTestDispatcher() через setMain/resetMain, запускайте тест в runTest и питайте combine из fake-repository. Собирайте uiState через Turbine до первых эмиссий, а затем проверяйте каждый awaitItem().
Типичные ошибки
- ✗Читать
uiState.valueпосле эмиссий fake и видеть лишь последнее состояние, ведьStateFlowсхлопывает значения - ✗Оставлять настоящий
Dispatchers.Main, из-за чегоviewModelScopeвообще не стартует на локальной JVM - ✗Ждать от
combineэмиссии раньше, чем каждый входной поток выдал своё первое значение
Уточняющие вопросы
- →Почему схлопывающий
StateFlowскрывает состояния от теста, который лишь читаетuiState.valueв конце? - →Как изменятся проверки, если один из объединяемых
Flowrepository вообще ничего не выдаст?