Несколько лет назад, на заре разработки Android, стратегией тестирования часто пренебрегали в проектах для этой платформы.

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

Не способствовал решению этой задачи и Android SDK, поскольку не предоставлял эффективных фреймворков и библиотек для тестирования, кроме базового JUnit-фреймворка и некоторых возможностей автоматизации. Его было сложно настроить и запустить, примитивная IDE тоже не помогала (помните Eclipse?), документации почти не существовало, а сама компания Google не предоставила должных рекомендаций и примеров по этому вопросу.

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

К счастью, сегодня ситуация в целом изменилась, и большинство команд применяют тот или иной подход к тестированию. Android SDK, претерпев значительные изменения, предоставляет обширный набор инструментов и эффективные фреймворки для различных типов тестов. Сообщество Android приняло несколько типов архитектуры и шаблонов проектирования, которые облегчают реализацию тестов для различных слоев и компонентов приложений, и мы, разработчики, лучше осознаем критическую роль должного подхода к тестированию.

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

Что необходимо подвергнуть модульным тестам и в каком объеме их использовать? Нужны ли UI-тесты и как сделать их максимально быстрыми и надежными? Есть ли в потребность в интеграционных тестах и для чего именно они нужны? Как насчет сквозных тестов или ручного контроля качества? Стоит ли отдавать предпочтение какому-то одному виду тестов перед другими?

Цель этой статьи — предложить и описать эффективную стратегию тестирования, которая позволяет максимально сократить затраты времени и сил, обеспечивая оптимальную отдачу в плане корректности, гарантии качества и стабильности продуктов. Я расскажу, как, по моему мнению, лучше всего использовать и распределять различные типы тестов и инструментов, доступных в Android. В следующих частях буду расширять информацию по конкретным темам, требующих более глубокого описания и детализации.

Зачем нужна эффективная стратегия тестирования?

Основные цели реализации стратегии тестирования:

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

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

Критерии надежной стратегии тестирования

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

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

Чтобы достичь оптимального эффекта — максимальной ценности при минимальных затратах, — необходимо тщательно разработать стратегию тестирования. Для этого надо решить: какой тип тестов наиболее адекватен для данного класса, компонента или функции? Какие тесты больше подойдут — модульные или интеграционные? Стоит ли строго следовать обычно рекомендуемой пирамиде тестов?

Может показаться, что простым решением будет реализация в основном UI-тестов с помощью Espresso. В конце концов, создание фронтенд-системы предполагает заинтересованность в тестировании того, что видит пользователь, и корректности визуального восприятия, подтверждающего, что все работает так, как ожидается. Лишь убедившись в том, что весь пользовательский поток функционирует должным образом, эмулируя одно и то же поведение и проверяя результат, можно констатировать полное отсутствие дефектов в реализации.

Но проведение UI-тестов — процесс дорогой и медленный. В некоторых случаях понадобится настраивать сложное состояние приложения, которое может включать вход в систему, доступ к удаленным и локальным данным и даже доступ к возможностям устройства, таким как камера, Bluetooth и т. д. Не исключено, что придется пройти через длинный пользовательский поток, что станет особенно сложной задачей при значительном размере приложения, снабженного множеством экранов и потоков. Если в большом приложении нужно запускать целый набор тестов такого типа после каждого коммита в основной ветке, как и положено, циклы разработки могут стать очень длинными и привести к узким местам в конвейере непрерывной интеграции и в общем потоке разработки.

В некоторых проектах ситуация может усугубиться дополнением автоматизированного UI-тестирования (при котором не удалось добиться надежного страховочного покрытия) множеством ручных тестов. В итоге получаем один из антишаблонов в стратегии тестирования под названием «капкейк». При его реализации, помимо длительных сеансов автоматизированного UI-тестирования, приходится выполнять длинные циклы ручных тестов. 

Чтобы избежать подобных проблем, рассмотрим так называемый «разнородный подход». Он поможет ускорить выполнение тестового комплекса, снизить затраты на его разработку и поддержку, сохранив при этом высокий уровень уверенности в корректности кода. 

Стратегия

Первое условие для реализации предлагаемой здесь стратегии: приложение должно быть спроектировано с архитектурой, способствующей разделению задач и модульному дизайну. Хорошим примером является Clean Architecture (чистая архитектура), широко распространенная в сообществе Android, хотя любой другой шаблон также будет уместен. Помимо очевидных преимуществ приемлемой архитектуры, таких как масштабируемость, гибкость и адаптивность, хорошо разработанная кодовая база позволит тестировать классы модулей с минимальными затратами времени и усилий. Класс, содержащий слишком много зависимостей, в числе которых есть такие, которые принадлежат Android-фреймворку или другому стороннему компоненту, усложнит процесс тестирования, а во многих случаях сделает его невозможным.

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

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

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

Вот почему так важно выбрать хорошую комбинацию тестов. Если большинство тестов основаны на UI, запуск всего тестового комплекса будет мучительно медленным и во многих случаях хлопотным процессом. Необходимо обеспечить тот же уровень уверенности в корректности с помощью более быстрой и надежной комбинации тестов.

В качестве графического представления описанной выше стратегии предлагаю разновидность классической тестовой пирамиды, определенной Майком Коном:

Предлагаемый новый вариант пирамиды стратегии тестов

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

Модульные тесты

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

Определение модульного теста и подходы к его реализации, а также различные техники станут темой второй части этой серии статей. Существует множество мнений и философских подходов к реализации модульных тестов. Если же обобщить ключевые концепции, то модульный тест должен быть сосредоточен на одной единице поведения (не обязательно на одном классе), при этом нужно использовать реальные зависимости как можно чаще, стараясь свести к минимуму моки (mocks) и фейки (fakes), применяя их только в случае крайней необходимости. Также важно тестировать то, что делает данный компонент, а не то, как он это делает.

Интеграционные тесты

Интеграционные (integration) тесты позволяют проверить правильность совместной работы различных компонентов системы. Например, можно проверить, как Repository (репозиторий), Interactor (интерактор) и ViewModel (модель представления) работают совместно и получает ли ViewModel ожидаемые данные в правильном типе и формате после запроса через Interactor, основываясь на состоянии данных в Repository. Интеграционные тесты выполняются в JVM, а не в эмуляторе или на реальном устройстве, что делает их быстрыми и надежными.

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

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

Тесты пользовательского интерфейса

Тесты пользовательского интерфейса (UI) позволяют убедиться в том, что экран корректно отображает состояние приложения, с правильными данными, положением компонента, взаимодействием с пользователем и т. д. Такие тесты выполняются на реальном устройстве или в эмуляторе. Это означает, что они дорогие и медленные. При использовании шаблона MVVM можно просто определить и настроить состояния и модели с различными данными в зависимости от сценария тестирования и проверить пользовательский интерфейс с помощью утверждений Espresso. Ключевым моментом здесь является тестирование пользовательского интерфейса в изоляции от остальной части приложения, поэтому такие тесты должны выполняться относительно быстро. Как будет ясно из следующих статей, Android-класс FragmentScenario является другом разработчика.

Сквозные тесты

Сквозные (end to end) тесты самые дорогие в реализации и медленные в запуске. Поэтому рекомендуется проводить их как можно меньше, отдавая предпочтение проверке критически важных пользовательских потоков и основных функций приложения. Такие тесты проводятся по методике «черного ящика» (black box), поэтому сценарии проверяются таким же образом, как пользователь использовал бы приложение. Это дает уверенность в правильности функций приложения, поскольку тестируется вся реализация. При использовании стейджинга или другой среды разработки можно включить в нее сетевые запросы и прочие внешние процессы.

Ручные тесты

Ручные (manual) тесты выполняются человеком. Они составляют минимальную часть процесса реализации набора тестов всех типов, описанных выше. При этом QA-команда, отвечающая за проверку качества ПО (если такая есть, а если нет, то команда разработчиков), должна провести несколько smoke-тестов (на общую работоспособность) и выявить пограничные случаи, которые не удалось подтвердить с помощью всех автоматизированных тестов, реализованных до сих пор. По сути, нужно избегать повторного тестирования того, что уже было проверено автоматизированными наборами. Это относится и к любым другим видам тестов, реализованных ранее.

Разработка набора тестов

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

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

Классический подход Clean Architecture с направлениями потоков данных

Итак, начнем с модульных тестов. Как уже говорилось, они предназначены для тестирования единицы поведения конкретного компонента. В данном примере, как видно на диаграмме ниже, надо протестировать Use Case (также известный как Interactor) и Repository. Эти компоненты могут содержать логику, которую нужно протестировать. В случае с Repository это может быть логика выбора источника данных, преобразование модели данных и т. д. То же самое касается Use Case: это может быть бизнес-логика или любая другая операция в домене, которую необходимо протестировать.

Теперь, когда реализованы модульные тесты для Use Case и Repository и можно подтвердить правильное поведение и корректность функциональности, которую они предоставляют по отдельности, пришло время проверить, как эти компоненты работают вместе. Для этого переходим к интеграционным тестам. Они должны быть как можно более масштабными, включать как можно больше компонентов, задействованных в функции или потоке данных. Как следует из приведенного выше примера, наша задача — тестировать от Repository до ViewModel. Таким образом, предоставляя фейковую версию данных, которые Repository получает из бэкенда или базы данных, будем проверять создание ViewModel правильных состояний, моделей данных представления и т. д. Итак, тестируем все контракты всех классов, участвующих в проекте, преобразование в различные модели для пересечения границ, управление исключениями и т. д. и т. п.

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

Теперь настала очередь UI-тестов. В отличие от всех остальных, они будут запущены в эмуляторе реального устройства. По этой причине необходимо сделать их небольшими и сфокусированными на чистых UI-утверждениях. Итак, как показано на диаграмме, внедрим в ViewModel модели данных, которые представляют состояние приложения. Задействуем FragmentScenario для изолированного запуска этого экрана, а затем, используя Espresso, попробуем утверждать, что представление действительно представляет состояние, определенное ViewModel. Выясним, отображаются ли нужные компоненты с нужными копиями, является ли какой-либо из них быть видимым или нет, включен он или нет, проверим события, отправляемые представлением во ViewModel, и т. д. Как видите, здесь нет проверок всех пользовательских потоков и всех функций — только чистое UI-тестирование одного представления.

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

Резюме

В этой части, посвященной реализации стратегии тестирования Android-проектов, был представлен подход, который я считаю сбалансированным и эффективным. Главная его цель — максимально сократить время и усилия, затрачиваемые на тестирование, при этом получить максимальную отдачу от инвестиций и свести к минимуму участие человека в этом процессе.

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи David Guerrero: An effective testing strategy for Android (Part 1)

Предыдущая статьяТоп-25 полезных советов для React-разработчиков. Часть 1
Следующая статьяJava: оператор try-with-resources