содержание
Unit тестирование. Mockito. Test Driven Development
Основано на этой лекции.
В этой лекции мы поговорим о Unit-тестировании. Узнаем, как тестировать андроид-приложения, как работать с библиотекой Mokito, как измерить уровень покрытия тестами в вашем проекте. Отдельно рассмотрим такой подход к рахзработке, как Test Driven Development.
Содержание:
- Введение
- Unit-тестирование
- JUnit
- Mockito
- Unit-тестирование с классами Android
- Unit-тестирование с серверными запросами
- Dependency Injection
- Измерение покрытия кода тестами
- Дополнительно– Test Driven Development (TDD)
Введение
На сегодняшний день тестирование систем является неотъемлемой частью процесса их разработки, и, разумеется, приложения под Android не стали исключением. Тестирование систем позволяет приобрести определенную уверенность в корректности и качестве их работы. Кроме того, тестирование позволяет проще вносить изменения (после внесения изменений систему можно протестировать и убедиться, что она работает корректно).
Существует немало видов тестирования, но для начала нас интересует разделение тестирования по степени автоматизации. Здесь можно очень грубо разделить тестирование на автоматизированное и ручное. Ручное тестирование подразумевает проверку системы человеком, сверку с техническим заданием и требованиями и поиск несоответствий и багов. Автоматическое тестирование позволяет выполнять тестирование системы программно.
Разумеется, у обоих этих способов есть свои преимущества и свои недостатки. Ручное тестирование позволяет лучше выявить недостатки, в том числе и такие проблемы, которые сложно найти с помощью автоматизированного тестирования (к примеру, с помощью автоматизированного тестирования будет тяжело заметить неправильный шрифт или неправильное расположение элементов).
С другой стороны, ручное тестирование в тысячи раз медленнее автоматического. В среднем, за рабочий день тестировщик может полностью проверить работу достаточно небольшого приложения за один день на одном устройстве, в то время как автоматическое тестирование позволяет буквально за несколько минут проверить корректность работы приложения на большом количестве устройств (или мгновенно выполнить тесты для бизнес-логики на JUnit).
Поскольку каждый вид тестирования имеет свои преимущества, правильным решением, будет их совместное использование.
Но нас, разумеется, будет интересовать только автоматическое тестирование, которое позволяет разработчику самостоятельно протестировать свое приложение. Кроме того, написание тестов при разработке имеет еще несколько очень важных преимуществ:
- Если вы хотите писать тесты, то вам придется придерживаться определенного архитектурного стиля. Мы обсуждали это уже не раз, и именно возможность тестирования была одной из основных причин, из-за которой мы занимались изучением архитектуры приложений.
- Написание тестов позволяет еще на этапе разработки выявить некоторые проблемы и случайные ошибки в вашем коде.
- Если в вашем проекте есть автоматические тесты, вы можете с меньшим риском вносить изменения в различные участки кода, поскольку с помощью тестов вы можете быстро проверить, что новыми изменениями вы не сломали старое поведение (конечно, тесты дают не гарантию корректности кода, а только лишь некоторую уверенность в этом, что тоже хорошо).
- Наличие тестов позволяет контролировать процесс разработки: тесты можно поставить на CI-сервер, чтобы отслеживать текущее состояние кода или проверять рабочие ветки, что очень удобно и дает гарантию того, что в production (релиз) попадет только код с выполняющимися тестами.
- Автоматические тесты могут служить документацией к вашему коду.
Кроме того, существует немало других разделений по видам тестирования. Один из наиболее употребляемых и важных разделений тестирования – это разделение тестирования по степени их модульности. Здесь в общем случае выделяется 3 вида тестирования:
- Модульное тестирование – это тестирование отдельных модулей системы в независимом от других модулей окружении. Это, вероятно, наиболее популярный и известный вид тестирования, и это логично. Чем более детальное тестирование мы выполняем, тем легче найти потенциальные ошибки, так как в тестировании одного модуля мы проверяем корректность работы только этого модуля, а он, конечно, имеет намного меньшую сложность, чем вся система в целом. Именно для реализации модульного тестирования мы и создавали гибкую и удобную архитектуру с разбиением приложения на слои и отдельные модули.
- Интеграционное тестирование – после проверки корректности работы отдельных модулей нужно проверить, как эти модули взаимодействуют между собой. Потому что система – это не только набор отдельных модулей, но и правила и средства их взаимодействия. Каждый модуль в отдельности может работать правильно, но при этом объединение нескольких модулей может содержать ошибки. Это тестирование выполняется уже на более высоком уровне.
- Системное тестирование – после объединения всех модулей приложения в единую систему и проведения интеграционного тестирования для различных групп модулей нужно провести тестирование того, как система работает в целом и насколько она соответствует изначальным требованиям. Это тестирование на глобальном уровне, поэтому детали реализации отдельных модулей уже не играют роли – система проверяется больше с точки зрения пользователя, или по методу черного ящика.
В рамках системы Android можно выполнять автоматизированное тестирование всех видов, изложенных выше. Но есть и особенности. Во-первых, в рамках нашей архитектуры основным компонентом, который содержит бизнес-логику и который должен быть протестирован в первую очередь, является делегат или Presenter. Presenter – это обычный Java-класс (с возможными зависимостями от Android), поэтому тесты для него пишутся в рамках модульного тестирования и с помощью стандартного фреймворка JUnit. Также в рамках модульного тестирования нужно протестировать работу слоя данных.
В случае модульного тестирования все достаточно очевидно. Но что же насчет интеграционного и системного тестирования? Что является интеграцией нескольких модулей в случае Android-приложения? Обычно под интеграцией нескольких модулей понимается конкретный экран приложения, на котором объединяются модули, содержащие бизнес-логику, и модули из слоя данных. Конкретный экран уже должен быть протестирован с точки зрения пользователя, хоть и с небольшими знаниями о том, как устроена система внутри.
И насчет системного тестирования также все очевидно – это тестирование всего приложения и всех его экранов. Оно также выполняется с точки зрения пользователя.
Тестирование слоя данных, а также интеграционное и системное тестирование в Android реализуются с помощью специальных средств, которые будут рассмотрены в рамках следующей лекции. А сейчас мы перейдем к тестированию бизнес-логики приложения, а именно к тестированию Presenter-ов.
JUnit
Мы много раз сказали о тестировании, а также о разных средствах для написания тестов. Но что же такое тесты? Интуитивно мы понимаем, что это означает. Тесты – это некоторые проверки или утверждения, которые позволяют в определенной степени убедиться в корректности работы системы.
Для модульного тестирования Java-кода используется фреймворк JUnit. Этот фреймворк позволяет конфигурировать окружение для тестов, исполнять код, который будет проверять работу некоторых классов, и выводить результаты тестов (сколько успешных тестов, сколько ошибок, и где именно произошли ошибки).
Разберем простейший пример написания тестов. Допустим, у нас есть класс, в котором определен только один метод для сложения двух чисел:
class MyCalc { fun Sum(a: Int, b: Int): Int = a+b }
И напишем для него тестовый класс:
@RunWith(JUnit4.class) class MyCalcTest { lateinit val calc: MyCalc @Before fun setUp(){ // создаем экземпляр класса calc = MyCalc() } @Test fun sum() { // тестируем результат функции Assert.assertEquals(calc.Sum(2,2), 4) } @After fun tearDown(){ // } }
Какие основные элементы есть в этом тесте? Во-первых, в аннотации @RunWith указывается так называемый Runner, который и отвечает за запуск тестов, корректный вызов и обработку всех методов (но вроде работает и без него).
Во-вторых, мы указываем тестовые методы. Тестовые методы – это методы с аннотацией @Test, в которых выполняется непосредственная проверка тестируемого кода. Большинство проверок выполняется с помощью методов, начинающихся с assert: assertTrue, assertEquals и другие. Эти методы проверяют, соответствует ли результат работы (в нашем примере сложение чисел 2 и 2) ожидаемому значению (в нашем примере это число 4). В случае ошибки выбрасывается исключение, и JUnit информирует нас о том, что какой-то тест не выполняется.
И еще два важных метода – это методы, помеченные аннотациями @Before и @After. Код метода с аннотацией @Before будет выполняться перед выполнением каждого тестового метода. Соответственно, код с аннотацией @After будет выполняться после каждого тестового метода. Эти методы нужны для того, чтобы подготовить какие-то параметры или объекты к тестам (например, вынести тестируемый объект в поле класса и инициализировать его в методе setUp вместо того, чтобы выполнять инициализацию в каждом тестовом методе) или же очистить ресурсы после окончания тестового метода.
Фреймворк JUnit очень простой, и писать тесты на нем легко. По сути, все, что есть в этом примере, и есть основные возможности JUnit. Поэтому мы сразу перейдем к тестированию Presenter-а, который мы создавали в рамках прошлой лекции.
Начнем с простого, проверим, что Presenter корректно создается и инициализируется. Напомним, какие поля определены в этом Presenter-е:
class AuthPresenter(private val authView: AuthView){ ...
Получается, что нам нужно создать экземпляр объекта, который реализует интерфейс AuthView. Создадим этот объект, реализовав все его методы и ничего в них не выполняя:
var mPresenter = object : AuthView { override fun successLogin() {} override fun showLoginError(error: String) {} override fun showPasswordError(error: String) {} override fun showLoading() {} override fun hideLoading() {} override fun getToken(): String = "" override fun setToken(token: String) {} }
И мы наконец-то можем проверить, что наш Presenter корректно создается и инициализируется:
@Test fun testCreated(){ val presenter = AuthPresenter(mPresenter) assertNotNull(presenter) }
При запуске теста может возникнуть ошибка:
java.lang.RuntimeException: Method isEmpty in android.text.TextUtils not mocked.Это происходит из-за того, что класс TextUtils входит в библиотеку Андроид, про которую JVM пока не знает. Пока можно в build.graddle в секцию android добавить заглушку, подробнее эта тема будет затронута ниже:testOptions { unitTests.returnDefaultValues = true }
Казалось бы, это самый простой тест, но для его написания мы потратили очень много кода. Можно надеяться, что дальше все пойдет проще. Теперь протестируем метод, который вызывается при старте приложения (напомним, этот метод проверяет текущее состояние авторизации и, если пользователь уже авторизован, то открывает главный экран). Этот метод выглядит следующим образом:
init { val token = authView.getToken() if(token.trim()!="") authView.successLogin() }
Для этого метода возможно в общем случае два сценария, которые нужно протестировать: когда отсутствует сохраненный токен – тогда Presenter не должен вызывать никакие методы у View, и когда в приложении есть сохраненный токен, тогда Presenter должен вызвать метод successLogin у View.
И вот здесь серьезная проблема. В первом примере мы показывали, как проверить работу метода, который возвращает какой-то результат. Но как проверить void метод? Или еще хуже, как проверить то, что у какого-то объекта не был вызван ни один метод? Конечно, мы можем для каждого тестового метода писать свою реализацию AuthView, которая проверяла бы то, что был вызван определенный метод. Поэтому можно было бы написать примерно такой код (мне его лениво переводить на котлин, в принципе и так понятно):
public class TestAuthView implements AuthView { private final boolean mExpectRepositories; private final boolean mExpectLoginError; public TestAuthView(boolean expectRepositories, boolean expectLoginError) { mExpectRepositories = expectRepositories; mExpectLoginError = expectLoginError; } @Override public void successLogin() { assertTrue(mExpectRepositories); } @Override public void showLoginError() { assertTrue(mExpectLoginError); } }
Тогда эту реализацию можно было бы использовать следующим образом:
@Test public void testEmptyLogin() throws Exception { AuthView authView = new TestAuthView(false, true); AuthPresenter presenter = new AuthPresenter(mLifecycleHandler, authView); presenter.tryLogIn("", "123456"); }
При создании TestAuthView мы ожидаем, что для данного теста не будет вызван метод successLogin, зато будет вызван метод showLoginError. И такой подход может работать.
Но теперь мы можем усложнить пример. Допустим, нам нужно проверить, что какой-то метод был вызван несколько раз. Да, мы можем ввести специальные счетчики для каждого метода и вспомогательные классы для проверки этих счетчиков, но это становится уже слишком сложно. А ведь мы так же хотели бы проверять, правильные ли параметры были переданы вызванному методу.
Есть и другой пример, который наверняка отобьет желание реализовывать предложенный подход. Предположим, что для каких-то целей нам потребовалось передать объект Context в Presenter. И, если мы попробуем создать таким способом экземпляр класса Context, то мы получим порядка 150 методов, которые нужно переопределить, что уже совсем нетривиально.
Разумеется, мы не единственные разработчики, которые столкнулись с такой проблемой, и для нее уже давно известны способы решения. Самый простой и известный из этих способов – это библиотека Mockito.
Mockito
Mockito – это библиотека для создания mock-объектов на Java. Mock-объекты – это обычные объекты, все методы которого возвращают значения по умолчанию. Казалось бы, что в этом хорошего? Использование mock-объектов имеет три огромных преимущества:
- Позволяет в одну строчку создать экземпляр любого класса или интерфейса, при этом нет необходимости реализовывать или переопределять методы. Здесь есть только одно исключение – final классы. Поэтому простой совет – не используйте final классы (хотя есть инструменты, позволяющие замокать и их).
- Mock-объект позволяет узнать, был ли вызван какой-либо метод у этого объекта, а также сколько раз он был вызван и с какими параметрами.
- Mock-объект можно настроить таким образом, чтобы он реагировал на вызов какого-то метода нужным образом (возвращал определенный результат, выбрасывал исключение).
У котлина все классы по-умолчанию являются final, но к нашему счастью есть библиотека и для котлина, где это ограничение снято. В зависимости проекта добавьте:
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
Первые два преимущества – это как раз то, что нам нужно! Мы можем создать mock-объект для AuthView, а также узнать, какие методы у него были вызваны. Создать mock-объект с помощью Mockito очень просто – для этого служит статический метод mock:
val authView = Mockito.mock(AuthView::class.java)
И теперь мы можем вызывать все методы интерфейса AuthView, Mockito зарегистрирует все вызовы, которые мы сможем дальше проверить. Тогда мы можем написать тесты для проверки входа при существующем логине следующим образом:
@Test fun testNextScreenOpened(){ //создаем mock-объект для интерфейса AuthView val authView = Mockito.mock(AuthView::class.java) //создаем заглушку для метода getToken, всегда возвращаем "token" Mockito.`when`(authView.getToken()).thenReturn("token") // создаем экземпляр класса AuthPresenter val presenter = AuthPresenter(authView) // проверка токена происходит в конструкторе, поэтому мы сразу можем проверить вызывался ли метод successLogin Mockito.verify(authView).successLogin() }
Такая форма записи позволяет проверить, действительно ли у экземпляра AuthView был вызван метод successLogin. Мы также можем проверить, что у AuthView, например, не был вызван метод showLoading во время процесса инициализации:
Mockito.verify(authView, never()).showLoading()
^^^^^^^
Как видно, для этого используется еще одна форма метода verify, где вторым параметром указывается функция, возвращающая VerificationMode.
Кроме самого факта был вызов функции или нет можно узнать и количество вызовов. Например, предыдущий пример можно переписать так:
Mockito.verify(authView, Mockito.times(0)).showLoading()
Кроме этого, Mockito позволяет в удобной форме проверить, что у mock-объекта не был вызван ни один метод. Для этого служит метод verifyNoMoreInteractions. К примеру, так может выглядеть тест, проверяющий, что Presenter не вызывает у View никакие методы после создания (у нас этот тест не пройдет, т.к. метод getToken вызывается сразу из конструктора):
@Test fun testNextScreenNotOpened(){ val authView = Mockito.mock(AuthView::class.java) Mockito.`when`(authView.getToken()).thenReturn("") val presenter = AuthPresenter(authView) Mockito.verifyNoMoreInteractions(authView) }
На самом деле, мощь Mockito не ограничивается только этими методами. Как уже было сказано, Mockito позволяет подменять реализацию методов, чтобы они возвращали определенное значение и выбрасывали исключение. Но для нас будет достаточно рассмотренных средств.
Справедливости ради нужно сказать, что существуют и другие библиотеки для создания mock-объектов, но признается, что Mockito является наиболее удобной из них (к тому же она постоянно развивается).
А пока мы продолжаем писать тесты для конструктора AuthPresenter (казалось бы, что метод состоит из 3 строчек, но в нем собраны почти все проблемы тестирования модулей Android-приложений на JUnit). Напомним еще раз, как выглядит конструктор:
init { val token = authView.getToken() if(token.trim()!="") authView.successLogin() }
Мы уже говорили, что для этого метода есть два сценария: когда токен пустой и когда он не пустой.
Тут пропущен кусок оригинальной статьи, где рассматиривается создание класса для работы с токеном. Но у нас этот функционал отдан на откуп вызывающему классу, так что для тестирования сценариев с токеном нам достаточно заглушки на метод getToken
И теперь мы можем написать тесты для всех рассмотренных случаев:
lateinit var mAuthView: AuthView @Before fun startUp(){ mAuthView = Mockito.mock(AuthView::class.java) } @Test fun testNextScreenOpened(){ Mockito.`when`(mAuthView.getToken()).thenReturn("token") AuthPresenter(mAuthView) Mockito.verify(mAuthView).successLogin() } @Test fun testEmptyToken(){ Mockito.`when`(mAuthView.getToken()).thenReturn("") AuthPresenter(mAuthView) Mockito.verify(mAuthView, never()).showLoading() Mockito.verify(mAuthView, never()).successLogin() }
Теперь мы можем достаточно простым способом подменять значение токена и тестировать Presenter в зависимости от значения токена.
Unit-тестирование с классами Android
К сожалению, это еще не все проблемы, с которыми мы сталкиваемся при написании тестов. Использование Mockito – это весьма стандартный подход и в Java, который позволяет подменить реализацию или замокать некоторые вызовы, например, обращение к БД. Android добавляет и свои проблемы. Попробуем сейчас запустить тесты и получим ошибку:
java.lang.RuntimeException: Method isEmpty in android.text.TextUtils not mocked.
И сейчас пришла пора узнать, почему в таких делегатах как Presenter, которые тестируются на JUnit, не рекомендуется использовать зависимости от Android-классов. Это происходит потому, что тестирование на JUnit выполняется на JVM (виртуальной машине Java), которая ничего не знает о классах Android (а TextUtils определен в пакете android.text). Это фактически означает, что тесты на JUnit не смогут скомпилироваться, если в тестируемых классах есть зависимости от классов Android. Чтобы избежать этого, все классы Android поставляются в виде файла android.jar, в котором есть все классы и методы из Android. Но все методы во всех классах из этого jar-файла не содержат реализацию, а только выбрасывают исключение. Это позволяет тестам компилироваться, но не позволяет использовать эти методы напрямую.
Мы уже можем предположить, как решить такую проблему, когда мы используем экземпляры классов Android – тут нам поможет Mockito. Мы можем создавать экземпляры классов Android и мокать нужные методы.
К сожалению, в этом небольшом, но очень проблемном методе есть еще одна деталь – он использует статические методы из классов Android, а именно TextUtils. Мы уже знаем, как бороться с проблемой статических методов. Но здесь есть своя специфика.
В случае PreferenceUtils мы изменили доступ к простым данным, добавив новый класс в слое данных. В случае TextUtils мы можем поступить также, но это может быть слишком сложно – для каждого такого метода делать специальный класс, в таком случае мы получим слишком много разных делегатов. Переносить TextUtils в слой данных также не слишком хороший вариант, так как он не имеет к этому слою никакого отношения. Есть 3 разных способа решения этой проблемы, которые мы сейчас и рассмотрим.
Первым способом является библиотека PowerMock. PowerMock по принципу работы очень похож на Mockito, только в отличие от Mockito он позволяет замокать не только объекты и их методы, но в том числе и конструкторы объектов, и статические методы. Логичный вопрос – почему не использовать всегда PowerMock вместо Mockito? PowerMock должен использоваться в старых проектах, где вы не можете легко изменить реализацию какого-то метода, или же в случаях, когда вам нужно замокать какую-то библиотеку. PowerMock делает ваши тесты зависимыми от реализации, то есть вы в тестах уже указываете, как должен работать ваш код, а не тестируете его работу. Mockito должно быть достаточно для того, чтобы протестировать ваш код. Если Mockito недостаточно, значит, вам стоит пересмотреть архитектуру.
Про PowerMock нужно знать, так как бывают ситуации, когда без него не обойтись. Поэтому рассмотрим пример его использования. Во-первых, нужно подготовить Runner для JUnit и указать классы, в которых будут замоканы статические методы:
TODO: PowerMock не взлетел
Здесь мы указываем, что хотим замокать класс TextUtils (это позволяет PowerMock перехватывать вызовы методов этого класса), и после этого мокаем сам метод isEmpty.
Этот способ нужно знать, но применять его нужно очень и очень аккуратно, иначе PowerMock может быстро стать “золотым молотком” в ваших тестах.
Вторым и наиболее популярным способом работы с классами Android в рамках JUnit является библиотека Robolectric. Эта библиотека позволяет эмулировать работу Android-приложения на JVM. Разумеется, это будет только эмуляция и только для основных функций системы, но для тестирования это очень удобно. Использовать Robolectric очень легко, нужно только добавить зависимости:
testImplementation 'org.robolectric:robolectric:4.2.1'
И указать в качестве Runner класс RobolectricTestRunner:
@RunWith(RobolectricTestRunner::class) @Config(sdk=[21], manifest = Config.NONE) class AuthPresenterTest { ...
И это все! Правда, время запуска тестов может увеличиться, так как Robolectriс выполняет загрузку всех своих классов, чтобы вы могли использовать их в тестах.
Robolectric использует концепцию shadow классов. Когда вы обращаетесь к какому-либо классу, Robolectric проверяет наличие такого Shadow класса, и, если он существует, вызов метода идет к этому классу, а не к исходному классу. При этом вам не нужно явно указывать, реализацию каких классов вам нужно заменить. В состав библиотеки Robolectric изначально входит большое количество shadow классов, которые нужны для замены самых популярных классов из Android. Но вы также можете добавить и свои shadow классы, если вам не хватает стандартных.
Нужно также сказать, что Robolectric позволяет выполнять базовое тестирование UI-элементов, но для этого не нужно его использовать. Для тестирования UI следует использовать настоящие устройства и другие фреймворки, например, Espresso.
И есть еще один способ, который является очень простым и в то же время действенным. В подавляющем большинстве случаев с проблемой того, как замокать статический метод, вы столкнетесь как раз при использовании методов TextUtils.isEmpty или TextUtils.equals. И тогда, если нельзя использовать стандартные классы из Android, то можно создать собственный класс с этими методами (скопировав их в свой класс):
// автоматическая трансляция кода - не проверял object TextUtils { fun isEmpty(text: CharSequence?): Boolean { return text == null || text.length == 0 } }
И теперь во всех классах можно использовать собственный класс TextUtils вместо классов Android. Этот способ достаточно простой, но позволяет решить возникшую проблему.
Unit-тестирование с серверными запросами
Мы наконец-то разобрались со всеми возникшими проблемами, и теперь наши тесты для конструктора наконец работают как нужно! Мы знаем, как замокать различные объекты, как работать со статическими методами и даже как простейшим образом подменять реализацию нужных классов из слоя данных. И теперь нам осталось разобрать еще один важный вопрос, о котором мы говорили изначально – как изменять окружение для эмулирования различных ответов сервера. В данном примере это нужно для того, чтобы проверить различные варианты для ответов авторизации. Напомним код для авторизации в Presenter:
fun tryLogIn(login: String, password: String) { if (login.trim()=="") { authView.showLoginError( ERROR_EMPTY_LOGIN ) } else if (password.trim()=="") { authView.showPasswordError( ERROR_EMPTY_PASSWORD ) } else { // показ прогресс-бара authView.showLoading() try { // в оригинальной статье опущен момент с сохранением токена - это нужно делать тут val jsonResp = JSONObject(Factory.repository!!.login("qq", "ww")) if(jsonResp.has("status") && jsonResp.getString("status")=="OK") { authView.setToken("some token") authView.successLogin() } else { authView.showLoginError(ERROR_AUTH) } } catch (e: Exception){ authView.showLoginError(ERROR_NETWORK) } authView.hideLoading() } }
Какие варианты нам нужно протестировать для этого метода? Разумеется, это случаи, когда логин или пароль пустые, написание для них тестовых методов является тривиальной задачей:
@Test fun testEmptyLogin(){ Mockito.`when`(mAuthView.getToken()).thenReturn("") val mPresenter = AuthPresenter(mAuthView) mPresenter.tryLogIn("", "password") Mockito.verify(mAuthView).showLoginError(AuthPresenter.ERROR_EMPTY_LOGIN) } @Test fun testEmptyPassword(){ Mockito.`when`(mAuthView.getToken()).thenReturn("") val mPresenter = AuthPresenter(mAuthView) mPresenter.tryLogIn("login", "") Mockito.verify(mAuthView).showPasswordError(AuthPresenter.ERROR_EMPTY_PASSWORD) }
Теперь у нас осталось еще два случая: успешная авторизация на сервере и неудача при попытке авторизации (все возможные ошибки обрабатываются одинаково, поэтому других важных вариантов здесь нет).
Как мы могли бы проверить корректность обработки этих двух вариантов? Самый простой способ – выполнить реальные запросы на сервер, используя в первом случае существующие данные для авторизации, а для проверки ошибки – любые случайные данные. На самом деле, это не самый плохой вариант из возможных. Тестирование на реальном сервере, а не на измененном для теста окружении будет ближе к реальной работе системы. Однако здесь есть несколько минусов:
- Для проверки стандартных корректных сценариев будет необходимо использовать в коде реальные данные существующих пользователей. Конечно, это не настолько критично, так как можно использовать специальных тестовых пользователей.
- Серверные запросы могут выполняться не быстро. В случае, когда у вас несколько тысяч тестов, это может оказать ощутимый эффект на скорости выполнения тестов, что нежелательно. Кроме того, такое количество запросов может ощутимо нагружать работу сервера.
- Тестовые методы попадают в зависимость от окружения. Допустим, вы проверяете сценарий корректного входа и ожидаете успешный вход при вводе реальных данных. А в этот момент на сервере, к которому вы обращаетесь в тесте, случился сбой, и поэтому вы получите ошибку, а тест не пройдет, хотя он написан верно.
По комплексу описанных причин лучше использовать различные методы для подмены окружения при тестировании. Под окружением в данном случае имеется в виду слой данных. И здесь есть большое количество различных вариантов.
Самый простой из этих вариантов мы уже рассмотрели в процессе избавления от проблемы при работе с настройками – это статическое поле для Repository и метод для установки этого поля. Этот способ тривиальный, но вполне эффективный.
Мы этот вариант не рассматривали и дальше будем использовать штатные средства библиотеки Fuel для тестирования.
Этот код устарел — не использовать!!!
Библиотека Fuel позволяет задать своего клиента, который симулирует ответы на запросы:
FuelManager.instance.client = object : Client { // переопределяем метод executeRequest и формуруем нужный нам ответ - в данном случае ошибка сервера override fun executeRequest(request: Request): Response { return Response( request.url, statusCode = 500 ) } }
Проверим сценарии авторизации.
// тест на ошибку связи/сервера @Test fun testNetworkError(){ FuelManager.instance.client = object : Client { override fun executeRequest(request: Request): Response { return Response( request.url, statusCode = 500 ) } } Mockito.`when`(mAuthView.getToken()).thenReturn("") val mPresenter = AuthPresenter(mAuthView) // логин/пароль условно валидные mPresenter.tryLogIn("login", "password") // в результате должен вызваться метод showLoginError Mockito.verify(mAuthView).showLoginError(AuthPresenter.ERROR_NETWORK) } // тест на успешную авторизацию @Test fun testSuccessLogin(){ FuelManager.instance.client = object : Client { override fun executeRequest(request: Request): Response { // подсовываем валидный ответ val resp = """{"status":"OK"}""".toByteArray() return Response( request.url, body = DefaultBody.from( {ByteArrayInputStream(resp)}, {-1} ) ) } } Mockito.`when`(mAuthView.getToken()).thenReturn("") val mPresenter = AuthPresenter(mAuthView) mPresenter.tryLogIn("login", "password") // при валидном ответе должен быть вызван метод successLogin Mockito.verify(mAuthView).successLogin() }
Проверим сценарии авторизации. Для этого создадим тестовый класс для реализации объекта Repository, который в зависимости от логина/пароля будет возвращать валидный/не валидный ответ:
class DummyAPI(): API() {
override fun login(name: String, password: String): String {
return if(name=="alice" && password=="qwerty") """{"status":"OK","token":"123"}"""
else """{"status":"error","error":"user $name not found"}"""
}
}
И теперь напишем оставшиеся тесты для проверки сценариев авторизации:
@Test fun testSuccessAuth() { Factory.repository = DummyAPI() Mockito.`when`(mAuthView.getToken()).thenReturn("") val mPresenter = AuthPresenter(mAuthView) mPresenter.tryLogIn("alice", "qwerty") Mockito.verify(mAuthView).successLogin() } @Test fun testErrorAuth() { Factory.repository = DummyAPI() Mockito.`when`(mAuthView.getToken()).thenReturn("") val mPresenter = AuthPresenter(mAuthView) mPresenter.tryLogIn("bob", "12345") Mockito.verify(mAuthView).showLoginError(AuthPresenter.ERROR_AUTH) }
Таким образом, мы можем протестировать все возможные сценарии работы Presenter-а исключительно с помощью Unit-тестов и обрести определенную уверенность в том, что этот класс работает корректно.
Кроме того, с помощью Unit-тестов мы можем проверить и комплексную работу Presenter-а, к примеру, типичные сценарии взаимодействия пользователя с экраном авторизации. Какие это могут быть сценарии? Например, этот экран может открыть неавторизованный пользователь, который вначале введет неправильные данные, а после исправит ошибку и войдет в приложение. Мы можем протестировать полностью весь этот сценарий с точки зрения логики:
@Test fun testScreenScenario(){ Mockito.`when`(mAuthView.getToken()).thenReturn("") Factory.repository = DummyAPI() val mPresenter = AuthPresenter(mAuthView) // проверяется, что ни один метод еще не вызфывлся, // но в нашей реализации этого делать нельзя - getToken вызывается из конструктора // Mockito.verifyNoMoreInteractions(mAuthView) mPresenter.tryLogIn("login", "password") Mockito.verify(mAuthView).showLoading() Mockito.verify(mAuthView).hideLoading() Mockito.verify(mAuthView).showLoginError(AuthPresenter.ERROR_AUTH) mPresenter.tryLogIn("alice", "qwerty") Mockito.verify(mAuthView, times(2)).showLoading() Mockito.verify(mAuthView, times(2)).hideLoading() Mockito.verify(mAuthView).successLogin() }
Для улучшения понимания и закрепления материала еще раз проговорим, что происходит в этом тесте. Во-первых, мы мокаем поставщик локальных данных так, чтобы он возвращал пустой токен (это ситуация, когда пользователь не авторизован). В таком случае Presenter не должен ничего отдавать View.
Далее, мы подменяем репозиторий, чтобы корректно проверять сценарии авторизации. После этого пользователь вводит неправильные данные, и ему возвращается ошибка. Мы проверяем, что эта ошибка обрабатывается. Также мы проверяем, что во время выполнения запроса на авторизацию показывается и скрывается прогресс. И, наконец, мы проверяем корректный вход, чтобы убедиться, что Presenter корректно обрабатывает и такую ситуацию даже после первого ошибочного входа.
Такие тесты также весьма полезны, но лучше отдавать предпочтение небольшим тестам, которые легко понять и читать. И чем больше таких тестов будет в приложении, тем лучше для вашего приложения.
Рассмотренных примеров и средств при построении корректной архитектуры будет достаточно для большинства возникающих ситуаций. Но также существует еще несколько важных моментов, которые нужно рассмотреть подробнее. И в первую очередь это различные средства обеспечения замены репозитория и других объектов. Статический метод для установки значения работает хорошо, но существует немало альтернатив такому подходу, которые имеют свои преимущества и их нужно рассмотреть. Для реализации такой возможности используются принципы Inversion of Control (IoC) и Dependency Injection (DI).
Дальше идет текст из оригинальной статьи, на котлин не переведено
Dependency Injection
Если говорить совсем общими словами, то эти принципы IoC и DI позволяют уменьшить связность кода, не думать о том, как инициализировать объекты и подменять объекты во время работы приложения. Нас интересует в первую очередь последнее свойство, так как мы хотим подменить ответ сервера или же реализацию классов, выполняющих серверные запросы.
Существует много путей реализации IoC и DI в Android для описанной ситуации, более того, есть даже принципиальное различие в способах подмены результата:
- Подмена ответа сервера: использование тестового окружения или подстановка своего ответа через OkHttp (собственно в предыдущем разделе мы это и делали).
- Подмена моделей ответа: static setter, который мы уже рассмотрели, библиотеки для реализации DI и productFlavors в gradle (а это как раз пропустили).
Чтобы не усложнять и без того не очень простой материал текущей лекции, способы реализации DI и IoC будут подробно рассмотрены в рамках следующей лекции, а пока мы дадим их небольшое описание и обзор.
Первый из предложенных способов более актуален для проведения интеграционного и системного тестирования, когда мы хотим проверить работу приложения в целом и сделать так, чтобы изменение ответов выполнялось как можно “дальше” от самого приложения. Потому что при таком подходе мы можем проверить, как будет работать приложение для конечного пользователя, а не только в рамках тестового окружения.
Существует немало библиотек для Android, которые позволяют использовать принципы Dependency Injection. Это Guice, Dagger 2, недавно появившийся tiger и другие. Но на самом деле у них всех есть свои недостатки. Во-первых, некоторые из них, к примеру, Guice, замедляют работу приложения. Во-вторых, вспомните, что нам нужно только менять ответы сервера и почти для любой части приложения все элементы жестко зафиксированы. Поэтому использовать лишнюю библиотеку только для того, чтобы подменить ответы в нескольких местах – это не всегда разумно. Поэтому Dagger 2 будет рассмотрен только в рамках дополнительной лекции.
Но, вероятно, одним из самых удобных и мощных решений для реализации принципов IoC является использование product flavors из gradle плагина для Android, которые полностью поддерживаются в Android Studio.
Product flavors позволяют использовать разный код и разные ресурсы в зависимости от типа сборки. Мы можем сделать два различных product flavors (например, mock и prod). Во флаворе mock мы будем использовать тестовые ответы сервера или другие моки, и будем писать тесты с использованием этого флавора. Флавор prod может использовать реальный сервер, и мы будем проверять в нем работу приложения в рамках ручного тестирования.
Все средства для реализации DI мы обсудим во время изучения UI-тестирования, а пока нам достаточно простейшего способа с использованием статического метода для установки значения.
Измерение покрытия кода тестами
Также очень важно знать и уметь измерять, насколько полно и качественно вы тестируете свой код. Для этого существуют инструменты проверки процента покрытия кода тестами. Во-первых, это стандартный запуск тестов из Android Studio с измерения покрытия. Такой способ позволяет посмотреть, какой код был выполнен в ходе тестов, а какой остался нетронутым. Это позволяет очень удобно найти участки кода, которые не были протестированы, и написать тесты для них.
Во-вторых, есть более совершенные инструменты, такие как JaCoCo (Java Code Coverage), которые позволяют проверять процент покрытия кода тестами более детально. JaCoCo позволяет отслеживать покрытие различных веток развития программы, а не только обычное выполнение кода по строками.
Например, у нас есть следующий метод:
public int someMethod(boolean first, boolean second) { if (first || second) { return 1; } return 2; }
И тесты для него:
@Test public void testSomeMethodCondition() throws Exception { int result = someMethod(false, true); assertEquals(1, result); } @Test public void testSomeMethodDefault() throws Exception { int result = someMethod(false, false); assertEquals(2, result); }
Для таких тестов при измерении покрытия из Android Studio вы получите результат в 100% (так как в ходе тестов были выполнены хотя бы раз все строчки метода), а при измерении покрытия с помощью JaCoCo вы получите результат, что есть еще случаи, которые вы не протестировали (различные варианты использования булевских переменных). К сожалению, JaCoCo не указывает, какой именно участок вашего кода не протестирован, так как он работает на уровне байт-кода и не знает о вашем исходном коде. Но JaCoCo все равно является мощным инструментом, который нужно использовать в своих проектах.
Test Driven Development (TDD)
Test driven development – это методология разработки, в ходе которой разработчик вначале пишет тест, а уже после пишет код, который будет соответствовать этому тесту. У такого подхода есть свои преимущества:
- Поскольку вы сначала пишете тесты, вы не сможете оказаться в ситуации, когда код уже написан, а на написания тестов нет времени или возможностей (разумеется, если будете следовать методологии TDD).
- Мы обсуждали, что хорошая архитектура нужна для того, чтобы можно было писать тесты. Когда вы в первую очередь пишете тесты, вы вынуждены создавать архитектуру, которая позволит этим тестам выполняться.
- Очень часто разработчики пишут тесты таким образом, чтобы они соответствовали написанному коду, а не наоборот. Из-за этого тесты часто бывают бесполезны. Когда вы пишете код так, чтобы он соответствовал вашим тестам, вы более уверены в том, что тесты действительно проверяют корректность работы системы. Конечно, если вы умеете писать хорошие и полные тесты для ваших классов.
Процесс разработки через тестирование является полностью противоположным тому, как мы привыкли писать программы. Обычно мы пишем код и после уже тестируем его. С разработкой через тестирования все полностью наоборот.
Процесс разработки через тестирование состоит из следующих шагов:
- Создать тест, который не выполняется.
- Написать самый простой и примитивный код, который позволит тесту выполниться.
- Поскольку на втором шаге был написан, возможно, неудачный код, то теперь мы должны отрефакторить его. При этом во время рефакторинга нужно постоянно запускать тест, чтобы проверить, что мы ничего не сломали в ходе рефакторинга.
- После завершения такого процесса мы возвращаемся к пункту 1 и пишем новый тест.
Тогда структурную схему такого подхода можно представить в виде следующей диаграммы:
Когда вы следуете методологии TDD, ваши тесты являются спецификацией того, как должна вести себя система в итоговом варианте. Если вы можете написать тесты для всех пунктов вашей спецификации, то вы получите хорошую уверенность в том, что система работает как нужно.
Конечно, поначалу такая схема работы может занимать много лишнего времени, но ее стоит попробовать, так как в будущем это может помочь вам добиться высоких результатов, особенно если вы заботитесь о качестве своих программных продуктов.
содержание
What is a software product? The business code itself, right? Actually, that’s only a part of it. A software product consists of different elements:
- Business code
- Documentation
- CI/CD pipeline
- Communication rules
- Automation tests
Pure code is not enough anymore. Only if these parts are integrated into a solid system, can it be called a software product.
Tests are crucial in software development. Moreover, there is no separation between “application” and “tests” anymore. Not because the absence of the latter will result in an unmaintainable and non-functional product (though it’s definitely the case), rather due to the fact that tests guide architecture design and assure code testability.
Unit tests are only a fraction of the huge testing philosophy. There are dozens of different kinds of tests. Tests are the foundation of development these days. You can read my article about integration tests on Semaphore’s blog. But now it is time to deep dive into unit testing.
The code examples for this article are in Java, but the given rules are common for any programming language.
Table of contents
- What is unit testing
- Test-driven development
- Unit tests requirements
- The unit testing mindset
- Best practices
- Unit testing tools
What is unit testing
Every developer has experience in writing unit tests. We all know their purpose and what they look like. It can be hard, however, to give a strict definition for a unit test. The problem lies within the understanding of what a unit is. Let’s try to clarify that first.
A unit is an isolated piece of functionality.
Sounds reasonable. According to this definition, every unit test in the suite should cover a single unit.
Take a look at the schema below. The application consists of many modules, and each module has a number of units.

In this case, there are 6 units:
- UserService
- RoleService
- PostService
- CommentService
- UserRepo
- RoleRepo
According to the given schema, the unit can be defined in this way:
A unit is a class that can be tested in isolation from the whole system.
So, we can write tests for each specific unit, right? Well, this statement is both correct and incorrect, because units do not exist independently of one another. They have to interact with each other, or the application won’t work.
Then how can we write unit tests for something that cannot practically be isolated? We’ll get to this soon, but let’s make another point clear first.
Test Driven Development
Test Driven Development is the technical practice of writing tests before the business code. When I heard about it for the first time, I was confused. How can one write tests when there is nothing to test? Let’s see how it works.
TDD declares three steps:
- Write a test for the new functionality. It’s going to fail because you haven’t written the required business code yet.
- Add the minimum code to implement the feature.
- If the test passes, refactor the result and go back to the first step.
This lifecycle is called Red-Green-Refactor.
Some authors have proposed enhancements for the formula. You can find examples with 4 or even 5 steps, but the idea remains the same.
The problem of unit definition
Suppose we’re creating a blog where authors can write posts and users can leave comments. We want to build functionality for adding new comments. The required behaviour consists of the following points:
- User provides post id and comment content.
- If the post is absent, an exception is thrown.
- If comment content is longer than 300 characters, an exception is thrown.
- If all validations pass, the comment should be saved successfully.
Here is the possible Java implementation:
public class CommentService {
private final PostRepository postRepository;
private final CommentRepository commentRepository;
// constructor is omitted for brevity
public void addComment(long postId, String content) {
if (!postRepository.existsByid(postId)) {
throw new CommentAddingException("No post with id = " + postId);
}
if (content.length() > 300) {
throw new CommentAddingException("Too long comment: " + content.length());
}
commentRepository.save(new Comment(postId, content));
}
}
It’s not hard to test the content’s length. The problem is that CommentService relies on dependencies passed through the constructor. How should we test the class in this case? I cannot give you a single answer, because there are two schools of TDD. The Detroit School (classicist) and the London School (mockist). Each one declares the unit in a different way.
The Detroit School of TDD
If a classicist wanted to test the addComment method we described earlier, the service instantiation might look like this:
class CommentServiceTest {
@Test
void testWithStubs() {
CommentService service = new CommentService(
new StubPostRepository(),
new StubCommentRepository()
);
}
}
In this case, StubPostRepository and StubCommentRepository are implementations of the corresponding interfaces used for test cases. By the way, the Detroit School does not restrict applying to real business classes.
To summarize the idea, take a look at the schema below. The Detroit School declares the unit not as a separate class but a combination of ones. Different units can overlap.

There are many test suites that depend on the same implementations — StubPostRepository и StubCommentRepository.
So, the Detroit school followers would declare the unit in this way:
A unit is a class that can be tested in isolation from the whole system. Any external dependencies should be either replaced with stubs or real business objects.
The London School of TDD
A mockist, on the other hand, would test addComment differently.
class CommentServiceTest {
@Test
void testWithStubs() {
PostRepository postRepository = mock(PostRepository.class);
CommentRepository commentRepository = mock(CommentRepository.class);
CommentService service = new CommentService(
postRepository,
commentRepository
);
}
}
The London School defines a unit as a strongly isolated piece of code. Each mock is an implementation of the class’s dependency. Mocks should be unique for every test case.
A unit is a class that can be tested in isolation from the whole system. Any external dependencies should be mocked. No stubs are allowed to be reused. Applying real business objects is prohibited.
Take a look at the schema below to clarify the point.

Summary of unit definition
The Detroit School and the London school have arguments behind approaches they propose. These arguments are, however, beyond the scope of this article. For our purposes, I will apply both mocks and stubs.
So, it’s time to settle on our final unit definition. Take a look at the statement below.
A unit is a class that can be tested in isolation from the whole system. All external dependencies should be either mocked or replaced with stubs. No business objects should be involved in the process of testing.
We’re not involving external business objects in the single unit. Though the Detroit School of TDD allows for it, I consider this approach unstable. This is because business objects’ behaviour can evolve as the system grows. As they change, they might affect other parts of the code. There is, however, one exception from the rule: Value objects. These are data structures that encapsulate isolated pieces. For example, the Money value object consists of the amount and the currency. The FullName object can have the first name, the last name, and the patronymic. Those classes are plain data holders with no specific behaviour, so it’s OK to apply them directly in tests.
Now that we have a working definition of a unit, let’s move on to establishing how a unit test should be constructed. Each unit test has to follow a set of defined requirements. Take a look at the list below:
- Classes should not break the DI (dependency inversion) principle.
- Unit tests should not affect each other.
- Unit tests should be deterministic.
- Unit tests should not depend on any external state.
- Unit tests should run fast.
- All Tests Should Run in the CI Environment
Let’s clarify each point step by step.
Unit test requirements
Classes should not break the DI Principle
This one is the most obvious, but it’s worth mentioning because breaking this rule renders unit testing meaningless.
Take a look at the code snippet below:
public class CommentService {
private final PostRepository postRepository = new PostRepositoryImpl();
private final CommentRepository commentRepository = new CommentRepositoryImpl();
...
}
Even though CommentService declares external dependencies, they are bonded to PostRepositoryImpl and CommentRepositoryImpl. This makes it impossible to pass stubs/doubles/mocks to verify the class’s behaviour in isolation. This is why you should pass all dependencies through the constructor.
Unit tests should not affect each other
The philosophy of unit testing can be summed up in the following statement:
A user can run all unit tests either sequentially or in parallel. This should not affect the result of their execution. Why is that important? Suppose that you ran tests A and B and everything worked just fine. But the CI node ran test B and then test A. If the result of test B influences test A, it can lead to false negative behaviour. Such cases are tough to track and fix.
Suppose that we have the StubCommentRepository for testing purposes.
public class StubCommentRepository implements CommentRepository {
private final List<Comment> comments = new ArrayList<>();
@Override
public void save(Comment comment) {
comments.add(comment);
}
public List<Comment> getSaved() {
return comments;
}
public void deleteSaved() {
comments.clear();
}
}
If we passed the same instance of StubCommentRepository, would it guarantee that unit tests are not affecting each other? The answer is no. You see, StubCommentRepository is not thread-safe. It is probable that parallel tests won’t give the same results as sequential ones.
There are two ways to solve this issue:
- Make sure that each stub is thread-safe.
- Create a new stub/mock for every test case.
Unit tests should be deterministic
A unit test should depend only on input parameters but not on outer states (system time, number of CPUs, default encoding, etc.). Because there is no guarantee that every developer in the team has the same hardware setup. Suppose that you have 8 CPUs on your machine and a test makes an assumption regarding this. Your colleague with 16 CPUs will probably be irritated that the test is failing on their machine every time.
Let’s take a look at an example. Imagine that we want to test a util method that tells whether a provided date-time is morning or not. This is how our test might look:
class DateUtilTest {
@Test
void shouldBeMorning() {
OffsetDateTime now = OffsetDateTime.now();
assertTrue(DateUtil.isMorning(now));
}
}
This test is not deterministic by design. It will succeed only if the current system time is classified as morning.
The best practice here is to avoid declaring test data by calling non-pure functions. These include:
- Current date time.
- System timezone.
- Hardware parameters.
- Random numbers.
It should be mentioned that property-based testing provides similar data generation, although it works a bit differently. We’ll discuss it at the end of the article.
Unit tests should not depend on any external state
This means that every test run guarantees the same result within any environment. Trivial? Perhaps it is. Though the reality might be trickier. Let’s see what happens if your test relies on an external HTTP service always being available and returns the expected result each time.
Suppose we’re creating a service that provides a weather status. It accepts a URL where HTTP API calls are transmitted. Take a look at the code snippet below. It’s a simple test that checks that current weather status is always present.
class WeatherTest {
@Test
void shouldGetCurrentWeatherStatus() {
String apiRoot = "https://api.openweathermap.org";
Weather weather = new Weather(apiRoot);
WeatherStatus weatherStatus = weather.getCurrentStatus();
assertNotNull(weatherStatus);
}
}
The problem is that the external API might be unstable. We cannot guarantee that the outer service will always respond. Even if we did, there is still the possibility that the CI server running the build forbids HTTP requests. For example, there could be some firewall restrictions.
It is important that a unit test is a solid piece of code that doesn’t require any external services to run successfully.
All tests should run in the CI environment
Tests act preventatively. They should reject any code that does not pass the stated specifications. This means that code that does not pass its unit test should not be merged to the main branch.
Why is this important? Suppose that we merge branches with broken code. When release time comes we need to compile the main branch, build the artefacts, and proceed with the deployment pipeline, right? But remember that code is potentially broken. The production might go down. We could run tests manually before the release, but what if they fail? We would have to fix those bugs on the fly. This could result in a delayed release and customer dissatisfaction. Being sure that the main branch has been thoroughly tested means that we can deploy without fear.
The best way to achieve this is to integrate tests run in the CI environment. Semaphore does it brilliantly. The tool can also show each failed test run, so you don’t have to crawl into CI build logs to track down problems.
It should be stated here that all kinds of tests should be run in the CI environment, i.e. integration and E2E tests also.
Summary of unit test requirements
As you can see, unit tests are not as straightforward as they seem to be. This is because unit testing is not about assertions and error messages. Unit tests validate behaviour, not the fact that the mocks have been invoked with particular parameters.
How much effort should you put into tests? There is no universal answer, but when you write tests you should remember these points:
- A test is excellent code documentation. If you’re unaware of the system’s behaviour, the test can help you to understand the class’s purpose and API.
- There is a high chance that you’ll come back to the test later. If it’s poorly written, you’ll have to spend too much time figuring out what it actually does.
There is an even simpler formula for the stated points. Every time you’re writing a test, keep this quote in mind:
Tests are parts of code that do not have tests.
The unit testing mindset
What is the philosophy behind unit testing? I’ve mentioned the word behaviour several times throughout the article. In a nutshell, that is the answer. A unit test checks behaviour, but not direct function calls. This may sound a bit complicated, so let’s deconstruct the statement.
Refactoring stability
Imagine that you have done some minor code refactoring, and a bunch of your tests suddenly start failing. This is a maddening scenario. If there are no business logic changes, we don’t want to break our tests. Let’s clarify the point with a concrete example.
Let’s assume that a user can delete all the posts they have archived. Here is the possible Java implementation.
public class PostDeleteService {
private final UserService userService;
private final PostRepository postRepository;
public void deleteAllArchivedPosts() {
User currentUser = userService.getCurrentUser();
List<Post> posts = postRepository.findByPredicate(
PostPredicate.create()
.archived(true).and()
.createdBy(oneOf(currentUser))
);
postRepository.deleteAll(posts);
}
}
PostRepository is an interface that represents external storage. For example, it could be PostgreSQL or MySQL. PostPredicate is a custom predicate builder.
How can we test the method’s correctness? We could provide mocks for UserService and PostRepository and check the input parameters’ equity. Take a look at the example below:
public class PostDeleteServiceTest {
// initialization
@Test
void shouldDeletePostsSuccessfully() {
User mockUser = mock(User.class);
List<Post> mockPosts = mock(List.class);
when(userService.getCurrentUser()).thenReturn(mockUser);
when(postRepository.findByPredicate(
eq(PostPredicate.create()
.archived(true).and()
.createdBy(oneOf(mockUser)))
)).thenReturn(mockPosts);
postDeleteService.deleteAllArchivedPosts();
verify(postRepository, times(1)).deleteAll(mockPosts);
}
}
The when, thenReturn, and eq methods are part of the Mockito Java library. We’ll talk more about various testing libraries at the end of the article.
Do we test behaviour here? Actually, we don’t. There is no testing, rather we are verifying the order of methods called. The problem is that the unit test does not tolerate refactoring of the code it is testing.
Imagine that we decided to replace oneOf(user) with is(user) predicate usage. An example could look like this:
public class PostDeleteService {
private final UserService userService;
private final PostRepository postRepository;
public void deleteAllArchivedPosts() {
User currentUser = userService.getCurrentUser();
List<Post> posts = postRepository.findByPredicate(
PostPredicate.create()
.archived(true).and()
// replaced 'oneOf' with 'is'
.createdBy(is(currentUser))
);
postRepository.deleteAll(posts);
}
}
This should not make any difference, right? The refactoring hasn’t changed the business logic at all. But the test is going to fail now, because of this mocking setup.
public class PostDeleteServiceTest {
// initialization
@Test
void shouldDeletePostsSuccessfully() {
// setup
when(postRepository.findByPredicate(
eq(PostPredicate.create()
.archived(true).and()
// 'oneOf' but not 'is'
.createdBy(oneOf(mockUser)))
)).thenReturn(mockPosts);
// action
}
}
Every time we do even a slight refactoring, the test fails. That makes maintenance a big burden. Imagine what might go wrong if we made major changes. For example, if we added the postRepository.deleteAllByPredicate method, it would break the whole test setup.
This is happening because the previous examples are focusing on the wrong thing. We want to test behaviour. Let’s see how we can make a new test that will do that. First, we need to declare a custom PostRepository implementation for test purposes. It’s OK to store data in RAM, what’s important is PostPredicate recognition. Therefore, the calling method relies on the fact that predicates are treated correctly.
Here’s the refactored version of the test:
public class PostDeleteServiceTest {
// initialization
@Test
void shouldDeletePostsSuccessfully() {
User currentUser = aUser().name("n1");
User anotherUser = aUser().name("n2");
when(userService.getCurrentUser()).thenReturn(currentUser);
testPostRepository.store(
aPost().withUser(currentUser).archived(true),
aPost().withUser(currentUser).archived(true),
aPost().withUser(anotherUser).archived(true)
);
postDeleteService.deleteAllArchivedPosts();
assertEquals(1, testPostRepository.count());
}
}
Here is what changed:
- There is no PostRepository mocking. We introduced a custom implementation: TestPostRepository. It encapsulates the stored posts and guarantees the correct PostPredicate processing.
- Instead of declaring PostRepository returning a list of posts, we put the real objects within TestPostRepository.
- We don’t care about which functions have been called. We want to validate the delete operation itself. We know that the storage consists of 2 archived posts of the current user and 1 post of another user. The successful operation process should leave 1 post. That’s why we put assertEquals on posts count.
Now the test is isolated from the specific method invocations checks. We care only about the correctness of the TestPostRepository implementation itself. It doesn’t matter exactly how PostDeleteService implements the business case. It’s not about “how”, it’s about “what” a unit does. Furthermore, this refactoring won’t break the test.
You might also notice that UserService is still a regular mock. That’s fine because the probability of the getCurrentUser() method substitution is not significant. Besides, the method has no parameters. This means that we don’t have to deal with a possible input parameter mismatch. Mocks aren’t good or bad, just keep in mind that different tasks require different tools.
A few words about MVC frameworks
The vast majority of applications and services are developed using an MVC framework. Spring Boot is the most popular one for Java. Even though many authors claim that your design architecture should not depend on a framework (e.g. Robert Martin), the reality is not so simple. Nowadays, many projects are “framework-oriented”. It’s hard or even impossible to replace one framework with another. This, of course, influences test design as well.
I do not fully agree with the notion that your code should be “totally isolated from frameworks”. In my opinion, depending on a framework’s features to reduce boilerplate and focus on business logic is not a big deal. But that is an extensive debate that is outside of the scope of this article.
What is important to remember is that your business code should be abstracted from the framework’s architecture. This means that any class should be unaware of the environment in which the developer has installed it. Otherwise, your tests become too coupled in unnecessary details. If you decide to switch from one framework to another at some point, it would be a Herculean task. The required time and effort to do it would be unacceptable for any company.
Let’s move on to an example. Assume we have an XML generator. Each element has a unique integer ID and we have a service that generates those IDs. But what if the number of generated XMLs is huge? If every element in every XML document had a unique integer id, it could lead to integer overflow. Let’s imagine that we are using Spring in our project. To overcome this issue we decided to declare IDService with the prototype scope. So, XMLService should receive a new instance of IDService every time a generator is triggered. Take a look at the example below:
@Service
public class XMLGenerator {
@Autowired
private IDService idService;
public XML generateXML(String rawData) {
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
The problem here is that XMLGenerator is a singleton (the default Spring bean scope). Therefore, it instantiated 1s and IDService is not refreshed.
We could fix that by injecting ApplicationContext and requesting the bean directly.
@Service
public class XMLGenerator {
@Autowired
private ApplicationContext context;
public XML generateXML(String rawData) {
// Creates new IDService instance
IDService idService = context.getBean(IDService.class);
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
But here is the thing: now the class is bound to the Spring ecosystem. XMLGenerator understands that there is a DI-container, and it’s possible to retrieve a class instance from it. In this case, unit testing becomes harder. Because you cannot test XMLGenerator outside of the Spring context.
The better approach is to declare an IDServiceFactory, as shown below:
@Service
public class XMLGenerator {
@Autowired
private IDServiceFactory factory;
public XML generateXML(String rawData) {
IDService idService = factory.getInstance();
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
That’s better. IDServiceFactory encapsulates the logic of retrieving the IDService instance. IDServiceFactory is injected into the class field directly. Spring can do it. But what if there is no Spring? Could you do this with the plain unit test? Well, technically it’s possible. The Java Reflection API allows you to modify private fields’ values. I’m not going to discuss this at length, but I’ll just say: never use Reflection API in your tests! It’s an absolute anti-pattern.
There is one exception. If your business code does work with Reflection API, then it’s OK to apply reflection in tests as well.
Let’s get back to DI. There are 3 approaches to implement dependency injection:
- Field injection
- Setter injection
- Constructor injection
The second and the third approach do not share the problems of the first one. We can apply either of them. Both will work. Take a look at the code example below:
@Service
public class XMLGenerator {
private final IDServiceFactory factory;
public XMLGenerator(IDServiceFactory factory) {
this.factory = factory;
}
public XML generateXML(String rawData) {
IDService idService = factory.getInstance();
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
Now the XMLGenerator is completely isolated from the framework details.
Unit testing mindset summary
- Test what the code does but not how it does it.
- Code refactoring should not break tests.
- Isolate the code from the frameworks’ details.
Best practices
Now we can discuss some best practices to help you increase the quality of your unit tests.
Naming
Most IDEs generate a test suite name by adding the Test suffix to the class name. PostServiceTest, WeatherTest, CommentControllerTest, etc. Sometimes this might be sufficient, but I think that there are some issues with this approach:
- The type of test is not self-describing (unit, integration, e2e).
- You cannot tell which methods are tested.
In my opinion, the better way is to enhance the simple Test suffix:
- Specify the particular type of test. This will help us to clarify the test borders. For example, you might have multiple test suites for the same class (PostServiceUnitTest , PostServiceIntegrationTest , PostServiceE2ETest).
- Add the name of the method that is being tested. For example, WeatherUnitTest_getCurrentStatus. Or CommentControllerE2ETest_createComment.
The second point is debatable. Some developers claim that every class should be as solid as possible, and distinguishing tests by the method name can lead to treating classes as dummy data structures.
These arguments do make sense, but I think that putting the method name also provides advantages:
- Not all classes are solid. Even if you’re the biggest fan of Domain-Driven Design, it is impossible to build every class this way.
- Some methods are more complicated than others. You might have 10 test methods just to verify the behaviour of a single class method. If you put all tests inside one test suite, you will make it huge and difficult to maintain.
You could also apply different naming strategies according to specific context. There is no single right way to go about this, but remember that naming is an important maintainability feature. It’s a good practice to choose one strategy to share within your team.
Assertions
I’ve heard it stated that each test should have a single assertion. If you have more, then it’s better to split it into multiple suites.
I’m not generally fond of edge opinions. This one does make sense, but I would rephrase it a bit.
Each test case should assert a single business case.
It’s OK to have multiple assertions, but make sure that they clarify a solid operation. For example, look at the code example below:
public class PersonServiceTest {
// initialization
@Test
void shouldCreatePersonSuccessfully() {
Person person = personService.createNew("firstName", "lastName");
assertEquals("firstName", person.getFirstName());
assertEquals("lastName", person.getLastName());
}
}
Even though there are two assertions, they are bound to the same business context (i.e. creating a new Person).
Now check out this code snippet:
class WeatherTest {
// initialization
@Test
void shouldGetCurrentWeatherStatus() {
LocalDate date = LocalDate.of(2012, 5, 25);
WeatherStatus testWeatherStatus = generateStatus();
tuneWeather(date, testWeatherStatus);
WeatherStatus result = weather.getStatusForDate(date);
assertEquals(
testWeatherStatus,
result,
"Unexpected weather status for date " + date
);
assertEquals(
result,
weather.getStatusForDate(date),
"Weather service is not idempotent for date " + date
);
}
}
These two assertions do not build a solid piece of code. We’re testing the result of getStatusForDate and the fact that the function call is idempotent. It’s better to split this suite into two tests because the two things being tested aren’t directly linked.
Error messages
Tests can fail. That’s the whole idea of testing. If your suite is red, what should you do? Fix the code? But how should you do it? What’s the source of the problem? If an assertion fails, we get an error log that tells us what went wrong, right? Indeed, it’s true. Sadly, those messages aren’t always useful. How you write your tests can determine what kind of feedback you get in the error log.
Take a look at the code example below:
class WeatherTest {
// initialization
@ParameterizedTest
@MethodSource("weatherDates")
void shouldGetCurrentWeatherStatus(LocalDate date) {
WeatherStatus testWeatherStatus = generateStatus();
tuneWeather(date, testWeatherStatus);
WeatherStatus result = weather.getStatusForDate(date);
assertEquals(testWeatherStatus, result);
}
}
Suppose that weatherDates provide 20 different date values. As a matter of fact, there are 20 tests. One test failed and here is what you got as the error message.
expected: <SHINY> but was: <CLOUDY>
Expected :SHINY
Actual :CLOUDY
Not so descriptive, is it? 19/20 tests have succeeded. There must be some problem with a date, but the error message didn’t give much detail. Can we rewrite the test so that we get more feedback on failure? Of course! Take a look at the code snippet below:
class WeatherTest {
// initialization
@ParameterizedTest
@MethodSource("weatherDates")
void shouldGetCurrentWeatherStatus(LocalDate date) {
WeatherStatus testWeatherStatus = generateStatus();
tuneWeather(date, testWeatherStatus);
WeatherStatus result = weather.getStatusForDate(date);
assertEquals(
testWeatherStatus,
result,
"Unexpected weather status for date " + date
);
}
}
Now the error message is much clearer.
Unexpected weather status for date 2022-03-12 ==> expected: <SHINY> but was: <CLOUDY>
Expected :SHINY
Actual :CLOUDY
It’s obvious that something is wrong with the date: 2022-03-12. This error log gives us a clue as to where we should start our investigation.
Also, pay attention to the toString implementation. When you pass an object to assertEquals, the library transforms it into a string using this method.
Test data initialization
When we test anything, we probably need some data to test against it (e.g. rows in the database, objects, variables, etc.). There are 3 known ways to initialise data in a test:
- Direct Declaration.
- Object Mother Pattern.
- Test Data Builder Pattern .
Direct Declaration
Suppose that we have the Post class. Take a look at the code snippet below:
public class Post {
private Long id;
private String name;
private User userWhoCreated;
private List<Comment> comments;
// constructor, getters, setters
}
We can create new instances with constructors:
public class PostTest {
@Test
void someTest() {
Post post = new Post(
1,
"Java for beginners",
new User("Jack", "Brown"),
List.of(new Comment(1, "Some comment"))
);
// action...
}
}
There are, however, some problems with this approach:
- Parameter names are not descriptive. You have to check the constructor’s declaration to tell the meaning of each provided value.
- Class attributes are not static. What if another field were added? You would have to fix every constructor invocation in every test.
What about setters? Let’s see how that would look:
public class PostTest {
@Test
void someTest() {
Post post = new Post();
post.setId(1);
post.setName("Java for beginners");
User user = new User();
user.setFirstName("Jack");
user.setLastName("Brown");
post.setUser(user);
Comment comment = new Comment();
comment.setId(1);
comment.setTitle("Some comment");
post.setComments(List.of(comment));
// action...
}
}
Now the parameter names are transparent, but other issues have appeared.
- The declaration is too verbose. At first glance, it’s hard to tell what’s going on.
- Some parameters might be obligatory. If we added another field to the Post class, it could lead to runtime exceptions due to the object’s inconsistency.
We need a different approach to solve these problems.
Object Mother Pattern
In reality, it’s just a simple static factory that hides the instantiation complexity behind a nice facade. Take a look at the code example below:
public class PostFactory {
public static Post createSimplePost() {
// simple post logic
}
public static Post createPostWithUser(User user) {
// simple post logic
}
}
This works for simple cases. But the Post class has many invariants (e.g. post with comment, post with comment and user, post with user, post with user and multiple comments, etc.). If we tried to declare a separate method for every possible situation, it would quickly turn into a mess. Enter Test Data Builder.
Test Data Builder Pattern
The name defines its purpose. It’s a builder created specifically to test data declarations. Let’s see what it looks like in the form of a test:
public class PostTest {
@Test
void someTest() {
Post post = aPost()
.id(1)
.name("Java for beginners")
.user(aUser().firstName("Jack").lastName("Brown"))
.comments(List.of(
aComment().id(1).title("Some comment")
))
.build();
// action...
}
}
aPost(), aUser(), and aComment() are static methods that create builders for the corresponding classes. They encapsulate the default values for all attributes. Calling id, name, and other methods overrides the values. You can also enhance the default builder-pattern approach and make them immutable, making every attribute change return a new builder instance. It’s also helpful to declare templates to reduce boilerplate.
public class PostTest {
private PostBuilder defaultPost =
aPost().name("post1").comments(List.of(aComment()));
@Test
void someTest() {
Post postWithNoComments = defaultPost.comments(emptyList()).build();
Post postWithDifferentName = defaultPost.name("another name").build();
// action...
}
}
If you want to really dive into this, I wrote a whole article about declaring test data in a clean way. You can read it here.
Best practices summary
- Naming is important. Test suite names should be declarative enough to understand their purpose.
- Do not group assertions that have nothing in common.
- Specific error messages are the key to quick bug spotting.
- Test data initialization is important. Do not neglect this.
But the main thing you should remember about testing is:
Tests should help to write code, but not increase the burden of maintenance.
There are dozens of testing libraries and frameworks on the market. I’m going to list the most popular ones for the Java language.
JUnit
The de facto standard for Java. Used in most projects. It provides a test running engine and assertion library combined in one artefact.
Mockito
The most popular mocking library for Java. It provides a friendly fluent API to set testing mocks. Take a look at the example below:
public class SomeSuite {
@Test
void someTest() {
// creates a mock for CommentService
CommentService mockService = mock(CommentService.class);
// when mockService.getCommentById(1) is called, new Comment instance is returned
when(mockService.getCommentById(eq(1)))
.thenReturn(new Comment());
// when mockService.getCommentById(2) is called, NoSuchElementException is thrown
when(mockService.getCommentById(eq(2)))
.thenThrow(new NoSuchElementException());
}
}
Spock
As the documentation says, this is an enterprise-ready specification framework. In a nutshell, it has a test runner, and assertion and mocking utils. You write Spock tests in Groovy instead of Java. Here is a simple case validating that 2 + 2 = 4:
def "two plus two should equal four"() {
given:
int left = 2
int right = 2
when:
int result = left + right
then:
result == 4
}
Vavr Test
Vavr Test requires special attention. This one is a property testing library. It differs from regular assertion-based tools. Vavr Test provides an input value generator. For each generated value it checks the invariant result. If this is false, the amount of test data is reduced until only the failures remain. Take a look at the example below that checks that whether the isEven function is working correctly:
public class SomeSuite {
@Test
void someTest() {
Arbitrary<Integer> evenNumbers = Arbitrary.integer()
.filter(i -> i > 0)
.filter(i -> i % 2 == 0);
CheckedFunction1<Integer, Boolean> alwaysEven =
i -> isEven(i);
CheckResult result = Property
.def("All numbers must be treated as even ones")
.forAll(evenNumbers)
.suchThat(alwaysEven)
.check();
result.assertIsSatisfied();
}
}
Conclusion
Testing is a significant part of software development, and unit tests are fundamental. They represent the basis for all kinds of automation tests, so it’s crucial to write unit tests to the highest standard of quality.
The biggest advantage of tests is that they can run without manual interactions. Be sure that you run tests in the CI environment on each change in a pull request. If you don’t do this, the quality of your project will suffer. Semaphore CI is a brilliant CI/CD tool for automating and running tests and deployments, so give it a try.
That’s that! If you have any questions or suggestions, you can text me or leave your comments here. Thanks for reading!
Unit testing — один из обязательных инструментов в арсенале любого уважающего себя разработчика ПО, желающего сделать код более надежным и простым в обслуживании. Не каждый программист им пользуется ввиду отсутствия фундаментальных знаний о самом процессе тестирования и его методах.
Помогаем
В этой статье детально рассмотрим данный вид тестирования, а также разберемся с грамотным написанием модульных кейсов.
Содержание:
1. Что такое модульное тестирование?
2. Модульный тест против интеграционного теста
3. Что делает хороший модульный тест?
4. Почему именно модульное тестирование?
5. Как проводить модульное тестирование
6. Тестирование с помощью пирамиды Майка Кона
7. Методы unit-тестирования
8. Инструменты
9. Разработка через тестирование (TDD)
10. Преимущество модульного тестирования
11. Недостатки модульного тестирования
Заключение
1. Что такое модульное тестирование?
Модульное тестирование — это метод тестирования программного обеспечения при котором создаются модули, то есть небольшие части приложения, поведение каждого из которых проверяется отдельно. Модульное тестирование выполняется на этапе разработки приложения. Модулем может быть что угодно, например, процедура или функция.
Модульное тестирование состоит из трех этапов:
Ставайте досвідченим фахівцем з фінансів на рівні директора!
РЕЄСТРУЙТЕСЯ!
- Во-первых, инициализация небольшого фрагмента приложения, которое вы хотите протестировать.
- Для этого вызывается метод, применяемый в качестве стимула к тестируемой системе.
- Последний этап: наблюдение за поведением проверяемого модуля. Если наблюдаемое поведение соответствует ожиданиям пользователя, то модульный тест проходит. Этот пошаговый процесс также называется AAA (Arrange, Act, Assert).
Модульный тест бывает двух видов: на основе состояния, а также на основе взаимодействия. Если вы отслеживаете полученное состояние — это модульное тестирование на основе состояния.
Если тестирование происходит при использовании определенных методов — это модульное тестирование на основе взаимодействия. Пользователи часто путают модульное и интеграционное тестирование. Прежде чем продолжить обзор важно понять эту разницу.
2. Модульный тест против интеграционного теста
Целью модульного тестирования является проверка поведения каждой части программного обеспечения, независимо от других частей. Модульные тесты имеют более узкую область применения, позволяют нам охватить все случаи и гарантировать, что каждый отдельно взятый участок работает безупречно. Такое тестирование довольно легко реализовать.
С другой стороны, интеграционное тестирование подтверждает, что различные части системы нормально работают совместно в реальной среде. Это высокоуровневое тестирование, проверяющее сложные сценарии. Обычно для этого требуются внешние ресурсы, такие как веб-серверы и базы данных. Интеграционное тестирование применяют при взаимодействии между различными компонентами в условиях максимально близких к реальной среде (при помощи дополнительных инструментов).
Когда эти два вида тестирования объединяются — мы получаем высокий уровень уверенности в том, что вся система работает должным образом. Однако всегда нужно помнить о том, какой тест мы реализуем: модульный или интеграционный.
Бывают случаи, когда модульные тесты требуют наличия внешних ресурсов, таких как веб-серверы или база данных. В большинстве случаев причина кроется в плохом дизайне модульного теста. Давайте узнаем, как сделать хороший модульный тест, чтобы избежать подобных проблем.
3. Что делает хороший модульный тест?
Модульные тесты применяются для проверки различных аспектов приложения, не затрачивая много времени и усилий со стороны разработчиков.
Такие тесты:
- Работают, даже если в тестируемой системе есть ошибка. На хорошие модульные тесты не будут влиять внешние факторы, такие как окружающая среда или порядок выполнения. Если это произойдет, то это определенно недостаток дизайна.
- Забегая вперед, скажу, что хороший модульный тест ясен и рассказывает историю о поведенческом аспекте приложения. С помощью тестовых примеров легко понять, какой сценарий был протестирован. Если тест не проходит, то исправить ошибки можно быстро и без отладки кода.
- Модульные тесты написаны таким образом, что их можно использовать многократно.
Unit-тесты и тестируемая система не должны обращаться к сетевым ресурсам, файловым системам и базам данных, чтобы исключить общее влияние внешних факторов, что делает такой тест действительно модульным, а не интеграционным. Они просты в написании, и их легко поддерживать.
4. Почему именно модульное тестирование?
Иногда разработчики пропускают unit-тестирование из-за нехватки времени, не понимая, к чему это может привести. Это влечет за собой увеличение затрат на исправление дефектов во время интеграции, на бета-тестирование и последующее тестирование системы. Правильное модульное тестирование во время разработки приложения экономит время и деньги. Вот основные его преимущества:
- Вы сможете исправить дефекты на ранней стадии разработки, сэкономив ресурсы.
- Это помогает разработчику быстро разобраться с кодовой базой и быстро внести необходимые изменения.
- Хорошие модульные тесты обычно служат проектной документацией.
- Модульные тесты можно использовать повторно и при необходимости быстро перенести в новый проект. Вам всего лишь следует немного подправить код, чтобы он снова запустился.
5. Как проводить модульное тестирование
Модульное тестирование делится на ручное и автоматическое. Автоматизация модульных тестов — хорошая практика, ее также можно выполнять вручную. При ручном подходе используется документ с пошаговыми инструкциями. Перечислим по пунктам все составляющие автоматизированного подхода:
- Разработчик пишет модульные тесты, чтобы проверить функциональность конкретной части приложения. Они закомментированы и будут удалены позже, после успешного развертывания приложения.
- Функция должна быть изолирована, чтобы ее можно было проверить более тщательно. Лучшая практика unit-тестирования — копировать и вставлять код в тестовую среду, вместо работы в естественной среде. Изолированный код помогает выявить и устранить зависимости между тестируемым кодом и пространствами данных.
- Существует среда модульного тестирования для разработки автоматизированных тестовых случаев. Эта среда автоматизации помогает писать код и проверяет, правильно ли написан код. Во время выполнения модульных тестов платформа регистрирует статус тестовых случаев. В зависимости от серьезности сбоев структура может остановить последующее тестирование.
- Рабочий процесс модульного тестирования разделен на четыре категории: это создание тестовых примеров, обзор, базовый уровень и выполнение тестовых примеров.
6. Тестирование с помощью пирамиды Майка Кона
Ни один разговор о тестировании не обходится без упоминания пирамиды тестов, подробно описанную разработчиком Майклом Коном в его книге «Scrum: гибкая разработка ПО»:
Источник: leeorengel.com
Пирамида должна определять ваш подход к написанию тестов. Она показывает вам, на чем можно сосредоточить свое время. Наиболее важные выводы из диаграммы:
- Чем выше вы поднимаетесь по пирамиде, тем медленнее проходят тесты. Это приводит к увеличению продолжительности цикла обратной связи. Более длительное время цикла обратной связи приводит к увеличению времени на отслеживание причины сбоев, поскольку они обычно происходят дальше от источника реальной проблемы.
- Чем выше вы поднимаетесь по пирамиде, тем дороже становится писать и поддерживать тесты. По мере продвижения вверх по пирамиде элементы тестируемой системы и их взаимодействие становятся все более сложными.
- Наличие большего количества тестов на более низком уровне является желательной целью. Это очень желательно, но не всегда возможно. Напишите тест там, где он действительно подходит. Подробнее об этом ниже.
Определения тестов
Что касается типов тестов на каждом уровне пирамиды, то существуют разные мнения о том, что включает в себя каждый тип.
- Модульные тесты — как мы уже с вами разобрали, тестируют наименьшую единицу кода — обычно функцию, класс или структуру данных. Самым важным аспектом модульных тестов является их скорость.
- Компонентные тесты — это по сути интеграционные тесты. Их цель — выделить правильную функцию отдельного компонента.
- Интеграционные тесты обычно включают в себя тестирование взаимодействия между двумя или более компонентами системы.
- Системные тесты — служат для тестирования экземпляра (или части) вашей реальной системы. Обычно они соответствуют конфигурации тестовой среды вашей системы. Эти тесты могут быть дорогостоящими для начальной загрузки и медленными для выполнения.
- Manual или браузерные тесты — это тип системных тестов, ориентирующихся на пожеланиях конечного пользователя. Их следует ограничить сценариями тестирования, охватывающими многоэтапные или многостраничные рабочие процессы, которые невозможно эффективно протестировать другими способами.
7. Методы unit-тестирования
Ниже приведены методы покрытия кода при модульном тестировании:
- Тестирование белого ящика — с помощью пользовательского интерфейса проверяются ввод и вывод.
- Тестирование черного ящика — помогает проверить поведение различных функций, используемых в приложении.
- Тестирование серого ящика — используется для выполнения тестов, рисков и методов оценки.
8.Инструменты
Существует множество автоматизированных инструментов, помогающих при модульном тестировании. Здесь, для примера, рассмотрим самые популярные из них.
- Jtest — это плагин IDE, использующий фреймворки с открытым исходным кодом, с управляемыми и простыми действиями в один щелчок — для создания, масштабирования и поддержки модульных тестов. Автоматизация этих трудоемких аспектов модульного тестирования позволяет разработчикам больше сосредоточиться на бизнес-логике и создавать более мощные наборы тестов.
- Junit — бесплатный инструмент для тестирования, основанный на языке программирования Java. Он предоставляет утверждения для определения различных методов тестирования и проверяет данные, прежде чем вставлять их в фрагмент кода.
- NUnit — широко используемая среда тестирования, позволяющая писать скрипты вручную. Поддерживает параллельное выполнение тестов.
- JMockit — снова инструмент тестирования с открытым исходным кодом, позволяющий имитировать API с проверкой и записью синтаксиса.
- EMMA — это набор инструментов с открытым исходным кодом для анализа и составления отчетов по коду, написанному на языке Java. Предназначен для тестирования методов, строк и базовых блоков, он может обращаться к коду без какой-либо внешней библиотеки.
- PHPUnit — это популярный инструмент тестирования для программистов PHP. Инструмент позволяет разработчикам использовать предопределенные методы утверждения, чтобы убедиться, что система ведет себя определенным образом.
Это всего лишь несколько инструментов unit-тестирования, пользующихся большой популярностью на рынке технологий. Их гораздо больше, и вы можете выбрать любой из них в зависимости от ваших потребностей, требований и вкусов.
9. Разработка через тестирование (TDD)
Unit-testing при разработке предполагает широкое использование специальных фреймворков. Вот несколько фактов о том, что TDD привносит в мир модульного тестинга:
- Модульные тесты часто пишутся до самого кода.
- Применение фреймворков тестирования для написания или автоматизации модульных тестов.
- Используется для тестирования всех классов в приложении.
- Легкая и быстрая интеграция.
10. Преимущества модульного тестирования
- Модульное тестирование упрощает изменение и поддержку кода. Когда написаны хорошие модульные тесты, они могут выявлять проблемы каждый раз, когда код запускается или изменяется.
- Unit-тесты можно использовать повторно.
- Модульное тестирование ускоряет разработку. Все, что вам нужно — запустить графический интерфейс и предоставить все необходимые входные данные.
- Модульные тесты более надежны и в долгосрочной перспективе выполняются быстрее. Усилия, прилагаемые для написания и исправления дефектов во время модульного тестирования, намного меньше по сравнению с усилиями, необходимыми для исправления ошибок во время тестирования системы или приемочного тестирования.
- Менее затратное по времени и другим ресурсам.
- Модульное тестирование упрощает отладку. Если тест не проходит, последние изменения необходимо снова отладить. Тестирование на более высоких уровнях позволяет сканировать изменения, внесенные за несколько дней, недель, месяцев и т. д.
- Такой подход улучшает дизайн кода и позволяет проводить его рефакторинг. В процессе написания тестовых примеров для методов или функций всякий раз, когда изменения вызывают ошибку, ее можно быстро идентифицировать или при необходимости исправить.
- Модульные тесты при интеграции также дают равенство сборки.
- Разработчики могут понять, какие функции выполняет конкретный модуль, и взглянуть на модульные тесты, чтобы получить базовое представление об API.
- Поскольку unit-тесты являются модульными, можно тестировать выбранную часть кода, не дожидаясь завершения другой.
11. Недостатки модульного тестирования
- Модульное тестирование не выявляет всех ошибок в программе.
- Не может оценить все пути выполнения, даже в тривиальных программах.
- В основном фокусируется на единицах измерения и не может выявлять ошибки интеграции на более широком уровне.
Общее правило таково: unit-тестирование следует выполнять в сочетании с другими тестами, чтобы получить более точные результаты.
Правила работы с unit-тестами:
- Каждый модуль должен быть независимым. Любое изменение в нем не должно влиять на другие юниты.
- Используется для одновременной проверки только одного кода.
- Вы всегда должны использовать четкие и последовательные соглашения об именах.
- Для каждого модуля должен быть отдельный тестовый пример перед отправкой на реализацию.
- Ошибки следует исправить заранее, прежде чем переходить к следующему этапу в SDLC (System/Software Development Life Cycle).
- Чем больше кода вы напишете, тем больше у вас будет путей для проверки на наличие ошибок.
Заключение
Unit-тесты могут быть простыми или сложными в зависимости от характера тестируемого объекта, инструментов и стратегий, используемых для тестирования. Здесь можно быть уверенным только в одном: модульное тестирование сделает вашу программу более надежной и функциональной по сравнению с непроверенными приложениями.
Несколько полезных видеороликов по теме:
Содержание
- Что такое модульный тест
- Зачем утруждать себя написанием модульных тестов
- Обнаруживайте ошибки как можно раньше
- Документация
- Краткое введение в разработку на основе тестирования (TDD)
- Практика написания модульных тестов
- Пишем осмысленное имя теста
- Каждый тест должен охватывать только 1 сценарий
- Используйте шаблон AAA
- Изолируйте свой юнит от внешних зависимостей
- Избегайте тестирования детализированной реализации
- Работа над устаревшим кодом: должен ли я сначала отрефакторить или написать тест?
- Вывод
Что такое модульный тест
Модульный тест – это категория тестов с самой высокой степенью детализации, основанная на тестовой пирамиде. Обычно он ориентирован на функциональность класса, функции или компонента пользовательского интерфейса и изолирован от внешней системы, такой как базы данных и сторонние API.
Зачем утруждать себя написанием модульных тестов
В большинстве случаев задача написания/рефакторинга кода заключается в том, чтобы убедиться, что вы не нарушаете существующую функциональность. Раньше разработчику нужно было проверить измененный класс/функцию вручную, чтобы убедиться, что ничего не сломалось. Ручная работа подвержена ошибкам. Разработчики могут забыть некоторые тестовые случаи, и код с багами отправляется в продакшен. Наличие модульных тестов и их правильная настройка на CI избавит вас от таких сценариев. Следовательно, это повысит вашу уверенность в CD до продакшена.
Обнаруживайте ошибки как можно раньше
Модульные тесты пишутся (и должны быть написаны) изолированно, поэтому их можно выполнять без необходимости разворачивать внешние службы и запускать такие инструменты, как puppeteer. Он может работать быстро и гораздо менее требователен к памяти по сравнению с сквозными тестами. Это уникальное свойство модульных тестов позволяет разработчикам выполнять тесты столько, сколько необходимо в процессе разработки.
Некоторые тестранеры, такие как jest, предоставляют возможность наблюдать за запуском тестов каждый раз, когда в код вносятся изменения, что еще больше облегчает обнаружение ошибок во время разработки.
Документация
Тщательно написанные тесты могут выступать в качестве документации, поскольку они описывают желаемое поведение конкретной части программного обеспечения. Я также нахожу, что тесты очень полезны во время процесса проверки кода. Они дают рекомендации по поведению программного обеспечения и избавляют от необходимости подробно разбираться в деталях реализации, чтобы понять его функциональность.
Краткое введение в разработку на основе тестирования (TDD)
Разговор о модульных тестах не будет полным без упоминания Test-Driven Development (TDD). В двух словах TDD можно охарактеризовать как красно-зеленый-рефакторинг подход к разработке программного обеспечения.
- Вы начинаете с написания одного теста, чтобы охватить одно требование. Тест должен быть провален, так как у вас нет работающей реализации системы (красный).
- Вы пишете реализацию, чтобы она прошла тест (зеленый).
- Отрефакторите свой код (если это необходимо).
- Переходите к следующему требованию и возвращайтесь к шагу 1
Ключевой вывод из TDD заключается в том, чтобы позволить тестам управлять вашей архитектурой, а не наоборот.
Практика написания модульных тестов
Я буду использовать образец, написанный на javascript + jest, так как это язык и тестовый фреймворк, с которыми мне наиболее комфортно. В качестве примера мы используем класс dateFormatter со следующей спецификацией:
- Этот класс имеет публичный метод format, которая принимает объект Javascript Date в качестве входных данных и возвращает строку даты в формате dd-mm-yyyy.
- Если входные данные являются недопустимым объектом даты, он вызовет исключение.
Пишем осмысленное имя теста
// Плохо
test('format should format date correctly', function (){
...
})// Хорошо
test('format should return date with dd-mm-yyyy format given a valid date object input', function (){
...
})
Золотое правило содержательного названия теста – это четкое описание выходных и входных данных. Читатель должен быть в состоянии понять желаемое поведение без необходимости читать детали реализации тестируемой системы.
Каждый тест должен охватывать только 1 сценарий
describe('DateFormatter', function() {
// Плохо
test('format should return the date with following format:dd-mm-yyyy given valid date object and throw exception if the input is invalid date object', function(){
...
}) // Хорошо
test('dateFormatter should return the date with following format:dd-mm-yyyy given valid date object', function() {
...
}) test('dateFormatter should throw exception given invalid date object', function() {
...
})
})
Следует избегать тестирования двух функций в рамках одного теста. Причина этого принципа заключается в том, что если тест проваливается, мы не знаем, какая функция проваливается. Вам нужно будет проверить обе функции, даже если только одна из них не пройдет тест.
Используйте шаблон AAA
Шаблон Arrange, Act, Assert – это распространенный шаблон, который можно использовать для улучшения читабельности теста, разделяя части теста пустой строкой.
- Arrange готовит необходимые приспособления, насмешки, заглушки и тестируемую систему.
- Act выполняет тестируемую функциональность
- Assert-это утверждение результата выполнения относительно желаемого значения
describe('DateFormatter', function() {
test('format should return the date with following format:dd-mm-yyyy given valid date object', function() {
// подготовка данных
const sut = DateFormatter();
const date = new Date('2020-01-01');
// выполнение логики
const result = sut.format(date);
// сверка результатов
expect(result).toBe('01-01-2020')
})
})
Изолируйте свой юнит от внешних зависимостей
Допустим, мы добавили еще одну функциональность поверх класса. Каждый раз, когда метод format выполняется, он будет регистрировать результат в стороннем API с помощью функции logToExernalAPI.
import { logToExternalAPI } from './third-party-services'; class DateFormatter() {
format(date) {
...
logToExternalAPI(result);
return result;
}
}
Как написать тест для этой новой функциональности? Один из подходов заключается в рефакторинге класса и использовании инъекции зависимостей, чтобы избежать прямой зависимости от другого блока. Используя эту технику, мы также улучшаем дизайн класса, отделяя его от реализации logger.
class DateFormatter {
constructor(logger){
this.logger = logger;
}
format(date) {
...
this.logger(result);
return result;
}
}
describe('DateFormatter', function(){
test('format should call logger with the formatted given valid date object', function() {
// Arrange
const loggerMock = jest.fn();
const sut = DateFormatter(loggerMock);
const date = new Date('2020-01-01');
// Act
sut.format(date); // Assert
expect(loggerMock).toBeCalledWith('01-01-2020')
})
})
Избегайте тестирования детализированной реализации
Пример детализации реализации тестирования выглядит следующим образом:
- Проверка последовательности вызовов функций
- Проверка внутреннего состояния класса
Следует избегать деталей реализации тестирования, поскольку это создает тесную связь между тестами и реализацией. Например, если вы пишете тесты для проверки последовательности вызовов функций внутри метода вашего класса и решаете изменить порядок, тест не будет выполнен, даже если он на самом деле не влияет на пользователя вашего класса.
Работа над устаревшим кодом: должен ли я сначала отрефакторить или написать тест?
В некоторых случаях конкретный класс/компонент, с которым вы хотите работать, написан таким образом, что его очень трудно проверить.
В общем, я бы предложил написать тест перед рефакторингом, если это не очень трудно сделать.
Вывод
Написание модульных тестов является важной и широко распространенной практикой повышения качества программного обеспечения, предоставляя средства для обеспечения правильности программного обеспечения и позволяя разработчику обнаруживать ошибки как можно раньше.
В конце концов, эта техника (как и другие техники) потребует некоторой практики и дисциплины для овладения. Я надеюсь, что эта статья может дать вам некоторое базовое представление о том, как и почему писать хорошие тесты для вашего программного обеспечения.
Счастливого кодинга!




