- Основы
- Инструменты
- Тестирование сущностей различных слоёв архитектуры Surf
- MVI
- Binding
- MVP
- Мокирование доменных моделей
- Best practices
- Материалы
Unit-тестирование (или модульное тестирование) - процесс проверки корректности работы отдельных модулей программы. Под модулем может подразумеваться класс, метод или совокупность классов.
Плюсы написания тестов:
- выявление багов и определение угловых кейсов на раннем этапе разработки;
- ускорение разработки;
- предотвращение регресса кодовой базы;
- создание дополнительной документации модуля, описывающее его поведение в различных ситуациях.
В android-проекте unit-тесты располагаются в каталоге <название-модуля>/src/test/java/.
Тесты конкретного модуля (класса, extension-методов, утилит, etc) располагаются в классе с соответствующим названием - <название-фичи>Test.
Члены класса упорядочены следующим образом:
- приватные поля с моками, стабами и прочими сущностями, использующимися в тестах;
- методы, выполняющиеся единожды перед всеми тестами;
- методы, выполняющиеся перед каждым тестом;
- методы, выполняющиеся единожды после всех тестов;
- методы, выполняющиеся после каждого теста;
- тесты.
Порядок расположения тестов внутри тест-класса произвольный.
Название тестов должно содержать описание конфигурации среды выполнения и ожидаемый результат. Другими словами, название теста должно чётко описывать его содержимое.
@Test
fun `when a and b equals 2, sum should be equals 4`() { ... }Исключение - тесты с параметрами.
@Test
fun `with given email and phone validation should pass`() = forAll(...) { ... }Тело unit-теста в большинстве случаев состоит из следующих блоков:
- given - конфигурация тестовой среды;
- when - выполнение действия, результат которого нужно проверить;
- then - проверка результатов.
@Test
fun `when a and b equals 2, sum should be equals 4`() {
// given
val a = 2
val b = 2
//when
val result = a + b
//then
result shouldBe 4
}Unit-тестами должно быть покрыто публичное API модуля за исключением тривиальных геттеров-сеттеров.
Для того, чтобы определить, какие кейсы должны быть проверены тестами, можно обратиться к тест-кейсам,
написанным тестировщиком. Их можно найти в JIRA проекта во вкладке Xray Test Repository.
- Kotest - написание unit-тестов на Kotlin;
- Mockk - создание моковых объектов для использования в unit-тестах
- Kotest plugin - плагин для Android Studio, позволяющий запускать тесты из IDE
Для корректной работы библиотек версия Gradle-wrapper не должна быть ниже 6.0.
Основной принцип, которого придерживаемся при реализации unit-тестов для любых сущностей - все внешние/внутренние объекты, с которыми сущность взаимодействует, должны быть заменены стабами/моками.
Тесты должны быть изолированы друг от друга. После изменения состояния полей тест-класс внутри теста необходимо сбросить состояние этих полей.
Нужно стараться избегать копипасты самих тестов и их блоков инициализации.
Если необходимо проверить, что на одном наборе данных метод вернет true, а на другом - false,
стоит воспользоваться параметризованными тестами.
Если во многих тестах в блоке инициализации содержится один и тот же код конфигурирования моков и стабов,
то стоит его вынести в методы, выполняющиеся до и после тестов.
Stub - сущность реализует интерфейс реального объекта.
Методы возвращают либо простой результат-заглушку, либо ничего.
Как правило ничего не знает о компонентах, которые его используют.
Mock - разработчик программирует результат выполнения метода с определенными параметрами - как до выполнения теста, так и во время выполнения в блоке given.
Для написания тестов для модуля в build.gradle необходимо добавить следующую строку:
apply from: "../unitTestConfiguration.gradle"
Kotest предоставляет возможность написание тест-классов в различных стилях, в т.ч. функциональном. В качестве стиля по умолчанию предлагается использовать обычный JUnit-стиль с аннотациями. При желании можно выбрать любой из представленных.
Тестирование Middleware сводится к трём шагам:
- Конфигурация входных событий и подписка на
Middleware.transform() - Выполнение метода
Middleware.transform(event.toObservable()) - Проверка получившихся в результате трансформаций событий.
Для реализации тест-класса Middleware существует базовый класс BaseMiddlewareTest,
который содержит всё необходимое для создания экземпляра тестируемого Middleware.
Пример тест-класса для Middleware:
internal class RateChooserMiddlewareTest : BaseMiddlewareTest() {
private val initialState = RateChooserState()
private val sh = RateChooserStateHolder()
private val analyticsService = mockk<DefaultAnalyticService>()
private val route = mockk<RateChooserDialogRoute>()
@Test
fun `when rate button clicked and rate is good, should be mapped review composer screen open event`() {
setRate(5)
val middleware = createMiddleware()
val inputEvent = RateChooserEvent.Input.RateBtnClicked
val testObserver = TestObserver<RateChooserEvent>()
middleware.transform(inputEvent.toObservable()).subscribe(testObserver)
assertSoftly(testObserver.values().first()) {
shouldBeTypeOf<RateChooserEvent.Navigation>()
val events = actualEvent.events
events.firstOrNull()
.shouldBeNavigationCommand<Show>()
.withRoute<StoreRedirectDialogRoute>()
events.getOrNull(1)
.shouldBeNavigationCommand<Dismiss>()
.withRoute(route)
}
verify { analyticsService.performAction(any<RatingStarCountEvent>()) }
}
// остальные тесты
private fun createMiddleware(): RateChooserMiddleware {
return RateChooserMiddleware(
baseMiddlewareDependency,
analyticsService,
navigationMiddleware,
route,
sh
)
}
}- Проверка команд навигации
Для проверки того, что в результате трансформаций были произведены события команд навигаций,
существует набор мэтчеров NavigationMatchers.kt.
С их помощью можно проверить тип возвращаемых команд и их route, минуя длинные цепочки из shouldBeTypeOf().
@Test
fun `when rate button clicked, review composer screen open event should be produced`() {
val inputEvent = Input.RateBtnClicked
val testObserver = middleware.transform(inputEvent.toObservable()).test()
val actualEvent = testObserver.values().first()
assertSoftly(actualEvent) {
shouldBeTypeOf<RateChooserEvent.Navigation>()
val events = actualEvent.events
events.firstOrNull()
.shouldBeNavigationCommand<Open>()
.withRoute<ReviewActivityRoute>()
events.getOrNull(1)
.shouldBeNavigationCommand<Dismiss>()
.withRoute(route)
}
testObserver.dispose()
}- Проверка запросов
Предположим, что нужно протестировать middleware, запрашивающий данные из CityInteractor:
// CityInteractor.kt
class CityInteractor {
fun getCities(): Single<List<City>> { ... }
}
// MiddlewareToTest.kt
override fun transform(eventStream: Observable<SomeEvent>) =
transformations(eventStream) {
addAll(
onCreate() eventMap { getCities() }
)
}
private fun getCities(): Observable<out SomeEvent> {
return cityInteractor.getCities()
.io()
.asRequestEvent(DataLoad::GetCities)
}Для начала в тест-класс нужно добавить поле мока CityInteractor:
private val cityInteractor = mockk<CityInteractor> {
every { cityInteractor.getCities() } returns Single.just(emptyList<City>())
}Сконфигурировать мок можно либо на месте, либо в методе, выполняющемся перед тестами:
@BeforeAll
fun setUpAll() {
every { cityInteractor.getCities() } returns Single.just(emptyList<City>())
}Для проверки того, что в результате трансформаций были произведены события RequestEvent,
существует набор мэтчеров RequestMatchers.kt.
@Test
fun `when screen initialized, cities should be loaded`() {
val inputEvent = Lifecycle(LifecycleStage.CREATED)
val testObserver = middleware.transform(inputEvent.toObservable()).test()
assertSoftly(testObserver.values()) {
firstOrNull().shouldBeRequestLoading<List<Cities>>()
getOrNull(1)
.shouldBeRequestSuccess<List<Cities>>()
.withValue<List<Cities>>()
}
testObserver.dispose()
}- Тестирование асинхронных трансформаций
Для проверки трансформаций, применяемых с задержкой, используются следующие инструменты:
- RxJavaPlugins - позволяет переопределить используемые
Scheduler'ы; - TestScheduler - позволяет проматывать время для
Observable.
@Test
fun `when rate button clicked, RateBtnClickedDebounced should be produced after delay`() {
val testScheduler = TestScheduler()
RxJavaPlugins.setComputationSchedulerHandler { testScheduler }
val inputEvent = Input.RateBtnClicked
val scheduledMiddleware = MiddlewareToTest(baseMiddlewareDependency)
val testObserver = scheduledMiddleware.transform(inputEvent.toObservable()).test()
testScheduler.advanceTimeBy(1, TimeUnit.SECONDS)
testObserver.values()
.first()
.shouldBeTypeOf<Input.RateBtnClickedDebounced>()
testObserver.dispose()
}Обычный тест Reducer'а состоит из следующих шагов:
- Конфигурация стейта
initialStateи нового событияevent - Выполнение метода
Reducer.reduce(initialState, event) - Проверка получившегося в результате работы метода стейта.
Для реализации тест-класса Reducer'а существует базовый класс BaseReactorTest,
который содержит всё необходимое для создания экземпляра тестируемого Reducerа.
Пример тест-класса:
internal class ServiceSearchReducerTest : BaseReactorTest() {
@Test
fun `when query changed, filtered list should be produced`() {
val reducer = ServiceSearchReducer(baseReactorDependency)
val initialState = ServiceSearchState(query = EMPTY_STRING, items = listOf("a", "b", "c"))
val event = Input.QueryChanged(newQuery = "abc")
val newState = reducer.reduce(initialState, event)
newState.filteredItems.shouldBeEmpty()
}
}При написании теста для Reducer'а может понадобиться проверить факт отправки значения в команду/стейт CommandHolder'а.
Сделать это можно двумя способами.
- Явная подписка на команду/стейт
CommandHolder'а
@Test
fun `when pin is wrong, should show error under pins`() = forAll(
row((minErrorCount - 1).coerceAtLeast(0), minErrorCount + 1),
row(minErrorCount, minErrorCount + 1),
row(minErrorCount + 1, minErrorCount + 1)
) { attemptNumber, maxAttempts ->
val initialState = PinCodeAuthState(wrongAttemptsCount = attemptNumber - 1)
val remainingAttempts = maxAttempts - attemptNumber
val event = PinCodeAuthEvent.WrongPinEntered(remainingAttempts)
val testObserver = commandHolder.showError.observable.test()
reducer.reduce(initialState, event)
testObserver.values().firstOrNull() shouldBe event.remainingAttempts
}- Использование мока и captured value
Тест-класс должен реализовывать интерфейс StateEmitter.
val commandHolder = mockk<SomeCommandHolder>
val reducer = SomeReducer(errorHandler, commandHolder)
@Test
fun `when pin is wrong, should show error under pins`() = forAll(
row((minErrorCount - 1).coerceAtLeast(0), minErrorCount + 1),
row(minErrorCount, minErrorCount + 1),
row(minErrorCount + 1, minErrorCount + 1)
) { attemptNumber, maxAttempts ->
val errorMessageSlot = slot<String>()
every { commandHolder.showError.accept(capture(errorMessageSlot)) }
val initialState = SomeState()
val event = RequestValidation
reducer.reduce(initialState, event)
errorMessageSlot.captured shouldBeEqualIgnoringCase "Ошибка!"
}Для мокирования домменых моделей предусмотрен метод ru.surfstudio.standard.base.test.mock.ModelParser.parse.
На вход принимает json и класс модели.
Сценарий использования:
- В файл рядом с тестами кладутся kt-строки с json-ом необходимого ответа от сервера
- В том же файле или рядом кладутся методы
getMockDomainModel
Пример DomainModelMock.kt
fun getMockDomainModel(): DomainModel = ModelParser.parseModel(json, DomainModelObj::class).transform()
private val json = """
<json representation of the model>
""".trimIndent()- В конце теста необходимо выполнять
testObserver.dispose() - Для обеспечения изоляции тестов после использования
RxJavaPluginsнеобходимо вызыватьRxJavaPlugins.reset() - Для сущностей Android SDK (
Html,Uri) необходимы моки - Для мокирования статических методов используется
mockkStatic:
mockkStatic(HtmlCompat::class)- Когда необходимо гарантировать выполнение определенных инструкций внутри тестируемого метода,
можно использовать
verify:
// then block
// Будет выполнена проверка того, что методы вызваны с соответствующими значениями
// и в указанном порядке.
verify {
rateStorage.canShow = true
rateStorage.boundaryScore = 1
}- Быстрый старт: гайд по автоматизированному тестированию для Android-разработчика. JVM
- Практика написания тестов. Лекция Яндекса (видео)
- Типичные ошибки при написании юнит-тестов. Лекция Яндекса (видео)
- RxJava 2 in unit tests
- RxJava 2 unit testing tips
- Android Developers: Build local unit tests
- Android Developers: Testing from the command line
- Software Unit Test Smells (ENG)
- Test Smells (RUS)