Быстрый старт: гайд по автоматизированному тестированию для Android-разработчика. JVM

Привет! Меня зовут Сергей Иванов, я ведущий разработчик Android в Redmadrobot. С 2016 использую автотесты различных категорий и успел в этом набить немало шишек. Именно поэтому решил поделиться опытом. Возможно, что кому-то статья поможет систематизировать знания или начать применять эту практику в работе. 

b3dc389885b2f5f0c580a90409981145.jpg

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

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

Подробнее расскажу про тесты на JVM, а не про UI-тесты, о которых в последнее время пишут часто. Материал будет хорошей отправной точкой для изучения темы, а также поможет дополнить уже имеющиеся знания.

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

  • базовые понятие автоматизированного тестирования;

  • категории тестов их специфика на Android;

  • как писать тестируемый код;

  • как и какие инструменты использовать для тестирования;

  • как писать полезные и поддерживаемые тесты;

  • что тестировать;

  • как и когда применять методологию Test Driven Development.

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

  1. Находить баги на раннем этапе разработки. Это позволяет раньше устранять проблемы, при этом расходуя меньше ресурсов.  

  2. Локализовать проблему. Чем более низкоуровневым является тест, тем более точно он способен указать на причину ошибки.

  3. Ускорить разработку. Это вытекает из предыдущих пунктов и из того, что благодаря автотестам разработка разных частей фичи может быть оперативно разделена на несколько разработчиков. Установив контракты между компонентами приложения, разработчик может разработать свой компонент и проверить его корректность при отсутствии остальных (например, при полном отсутствии UI).

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

Но есть и проблемы:

  1. Нужно время на внедрение, написание и поддержку.

  2. При некорректном внедрении практики могут принести больше вреда, чем пользы. 

Важные базовые понятия автоматизированного тестирования

System Under Test (SUT) — тестируемая система. В зависимости от типа теста системой могут быть разные сущности (о них подробнее написал в разделе «категории тестов»). 

Для различия уровня тестирования по использованию знаний о SUT существуют понятия:

Black box testing — тестирование SUT без знания о деталях его внутреннего устройства.

White box testing — тестирование SUT с учётом деталей его внутреннего устройства.

Выделяют также Gray box testing, комбинацию подходов, но ради упрощения он будет опущен.

Для обеспечения базового качества автотестов важно соблюдать некоторые правила написания. Роберт Мартин сформулировал в книге «Clean Code» глобальные принципы F.I. R.S.T.

Fast — тесты должны выполняться быстро.

Independent — тесты не должны зависеть друг от друга и должны иметь возможность выполняться в любом порядке.

Repeatable — тесты должны выполняться с одинаковым результатом независимо от среды выполнения.

Self-validating — тесты должны однозначно сообщать о том, успешно их прохождение или нет.

Timely — тесты должны создаваться своевременно. Unit-тесты пишутся непосредственно перед кодом продукта.

Структура теста состоит как минимум из двух логических блоков:

  • cовершение действия над SUT,

  • проверка результата действия.

Проверка результата заключается в оценке:

  • состояния SUT или выданного ею результата,

  • cостояний взаимодействующих с SUT объектов,

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

2bf77cf2cb5a2226f2d4f91fd3bd9360.png

При необходимости также добавляются блоки подготовки и сброса тестового окружения, отчасти связанные с первыми тремя принципам F.I. R.S.T.

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

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

Зачастую для настройки окружения применяются тестовые дублеры.

Test doubles (Тестовые дублёры) — фиктивные объекты, заменяющие реальные объекты, от которых зависит SUT, для достижения целей теста.

Тестовые дублеры позволяют:

  • зафиксировать тестовое окружение, имитируя неважные, нереализованные, нестабильные или медленные внешние объекты (например, БД или сервер),

  • совершать проверки своих вызовов (обращений к функциям, свойствам).

Самая популярная классификация включает 5 видов тестовых дублеров, различных по своим свойствам: Dummy, Fake, Stub, Spy, Mock. 

Stub — объект, который при вызовах его функций или свойств возвращает предустановленные (hardcoded) результаты, а не выполняет код реального объекта. Если же функция не имеет возвращаемого значения, то вызов просто игнорируется.

Mock — объект, позволяющий проверять поведение SUT путём отслеживания обращений к функциям и свойствам объекта: были ли в ходе теста вызваны функции мока, в правильном ли порядке, ожидаемые ли аргументы были в них переданы и т.д. Может также включать функциональность Stub.

Почитать об этих и остальных видах дублеров можно в первоисточнике. 

Эта классификация не является стандартом, и в фреймворках для создания тестовых дублёров часто ради удобства API несколько типов обобщают термином Mock. А вот чем они на самом деле будут являться, зависит от их последующей конфигурации и применения в тесте. Например, при использовании фреймворка Mockito, экземпляр тестового дублера может быть создан как Dummy, а потом превращен в Stub и в Mock. 

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

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

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

Категории тестов

Есть разные версии категоризации тестов, по разным характеристикам, поэтому существует некоторая путаница. 

Покажу основные категории уровней тестов, на которых тестируется система, на примере одного из самых распространенных вариантов пирамиды тестирования:

e3e77bb4deae1d22223b4450572f0b51.png

Unit-тесты проверяют корректность работы отдельного unit-а (модуля). Unit-ом (то есть SUT данного типа тестирования) может быть класс, функция или совокупность классов.

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

Грань между Unit- и Integration-тестированием довольно тонкая. Интеграционными тестами, в зависимости от масштаба и контекста, в принципе могут называть тесты, проверяющие взаимодействие чего-либо с чем-либо с определенной долей абстракции: приложение (клиент)-сервер, приложение-приложение, приложение-ОС и др. Но в дальнейшем я буду говорить об интеграционном тестировании в рамках приложения.

End-to-end-тесты (E2E) — интеграционные тесты, которые воздействуют на приложение и проверяют результат его работы через самый высокоуровневый интерфейс (UI), то есть на уровне пользователя. Использование тестовых дублеров на этом уровне исключено, а значит обязательно используются именно реальные сервер, БД и т.д.

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

Вернёмся к категориям. В Android сложность категоризации автотестов усугубляется еще и тем, что они могут работать на JVM или в Instrumentation-среде (эмулятор или реальное устройство). Последние называют инструментальными.

Чтобы было удобнее ориентироваться в видах тестов, не путаясь в терминологии, предлагаю такую категоризацию для мобильного приложения на Android:

6bc75a26bde14405c2ee1755a6627b38.png

JVM Integration tests — интеграционные тесты, проверяющие взаимодействие модулей или совокупностей модулей без использования Instrumentation. Характеризуются они высокой скоростью исполнения, сравнимой с Unit-тестами, также выполняющимися на JVM.

Instrumentation Integration non-UI tests — интеграционные тесты, исполняемые уже в реальной Android-среде, но без UI.

Component UI tests — интеграционные инструментальные тесты с использованием UI и фиктивных сервера и БД, если таковые требуются. Тест может состоять как из одного экрана, запущенного в изоляции, так и из нескольких экранов с соблюдением их реального флоу.

E2E UI tests — интеграционные инструментальные UI-тесты без тестовых дублеров только с реальным флоу экранов. Максимально приближены к ручным тестам.

15ca4d94f2ce1cc7109b00e1b2dcfa34.png

Если Unit-тесты являются сильно завязанными на детали реализации, очень быстро выполняются, относительно легко пишутся и наиболее точно при поломке указывают на причину ошибки, то в случае E2E UI ситуация противоположная. Изменение этих характеристик происходит постепенно от низа к верху пирамиды.

При переходе от тестов на JVM к тестам на Instrumentation из-за использования настоящей Android-среды происходит резкое падение скорости выполнения этих тестов. Это становится серьезным ограничением. Особенно когда тесты необходимо запускать часто и много раз подряд. Поэтому к написанию инструментальных тестов следует прибегать лишь в случаях, когда использование настоящих Android-зависимостей действительно необходимо.

UI-тесты 

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

Часто они оказываются нестабильны в своём поведении и могут то выполняться, то падать, даже если не вносилось никаких изменений в реализацию (нестабильные тесты называют Flaky). Мало того, UI-тесты могут совершенно по-разному себя вести на разных устройствах, эмуляторах и версиях Android. Когда же UI-тесты являются еще и E2E, добавляется хрупкость и снижается скорость выполнения из-за реальных внешних зависимостей. Причем в случае ошибки найти её причину бывает затруднительно, поскольку проверки в таких тестах осуществляются на уровне состояния UI. В таких ситуациях выгоднее обойтись силами QA-инженеров.

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

Поэтому, для написания UI-тестов желательно иметь разработчиков или QA-инженеров-автоматизаторов, которые будут заниматься именно ими бÓльшую часть времени. 

Unit-тесты

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

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

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

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

Подытожим

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

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

  • Лучше делать акцент на быстро выполняющиеся тесты. Так, после Unit-тестов рекомендую проверять JVM Integration-тестами интеграцию в том масштабе, который можно комфортно обеспечить без использования Instrumentation — от ViewModel до слоя данных.

Дальше я буду говорить преимущественно о тестах на JVM. Но некоторые моменты актуальны и для остальных категорий.

Инструментарий

Раньше для написания JVM-тестов наши разработчики использовали фреймворки Junit 4 и Junit 5, но потом переключились на молодой перспективный Spek 2. Junit 4 нужен для инструментальных тестов — с другими фреймворками они не работают.

Для проверок (assert) используем AssertJ — отличную библиотеку с богатым набором читабельных ассертов и удобных дополнительных функций.

Для создания тестовых дублеров применяем Mockito-Kotlin 2 — Mockito 2, адаптированный для Kotlin.

Для стаббинга и мокирования сервера — MockWebServer — библиотеку от Square, рассчитанную на работу с OkHttp.

Фреймворки PowerMock и Robolectric не используем из соображений скорости выполнения тестов и их надёжности. Кроме того, эти фреймворки поощряют «плохо пахнущий код» — это дополнительные зависимости, без которых вполне можно обойтись. Для этого код должен быть тестируемым.

Дизайн кода

Признаки нетестируемого кода:  

  • Наличие неявных зависимостей, сильная связанность. Это затрудняет изолированное unit-тестирование, тестирование на раннем этапе развития фичи, распараллеливание разработки. Использование статических функций, создание сложных объектов внутри класса, ServiceLocator исключают возможность использования тестовых дублеров.

  • Обилие Android-зависимостей. Они требуют Instrumentation или объемную подготовку среды на JVM с тестовыми дублерами, если их использование вообще возможно (см. прошлый пункт).

  • Наличие явного управления асинхронным и многопоточным поведением. Если результат работы SUT зависит от выполнения асинхронной работы, особенно порученной другому потоку (или нескольким), то не получится просто так гарантировать правильность и стабильность выполнения тестов. Тест может совершить проверки и завершиться раньше, чем асинхронная работа будет выполнена, и результат не будет соответствовать желаемому. При этом принудительное ожидание в тестах (в первую очередь на JVM) — плохая практика, поскольку нарушается принцип Fast. 

Пример

class ExampleViewModel constructor(val context: Context) : BaseViewModel() {

    private lateinit var timer: CountDownTimer

    fun onTimeAccepted(seconds: Long) {
        val milliseconds = MILLISECONDS.convert(seconds, SECONDS)

        // Неявная зависимость, Android-зависимость, запуск асинхронной работы
        timer = object : CountDownTimer(milliseconds, 1000L) {
            override fun onTick(millisUntilFinished: Long) {
                showTimeLeft(millisUntilFinished)
            }

            override fun onFinish() {
                // Неявная зависимость. Вызов статической функции с Android-зависимостью
                WorkManager.getInstance(context)
                    .cancelUniqueWork(SeriousWorker.NAME)
            }
        }
        timer.start()
    }

Как сделать код тестируемым

Следовать принципам SOLID, использовать слоистую архитектуру. Грамотное разделение и реализация сущностей позволит писать изолированные тесты именно на интересующую часть функционала, не допускать чрезмерного разрастания тестового файла и, при необходимости, осуществлять распараллеливание разработки. DI позволит подменять настоящие реализации тестовыми дублёрами.

Стремиться к чистоте функций. Это функции, которые:

  1. При одинаковом наборе входных данных возвращают одинаковый результат.

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

Пример теста такой функции:

val result = formatter.toUppercase("адвокат")
assertThat(result).isEqualTo("АДВОКАТ")

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

Самая распространенная Android-зависимость в потенциально тестируемых классах — ресурсы, и их выносить из, скажем, ViewModel, ну, совсем не хочется. В таком случае можно внедрить Resources во ViewModel, чтобы стаббить конкретные ресурсы (их id актуальны на JVM) и проверять конкретные значения:

mock { on { getString(R.string.error_no_internet) } doReturn "Нет интернета" }

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

interface ResourceProvider {
    fun getString(@StringRes res: Int, vararg args: Any): String
}

class ApplicationResourceProvider(private val resources: Resources) : ResourceProvider {
    override fun getString(res: Int, vararg args: Any): String {
        return resources.getString(res, *args)
    }
}

class TestResourceProvider : ResourceProvider {
    override fun getString(res: Int, vararg args: Any): String = "$res"
}

При таком поведении TestResourceProvider по умолчанию правильность строки в ожидаемом результате можно сверять по id ресурса:

val string = TestResourceProvider().getString(R.string.error_no_internet)
assertThat(string).isEqualTo(R.string.error_no_internet.toString())

В общем случае лучше вообще не заменять дублерами типы, принадлежащие сторонним библиотекам и фреймворкам. Это может привести к проблемам при обновлении их API. Обезопасить себя можно также с помощью Wrapper. Подробнее ситуация разобрана в статье «Don«t Mock Types You Don«t Own».

Использовать Wrapper-ы для статический функций, управления асинхронным и многопоточным поведением. Существует немало стандартных статических функций или Android-зависимостей в виде таких функций. Если нужно иметь с ними дело, то следует помещать их во Wrapper-ы и внедрять в SUT для последующей подмены. 

Это поможет и при работе с асинхронностью и многопоточностью: инкапсулирующий управление ими Wrapper можно заменить тестовым дублером, который позволит проверяемому коду выполняться в одном потоке и синхронно вызвать асинхронный код. Для RxJava и Kotlin Coroutines есть стандартные решения от их авторов.

Дизайн тестов

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

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

Spoiler

public void testSubClassSerializerInvokedForBaseClassFieldsHoldingArrayOfSubClassInstances() {
    Gson gson = new GsonBuilder()
            .registerTypeAdapter(Base.class, new BaseSerializer())
            .registerTypeAdapter(Sub.class, new SubSerializer())
            .create();
    ClassWithBaseArrayField target = new ClassWithBaseArrayField(new Base[] {new Sub(), new Sub()});
    JsonObject json = (JsonObject) gson.toJsonTree(target);
    JsonArray array = json.get("base").getAsJsonArray();
    for (JsonElement element : array) {
        JsonElement serializerKey = element.getAsJsonObject().get(Base.SERIALIZER_KEY);
        assertEquals(SubSerializer.NAME, serializerKey.getAsString());
    }
}

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

Наименование теста и разделение на блоки

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

  • Given — настройка SUT и среды;

  • When — действие, инициирующее работу SUT, результат работы которой нужно проверить;

  • Then — проверка результатов на соответствие ожиданиям.

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

@Test
fun `when create - while has 1 interval from beginning of day and ending not in end of day - should return enabled and disabled items`() {
    // given
    val intervalStart = createDateTime(BEGINNING_OF_DAY)
    val intervalEnd = createDateTime("2019-01-01T18:00:00Z")
    val intervals = listOf(
        ArchiveInterval(startDate = intervalStart, endDate = intervalEnd)
    )

    // when
    val result = progressItemsfactory.createItemsForIntervalsWithinDay(intervals)

    // then
    val expected = listOf(
        SeekBarProgressItem.createEnabled(intervalStart, intervalEnd),
        SeekBarProgressItem.createDisabled(intervalEnd, createDateTime(END_OF_DAY))
    )
    assertThat(result).isEqualTo(expected)
}

«Лицо» теста — его название. Оно должно быть читабельным и ёмко передавать информацию о содержимом, чтобы для ориентации не приходилось каждый раз анализировать это самое содержимое.

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

Для тестов на Junit применим следующий паттерн именования в простых случаях:

В более сложных случаях, когда есть дополнительные условия:

  • when… — while/and… — should…, где

    while — предусловие до вызова целевой функции SUT;

    and — условие после вызова функции SUT.

Пример:

@Test
fun `when doesValueSatisfyRegex - while value is incorrect - should return false`() {

Так имя теста написано в виде требования, и в случае падения будет сразу видно, какой сценарий отработал некорректно:

07d6d78100822e1b488c83afdec6ac6e.png

Фреймворк Spek 2 выводит всё это на новый уровень. Он предоставляет «из коробки» DSL в стиле Gherkin (BDD).

object GetCameraGroupsInteractorTest : Spek({
    Feature("Transform cached cameras to groups of cameras") {
        ...
        Scenario("subscribe while has non-grouped camera and unsorted by groups order cameras") {
            ...

            Given("non-grouped camera and unsorted by groups order cameras") {
                ...
            }

            When("subscribe") {
                ...
            }

            Then("should return four groups") {
                ...
            }

            ...
        }
    }
})

Блоки Given, When, Then — подтесты глобального теста, описанного с помощью блока Scenario. Теперь нет необходимости ставить всё описание в названии, можно просто расположить все части в соответствующих блоках.

Результат выполнения имеет иерархический вид:

a627ff68feb7446c96005cd8a7efa4ae.png

Эти блоки могут присутствовать внутри Scenario в любом количестве, а для придания еще более «человекочитаемого» вида можно использовать блок And. Теперь насыщенный сценарий можно оформить, не растянув при этом название теста далеко за границу экрана:

6c18c882dcb60619af9a5a9fdbcb306b.png

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

Чтобы добиться схожего разделения и отображения результатов с помощью Junit 5, понадобилось бы написать в тестах много бойлерплейта с аннотациями.

Устранение лишнего кода

Чтобы сделать содержимое тестов читабельнее, нужно следовать нескольким правилам:

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

В Spek 2 вместо создания полностью отдельных тестов, если они концептуально относятся к одному сценарию, разделение проверок можно сделать с помощью блоков Then/And внутри Scenario:

...

Then("should return four groups") {...}

And("they should be alphabetically sorted") {...}

And("other group should contain one camera") {...}

And("other group should be the last") {...}

...

В Junit 4 такой возможности нет. На помощь приходит механизм SoftAssertions из AssertJ, который гарантирует выполнение всех assert в тесте. Например:

// then
assertSoftly {
    it.assertThat(capabilityState)
        .describedAs("Capability state")
        .isInstanceOf(Available::class.java)

    it.assertThat((capabilityState as Available).disclaimer)
        .describedAs("Disclaimer")
        .isNull()
}

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

3. Использовать обобщающие конструкции тестового фреймворка для одинаковой настройки окружения, если настройка повторяется для большого количества тестов, находящихся на одном уровне иерархии (например, beforeEachScenario и afterEachScenario в случае Spek 2). Если настройка одинакова для нескольких тестовых файлов, можно использовать Extension для Junit 5, Rule для Junit 4, а для Spek 2 подобного механизма «из коробки» нет, поэтому нужно обходиться конструкциями before…/after….

4. Объемные схожие настройки тестового окружения следует также выносить в отдельную функцию.

5. Использовать статические импорты для повсеместно применяемых функций вроде функций проверок AssertJ и Mockito.

6. Если создание вспомогательных объектов объемное, используется в разных тестовых файлах и с разными параметрами, следует завести генератор с дефолтными значениями:

Пример генератора

object DeviceGenerator {

    fun createDevice(
        description: String? = null,
        deviceGroups: List = emptyList(),
        deviceType: DeviceType = DeviceType.CAMERA,
        offset: Int = 0,
        id: String = "",
        photoUrl: String? = null,
        isActive: Boolean = false,
        isFavorite: Boolean = false,
        isPublic: Boolean = false,
        model: String? = null,
        vendor: String? = null,
        title: String = "",
        serialNumber: String = "",
        streamData: StreamData? = null
    ): Device {

        return Device(
            description = description,
            deviceGroups = deviceGroups,
            deviceType = deviceType,
            offset = offset,
            id = id,
            photoUrl = photoUrl,
            isActive = isActive,
            isFavorite = isFavorite,
            isPublic = isPublic,
            model = model,
            vendor = vendor,
            title = title,
            serialNumber = serialNumber,
            streamData = streamData
        )
    }
}
Given("initial favorite camera") {
    val devices = listOf(
        createDevice(id = deviceId, isFavorite = true)
    )
    ...
}

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

Тесты как документация

Когда предыдущие пункты соблюдены, тесты уже можно применять как документацию, свернув тестовые функции в IDE. 

Для сворачивания и разворачивания всех блоков кода в файле в случае Mac используются комбинации клавиш «Shift» + »⌘» + »-» и «Shift» + »⌘» + »+», для управления конкретным блоком — »⌘» + »-» и »⌘» + »+» соответственно. 

В тестах на Junit 4 можно сделать еще лучше, сгруппировав тесты по регионам, ведь их тоже можно сворачивать.

Пример

fd68f5c8152a92f14bd1f378be3fa4c3.png

В тестах на Spek 2 нет нужды делать разделение тестов по регионам, поскольку их можно хорошо сгруппировать с помощью блоков Scenario и Feature.

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

Наконец пример тестов на Spek 2 в режиме документации

cc1c03460e155db52320e0b50c6d62e2.png

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

Она лучше обычной текстовой, поскольку в отличие от тестов, обычную документацию можно забыть актуализировать. Чем тесты более высокоуровневые, тем более близкими к составленным аналитиком функциональным требованиям будут их названия. Это будет заметно в разделе «JVM Integration Testing».

Параметрические тесты

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

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

В документации Spek 2 не написано о возможности написания параметрических тестов, хотя она есть, и писать их проще, чем в Junit 4 и Junit 5. Для этих целей удобно использовать стиль тестов Specification.

Пример параметрического теста в Speck 2

class OrientationTypeTest : Spek({

    describe("Orientation type") {
        mapOf(
            -1 to Unknown,
            -239 to Unknown,
            361 to Unknown,
            2048 to Unknown,
            340 to Portrait,
            350 to Portrait,
            360 to Portrait,
            0 to Portrait,
            ...
        ).forEach { (tiltAngle, expectedOrientation) ->

            describe("get orientation by tilt angle $tiltAngle") {
                val result = OrientationType.getOrientation(tiltAngle)

                it("return $expectedOrientation type") {
                    assertThat(result).isEqualTo(expectedOrientation)
                }
            }
        }
    }
})

Результат выполнения:

418daa6ab67c95306b029141c1fee9b2.png

Снижение хрупкости non-UI тестов

Я писал, что степень хрупкости unit-тестов при изменениях исходного кода, обусловленную их привязкой к деталям реализации модуля, можно снизить. Это применимо для всех non-UI тестов.

Написание тестов в стиле White box искушает расширять видимость функций/свойств SUT для проверок или установки состояний. Это простой путь, который влечет за собой не только увеличение хрупкости тестов, но и нарушение инкапсуляции SUT.

Избежать этого помогут правила. Можно сказать, что взаимодействие с SUT будет в стиле Black box.

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

  2. Нужно стараться делать функции чистыми. Об этом я говорил выше.

  3. Проверки в тесте следует осуществлять по возвращаемому значению вызываемой публичной функции, публичным свойствам или, в крайнем случае, по взаимодействию с mock-объектами (с помощью функции verify () и механизма ArgumentCaptor в Mockito)

  4. Делать только необходимые проверки в рамках теста. Например, если в тесте проверяется, что при вызове функции «A» у SUT происходит вызов функции «X» у другого класса, то не следует до кучи проверять значения её публичных полей, особо не имеющих отношения к делу, и что у SUT не будет более никаких взаимодействий с другими функциями связанного класса (функция verifyNoMoreInteractions () в Mockito).

  5. Если для проведения определенного теста невозможно привести SUT в требуемое предварительное состояние с помощью аргументов целевой функции, моков/стабов или изменения полей, то следует вызвать другие публичные функции, вызов которых приводит SUT в интересующее состояние в условиях реальной работы приложения. Например, вызвать функции onLoginInputChanged и onPasswordInputChanged для подготовки теста onEnterButtonClick во ViewModel

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

6276df73043b807111b439de7fbe5a53.png

Тестирование асинхронного кода с RxJava

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

Для тестирования SUT, осуществляющей планирование Rx-операций, нужно произвести замену реализаций Scheduler-ов так, чтобы весь код выполнялся в одном потоке. Также важно иметь в виду, что на JVM нельзя использовать AndroidSchedulers.mainThread ().

В большинстве случаев все Scheduler-ы достаточно заменить на Schedulers.trampoline (). В случаях, когда нужен больший контроль на

© Habrahabr.ru