В командной разработке тесты – это, как правило, задача QA- и SDET-специалистов. Вместе с тем навыки тестирования полезны и разработчикам, позволяя им проверить свои приложения и повысить стабильность их работы.

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

На примере  Android-разработки обсудим подходящие инструменты тестирования – от JUnit до Kaspresso, а также немного познакомимся с методологиями Test Driven Development (TDD) и Behaviour Driven Development (BDD). Наконец, рассмотрим их отличия на примере кейса.

Тестирование IT-системы охватывает множество проверок архитектуры, UI, кода и взаимодействия его частей, соответствия требованиям. По мере усложнения систем в отрасли растут потребности как в обеспечении качества (QA), так и в автоматизации тестирования (SDET), которая позволяет проводить некоторые тесты быстро и с минимальным участием людей.

Уровни тестирования

С появлением тестов для различных уровней программы возникла их абстрактная иерархия – Пирамида автотестов, в которую входят:

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

  • Интеграционные тесты. Проверяют логику взаимодействия различных компонентов, подсистем без использования UI. В контексте Android сюда входят тесты БД (миграции, выборки, CRUD операции), тесты api-сервисов с моковыми данными и т.д.

  • UI-тесты. В контексте Android это полноценное автоматизированное тестирование экрана или набора экранов, проверка корректной работы пользовательского интерфейса. Вся логика при этом должна быть протестирована на нижних уровнях.

https://qastart.by/mainterms/64-piramida-testov-testirovaniya
https://qastart.by/mainterms/64-piramida-testov-testirovaniya

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

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

Unit-тесты

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

Инструменты для модульного тестирования

Unit-тесты для Android по умолчанию располагаются в папке src/test проекта или модуля, запускаются с использованием фреймворка JUnit. В идеале, один тест должен тестировать открытый интерфейс одного класса и проверять все ветвления кода и граничные случаи в нем. Зависимости должны иметь поведение, необходимое для проверки тестируемого класса.

В современных Android приложениях для unit-тестов, в основном, используются следующие библиотеки:

  • JUnit. Для запуска тестов, вызова assertion’ов.

  • Mockk. Позволяет мокать final классы Котлина, suspend функции, имеет удобный DSL для работы.

  • kotlinx-coroutines-test. Тестирование suspend-функций внутри TestCoroutineScope, предоставляемого функцией runBlockingTest, подмены main dispatcher’а в рамках тестов.

  • turbine. Небольшая, но удобная библиотека для тестирования kotlinx.coroutines.Flow.

  • robolectric. Позволяет писать unit-тесты для классов, использующих Android SDK без непосредственного запуска устройства – фреймворк умеет симулировать различные части системы.

Инструменты для интеграционного тестирования

Эти тесты для Android по умолчанию располагаются в папке src/androidTest проекта или модуля и запускаются уже на устройстве, так как должны иметь доступ, например, к контексту приложения для создания БД Room. Для запуска тестов используется уже упомянутый фреймворк JUnit.

Инструменты для тестирования пользовательского интерфейса

UI-тесты служат, в основном, для прогона основных пользовательских сценариев приложения (авторизация, регистрация, добавление товара в корзину и т.п.). Они помогают отловить ошибки в базовых сценариях и исправить их до попадания сборки с багами к тестировщикам. UI-тесты также по умолчанию располагаются в папке src/androidTest и запускаются на устройстве. Помимо JUnit, основные инструменты – это:

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

  • UI Automator. В отличие от Espresso, позволяет взаимодействовать с другими приложениями: совершать звонки, отправлять сообщения, изменять настройки устройства.

  • Kaspresso. Обертка над Espresso и UI Automator, которая позволяет писать стабильные, быстрые, удобочитаемые тесты. 

  • Для тестирования интерфейсов, реализованных с помощью Jetpack Compose, также появляются свои библиотеки, например, эта.

Также на Хабре можно прочитать больше об инструментах для UI-тестирования, например, в этой статье. 

TDD и BDD

TDD и BDD – две популярные методики разработки через тестирование. Рассмотрим их отличия на примере следующего кейса: 

  • Пользователь вводит сумму расхода, комментарий к расходу и выбирает категорию расхода.

  • Если сумма некорректна или не выбрана категория расхода, возвращается код ошибки, иначе – код успешной обработки.

  • Комментарий опционален.

TDD (Test Driven Development) – это методология разработки ПО, основанная на следующих коротких циклах:

  • Написать тест, проверяющий желаемое поведение.

  • Запустить тест. Test failed.

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

  • Запустить тест. Test succeeded.

  • Провести рефакторинг написанного программного кода, сопровождая прогонами теста.

Для начала создадим контракт нашей реализации.

sealed class VerificationResult {
   object Success : VerificationResult()
   object Failure : VerificationResult()
}

interface ExpenseController {
  
   fun verifyExpenseInfo(
       sum: String?,
       comment: String?,
       category: String?
   ): VerificationResult
}

class ExpenseControllerImpl: ExpenseController {

   override fun verifyExpenseInfo(
       sum: String?,
       comment: String?,
       category: String?
   ): VerificationResult = TODO()
}

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

class ExpenseControllerTest {

   private val controller: ExpenseController = ExpenseControllerImpl()

   @Test
   fun `when sum is not valid then result is failure`() {
       val sum: String? = null
       val comment: String? = null
       val category = "valid category"

       val result = controller.verifyExpenseInfo(sum, comment, category)

       assertTrue(result is VerificationResult.Failure)
   }

   @Test
   fun `when category is null then result is failure`() {
       val sum = "56"
       val comment: String? = null
       val category: String? = null

       val result = controller.verifyExpenseInfo(sum, comment, category)

       assertTrue(result is VerificationResult.Failure)
   }

   @Test
   fun `when comment is null then result is success`() {
       val sum = "56"
       val comment: String? = null
       val category = "valid category"

       val result = controller.verifyExpenseInfo(sum, comment, category)

       assertTrue(result is VerificationResult.Success)
   }

   @Test
   fun `when comment is not null then result is success`() {
       val sum = "56"
       val comment = "some comment"
       val category = "valid category"

       val result = controller.verifyExpenseInfo(sum, comment, category)

       assertTrue(result is VerificationResult.Success)
   }
}

Запускаем тест, получаем ожидаемый результат:

kotlin.NotImplementedError: An operation is not implemented.

Теперь напишем реализацию

class ExpenseControllerImpl : ExpenseController {

   override fun verifyExpenseInfo(
       sum: String?,
       comment: String?,
       category: String?
   ): VerificationResult = when {
       sum == null -> VerificationResult.Failure
       category == null -> VerificationResult.Failure
       else -> VerificationResult.Success
   }
}

Запустим тесты: все 4 теста проходят. Теперь настало время рефакторинга написанного кода.

class ExpenseControllerImpl : ExpenseController {

   override fun verifyExpenseInfo(
       sum: String?,
       comment: String?,
       category: String?
   ): VerificationResult = when {
       isNotValidNumber(sum) -> VerificationResult.Failure
       isCategoryNotSelected(category) -> VerificationResult.Failure
       else -> VerificationResult.Success
   }

   private fun isNotValidNumber(sum: String?): Boolean = sum == null
  
   private fun isCategoryNotSelected(category: String?): Boolean = category == null
}

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

Методология TDD имеет следующие преимущества:

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

  • Позволяет провести рефакторинг с меньшей вероятностью возникновения ошибок, поскольку есть способ сразу же проверить правильность написанного кода.

  • Позволяет локализовать ошибки быстрее.

В числе минусов можно выделить следующие:

  • Фокусировка на реализации задачи.

  • Код и описание тестов пишутся на одном языке.

  • В процесс вовлечена только команда разработки.

Подробнее про данную методологию можно прочитать в книге Кента Бека Экстремальное программирование. Разработка через тестирование.

BDD (Behaviour driven development) – методология разработки ПО, во многом схожая с TDD. Отличается тем, что тестовые сценарии пишутся на “человеческом” языке, а не на языке программирования.

Тестовые сценарии записываются в формате given-when-then. Например, given (имея) подключение к сети, when (когда) пользователь открывает ссылку, then (тогда) контент страницы отображается.

Перепишем наши требования с использованием BDD:

Сценарий: добавление траты.

Given Корректную сумму

And Введенный комментарий

And Выбранную категорию траты

When Пользователь нажимает кнопку добавления

Then Пользователь ��олучает успешный результат

Для данного подхода существуют свои фреймворки. Например, для Java это фреймворк JBehave.

К особенностям данного подхода можно отнести следующее:

  • Тестовые сценарии на “человеческом языке” может писать как заказчик,так и аналитик, тестировщик. Это повышает уровень знаний всей команды о разрабатываемой системе.

  • Тестовые сценарии легко изменяются.

  • Результаты тестов также более понятны заинтересованным лицам, по сравнению с результатами выполнения кода.

Узнать подробнее о BDD можно в этой статье.

Заключение

Мало у кого возникают сомнения, что тесты необходимы для проектирования качественного ПО. Существует множество фреймворков, инструментов и методологий (DDD, FDD и другие *DD), которые помогают команде на всех этапах жизненного цикла ПО. Тесты помогают быстро найти и локализовать ошибки, а также, если они правильно спроектированы, могут служить тестовой документацией. Также благодаря тестам код реализации, скорее всего, будет написан чище и понятнее. В то же время главное – не 100% покрытие кода тестами, а его соответствие бизнес-задачам, поэтому важно избегать крайностей и не писать тесты ради тестов.

Спасибо за внимание! Надеемся, что этот материал был вам полезен.