Пишем unit тесты так, чтобы не было мучительно больно



    Любую задачу в программировании можно выполнить массой разных способов, и не все они одинаково полезны. Хочу рассказать о том, как можно накосячить при написании модульных тестов. Я пишу мобильные приложения уже 6 лет, и в моем «багаже» много разных кейсов. Уверен, что кому-то будет полезно.

    Перед тем, как разобрать плохие практики, кратко пробежимся по свойствам и качествам хорошо написанных тестов.

    Хорошо написанные тесты должны быть достоверными и удобными. Как этого добиться.

    Повторяемость

    Тесты должны давать одинаковые результаты независимо от среды выполнения. Где бы ни был запущен тест – на локальной машине под Windows или macOS, или на CI сервере – он должен давать одинаковый результат и не зависеть от времени суток или погоды на Марсе. Единственное, что должно влиять на результат работы теста – тот код, который непосредственно проверяется в нем.

    Независимость

    Тесты не должны зависеть друг от друга. Иначе проблема в одном тесте может приводить к каскадной поломке других (да еще и не всегда воспроизводящейся).

    Очевидность

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

    Быстрота

    Тесты должны прогоняться как можно чаще, а значит, чем быстрее они выполняются, тем они удобней в повседневной работе.

    Своевременность

    Тесты должны быть написаны одновременно с тестируемым кодом или раньше него.

    Также эти качества известны по книгам дядюшки Боба как 5 принципов чистых тестов.

    А теперь разберем плохие практики, которые я чаще всего встречал в реальных проектах.

    Плохие практики


    Берем хорошие практики и делаем все наоборот. А теперь об этом подробнее.

    Множество проверок в одном тесте

    Несколько проверок в тесте, даже если они связаны по смыслу, вредны. Из-за этого снижается очевидность и ценность результата прогона тестов.

    Любая проверка в тесте приводит к его падению. Несколько проверок мешают понять, что конкретно сломалось.

    Каждая новая проверка – причина для изменения теста. А значит, меняться такие тесты будут чаще, чем нужно.

    Кроме того, современные тестовые фреймворки устроены так, что выбрасывают служебное исключение в случае, если проверка не проходит.

    А значит, что все дополнительные проверки после первой сработавшей никогда не выполнятся, и результаты их будут неизвестны.

    В общем, проверок должно быть сильно меньше, чем повторений слова «проверка» в этом коротком тексте.

    Пример теста с множеством проверок

    @Test
    fun `log out user EXPECT delete session and clear all caches`() {
        whenever(sessionRepository.delete()) doReturn Completable.complete()
        logOutUseCase.invoke()
                    .test()
                    .assertComplete()
    	 
        verify(sessionRepository).delete()
        verify(userRepository).delete()
    }
    

    Что делать? Такие тесты необходимо делить на более мелкие, содержащие одну действительно необходимую проверку.

    Пример разделения теста


    @Test
    fun `log out user EXPECT delete session`() {
        whenever(sessionRepository.delete()) doReturn Completable.complete()
    	 
        logOutUseCase.invoke()
                    .test()
                    .assertComplete()
    	 
        verify(sessionRepository).delete()
    }
    
    @Test
    fun `log out user EXPECT delete user`() {
        whenever(sessionRepository.delete()) doReturn Completable.complete()
    
        logOutUseCase.invoke()
                    .test()
                    .assertComplete()
    	 
        verify(userRepository).delete()
    }
    

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

    Добавлю простое эмпирическое правило: «Если не понятно, надо ли разделить тест, или можно оставить несколько проверок – надо делить».

    Скрытый вызов теста


    Выглядит как приватный метод, который содержит проверки и вызывается в других тестах. Частный случай множественных проверок в тесте, и проблемы приносит абсолютно те же.

    Пример скрытого вызова теста

    @Test
    fun `launch EXPECT open language selector`() {
        whenever(getLocaleSelectedUseCase()).thenReturn(false)
    	 
        presenter.attachView(view)
    	 
        verifyAnalytics()
        verify(router).newRoot(LanguageScreen)
    }
    	 
    private fun verifyAnalytics() {
        verify(analytics).trackLaunch()
    }
    

    Что делать? Способы борьбы аналогичны: делим один тест на несколько.

    Тест, который ничего не проверяет


    Обратный случай: тест вроде бы содержит проверки, но фактически они будут выполнены всегда. В приведенном примере тест проверяет, как реализовано mockito, а не юзкейс, для которого он написан.

    Пример теста, который ничего не проверяет

    @Test
    fun `resolve dynamic link EXPECT resolving with dataSource`() {
        val deepLinkRepository: DeepLinkRepository = mock {
            on { get(DYNAMIC_LINK) } doReturn Single.just(DEEP_LINK)
        }
    	 
        deepLinkRepository.get(DYNAMIC_LINK)
                        .test()
                        .assertValue(DEEP_LINK)
        verify(deepLinkRepository).get(DYNAMIC_LINK)
    }
    

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

    Логика в тестах


    Использование логики в тестах – зло. Любой логический оператор, будь то if, when, if not null, циклы и try, увеличивает вероятность ошибки. А ошибки тестов — это последнее, с чем хотелось бы разбираться. Также логика в тесте говорит о том, что в нем, скорее всего, происходит более одной проверки за раз. Страдает читаемость. Сложнее понять, как устроен тестовый метод.

    Пример теста с логикой

    @Test
    fun `on view ready EXPECT show operation or message`() {
        whenever(getTransferUseCase(transferId)) doReturn transfer
    	 
        presenter.attachView(view)
    	 
        if (transfer.number.isNullOrEmpty()) {
            verify(view).showOperationNumberMessage()
        } else {
            verify(view).showOperationNumber(transfer.number)
        }
    }
    

    Что делать? Разделить тест на несколько отдельных, более простых. Логические операторы удалить.

    Пример разделения теста с логикой на несколько

    @Test
    fun `transfer with number EXPECT show operation`() {
        whenever(getTransferUseCase(transferId)) doReturn transferWithNumber
    	 
        presenter.attachView(view)
    	 
        verify(view).showOperationNumber(transfer.number)
    }
    
    @Test
    fun `transfer without number EXPECT show message`() {
        whenever(getTransferUseCase(transferId)) doReturn transferWithoutNumber
    	 
        presenter.attachView(view)
    	 
        verify(view).showOperationNumberMessage()
    }
    

    Тестирование реализации, а не контракта (избыточное специфицирование)


    Контракт класса – это совокупность его открытых методов и полей и договоренности о том, как они работают.

    Типичный контракт презентера: при вызове onViewAttach происходит запрос данных, и как только они получены, view должна их отобразить, а в случае ошибки – показать диалог.

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

    Не надо так!

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

    Пример теста с избыточными проверками

    @Test
    fun `request phone EXPECT get phone from repository`() {
        whenever(phoneRepository.get()) doReturn Single.just(phone)
    	 
        getPhoneUseCase()
                    .test()
    	        .assertValue(phone)
        // эта проверка делает предположение о том, как и когда usecase получает данные
        verify(phoneRepository).get()
    }
    

    Что делать? Зависит от конкретного случая: где-то нужно просто удалить избыточные проверки, а где-то – разделить тест на несколько более простых.

    verifyNoMoreInteractions после каждого теста (в tearDown())


    Вызов verifyNoMoreInteractions для всех зависимостей класса после каждого теста несет целый ворох проблем.

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

    Эта проверка затронет все тесты, в том числе ненаписанные.
    В большинстве случаев это неоправданные проверки, которые проверяют реализацию класса под тестом, а не его контракт.

    Испортилась читаемость, так как для понимания, что проверяется в тесте, приходится заглядывать еще и в tearDown
    Но иногда проверить, не были ли вызваны лишний раз ресурсозатратные методы зависимостей, нужно. Делать это стоит в рамках отдельного теста.

    А проверки, что у зависимостей вызван только один метод и ничего более, в тестах можно осуществлять с помощью VerificationMode only().

    Разделяемое состояние


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

    Синтетический пример нестабильности тестов из-за разделяемого состояния

    class SharedState {
        private companion object{
            var counter = 0
        }
    
        @Test 
        fun `first is counter zero EXPECT true`(){
            val expected = 0
    	 
            assertEquals(expected, counter)
            counter ++
    }
    
        @Test
        fun `second is counter zero EXPECT true`(){
            val expected = 0
    	
            assertEquals(expected, counter)
    	counter ++
        }
    }
    

    Мы посмотрели самые популярные проблемы, которые встречались мне в реальных проектах. Каждая из них усложняет тесты, делая их менее полезными и более дорогими в поддержке. Так же для каждой такой проблемы приведены пути и способы ее решения. Делитесь своими популярными проблемами и эффективными способами их решения.

    Из всего перечисленного можно сделать вывод: не все unit тесты одинаково полезны.
    Центр Финансовых Технологий (ЦФТ)
    Большой. Сильный. Стабильный.

    Похожие публикации

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое