Skip to content

Latest commit

 

History

History
431 lines (336 loc) · 20.9 KB

File metadata and controls

431 lines (336 loc) · 20.9 KB

Главная

Unit-тестирование

  1. Основы
  2. Инструменты
  3. Тестирование сущностей различных слоёв архитектуры Surf
    1. MVI
      1. Middleware
      2. Reducer
      3. CommandHolder
    2. Binding
    3. MVP
  4. Мокирование доменных моделей
  5. Best practices
  6. Материалы

Основы

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.

Тестирование сущностей различных слоёв архитектуры Surf

Основной принцип, которого придерживаемся при реализации unit-тестов для любых сущностей - все внешние/внутренние объекты, с которыми сущность взаимодействует, должны быть заменены стабами/моками.

Тесты должны быть изолированы друг от друга. После изменения состояния полей тест-класс внутри теста необходимо сбросить состояние этих полей.

Нужно стараться избегать копипасты самих тестов и их блоков инициализации.
Если необходимо проверить, что на одном наборе данных метод вернет true, а на другом - false, стоит воспользоваться параметризованными тестами.
Если во многих тестах в блоке инициализации содержится один и тот же код конфигурирования моков и стабов, то стоит его вынести в методы, выполняющиеся до и после тестов.

Stub - сущность реализует интерфейс реального объекта. Методы возвращают либо простой результат-заглушку, либо ничего. Как правило ничего не знает о компонентах, которые его используют.
Mock - разработчик программирует результат выполнения метода с определенными параметрами - как до выполнения теста, так и во время выполнения в блоке given.


Для написания тестов для модуля в build.gradle необходимо добавить следующую строку:

apply from: "../unitTestConfiguration.gradle"

Kotest предоставляет возможность написание тест-классов в различных стилях, в т.ч. функциональном. В качестве стиля по умолчанию предлагается использовать обычный JUnit-стиль с аннотациями. При желании можно выбрать любой из представленных.

MVI
Middleware

Тестирование Middleware сводится к трём шагам:

  1. Конфигурация входных событий и подписка на Middleware.transform()
  2. Выполнение метода Middleware.transform(event.toObservable())
  3. Проверка получившихся в результате трансформаций событий.

Для реализации тест-класса 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
        )
    }
}
  1. Проверка команд навигации

Для проверки того, что в результате трансформаций были произведены события команд навигаций, существует набор мэтчеров 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()
}
  1. Проверка запросов

Предположим, что нужно протестировать 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()
}
  1. Тестирование асинхронных трансформаций

Для проверки трансформаций, применяемых с задержкой, используются следующие инструменты:

  • 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

Обычный тест Reducer'а состоит из следующих шагов:

  1. Конфигурация стейта initialState и нового события event
  2. Выполнение метода Reducer.reduce(initialState, event)
  3. Проверка получившегося в результате работы метода стейта.

Для реализации тест-класса 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()
    }
}
CommandHolder

При написании теста для Reducer'а может понадобиться проверить факт отправки значения в команду/стейт CommandHolder'а. Сделать это можно двумя способами.

  1. Явная подписка на команду/стейт 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
}
  1. Использование мока и 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()

Best practices

  • В конце теста необходимо выполнять testObserver.dispose()
  • Для обеспечения изоляции тестов после использования RxJavaPlugins необходимо вызывать RxJavaPlugins.reset()
  • Для сущностей Android SDK (Html, Uri) необходимы моки
  • Для мокирования статических методов используется mockkStatic:
mockkStatic(HtmlCompat::class)
  • Когда необходимо гарантировать выполнение определенных инструкций внутри тестируемого метода, можно использовать verify:
    // then block
    
    // Будет выполнена проверка того, что методы вызваны с соответствующими значениями 
    // и в указанном порядке.
    verify {
        rateStorage.canShow = true
        rateStorage.boundaryScore = 1
    }

Материалы