Clean Architecture — Junit Tests

Итак, третья в серии статья по чистой архитектуре (если вы не ознакомились с предыдущими двумя, то настоятельно рекомендую). Проект доступен по ссылке.

А начнем мы с того, что такое юнит-тесты и зачем они вообще нужны. Если говорить о том, что существует такая практика разработки как TDD — test driven development, то сразу становится понятным — сначала пишем тесты, а после уже к нему код. И на самом деле это то, к чему всем стоит на мой взгляд стремиться. Ведь как оно бывает на самом деле — тебе дают задачу, в которой написаны все тест-кейсы — в таком случае поведение/результат такое, а в таком случае такое. И по хорошему, для того, чтобы быть уверенным в том, что ты все эти условия продумал и написал им удовлетворяющий код — ты пишешь тесты.

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

Добавим сюда еще такой кейс — вам в уже существующем коде необходимо сделать доработки, но таким образом, чтобы все остальное не затронулось и продолжало работать как и до внесения изменений. А теперь представьте, что у вас не HelloWorld проект, а проект уровня какого-нибудь известного мессенджера или где 100500 фич и экранов. Перепроверять все это дело будет очень затратным по времени и по ресурсам. Сколько нужно тестировщиков, чтобы перепроверить перед релизом все фичи? Это называется временем регресса. Т.е. время, после того как программисты сдали все фичи (влили их в релиз кандидат версию гита) и до момента, когда все тестировщики протестировали все фичи, которые входят в релиз и все старые которые остались с предыдущих. Итак, если вам важно, чтобы это время было минимальным, то надо бы действительно задуматься о написании юнит-тестов.

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

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

Давайте более подробно рассмотрим тесты, написанные для проекта SpaceX. Начнем мы опять же с data слоя. Самый простой класс в нашем случае это DiskLaunchesDataStore. По логике у него 2 метода, первый отдает все значения из кеша для какого-то года, другой дает подробную информацию. Мы смотрим в код и видим, что в конструктор мы передаем кеш LaunchesCache. Значит его можно замокать, чтобы он отдавал нужные нам значения. Мокирование это именно тот нужный нам инструмент, ведь если у вас коде не соблюден принцип SOLID- D, то вы не сможете подменить нужную реализацию вашего класса.

Итак, пишем первый тест-кейс. Предположим наш кеш отдает нам пустой список, значит и наш источник данных тоже вернет пустой список. Все просто и понятно. С помощью мокито библиотеки пишем этот код — when(something.doCode).thenReturn(mockedData). Когда мы будем вызывать метод у нашего замокированного класса, тогда вернуть замокированные данные. Следующий тест мы пишем для случая, если наш кеш вернул 1 данные в списке, тогда мы ожидаем, что и источник вернет нам 1 элемент в списке. Это очень просто и смысла в этом нет, скажет мне сноб, а я отвечу — ок, идем дальше. Метод, который отдает детали. По сути он берет из списка по индексу элемент. И здесь возникают вопросы, а что будет, если наш список содержит 1 элемент или нисколько, а метод пробует взять по индексу 1, т.е. второй. Мы ожидаем исключение и для этого пишем наш тест, который как результат работы проверит выброс исключения. Но чтобы быть уверенным, что этот метод работает нормально в позитивном кейсе, то мы напишем также и позитивный тест — когда в списке 1 элемент и мы берем по индексу 0.

Ок. Это был очень простой тест. Давайте посмотрим на тест для другого источника данных — из облака — CloudLaunchesDataStoreTest. Здесь все намного сложнее. Во-первых нам нужно замокировать несколько классов — менеджер соединения, сервис для получения данных из облака и кеш. Первым делом, мы проверим, что нельзя брать данные из облака для детального экрана. Мы напишем тест, который должен закончиться исключением. Дальше мы должны проверить, что если нет соединения с интернетом, то так же должно выброситься исключение нужного класса. Дальше мы рассмотрим случай, когда что-то пошло не так, во время получения данных из сети.

Далее мы предположим, что соединение с сетью есть, никаких исключений не было выброшено во время получения данных. Но сам ответ от сервера закончился ошибкой, например 404 (или 500 и т.д.). Что мы должны тогда получить? Иключение о том, что сервер недоступен.

А вот вам еще одна ситуация — мы получили ответ 200, но в теле ответа нет ничего, нул. Что будет тогда? По нашей логике мы должны отдать ошибку — сервер недоступен. И уже тогда, когда мы продумали и протестировали все возможные ошибки и случаи, когда что-то пошло не по позитивному сценарию, мы можем начать писать тесты на позитивный и успешный сценарии.

Итак, мы проверяем, что при удачном ответе от сервера нам может прийти пустой список. В нашем случае наше апи говорит что это ОК. Мы мокаем ответ от сервера — пустой список, мокаем манагер соединения, чтоб отдавал тру на вопрос есть ли соединение и все. Готово.

Дальше мы протестируем, что нам пришел ответ от сервера например с 1 элементом в нем и мы должны не только отдать результат Успех, но еще и положить его в кеш. Для этого есть метод у мокито — verify, проверить, что метод у какого-то класса был вызван.

Следующий класс в дата слое — фабрика источников данных LaunchesDataStoreFactoryImpl.

В нем один метод, который принимает на вход всего 2 параметра. По логике, он отдает нужный источник данных исходя из 2 условий, если данные уже лежат в кеше и исходя из входного параметра приоритета. Значит нам нужно написать по крайней мере 4 теста — 2 параметра, влияющих на результат, каждый с 2 состояниями (есть в кеше или нет, приоритет или облако или кеш). И вот мы поочередно мокаем входящие данные и проверяем, что на выход из фабрики источников данных идет нужный источник данных.

Дальше идем в слой домейн. Здесь всего 2 теста. Так как остальные классы слишком простые чтобы на них писать тесты (но вы всегда можете их написать, ведь в боевом проекте тестами нужно крыть все классы, на одной работе условием прохождения функционала было покрытие тестами на 80 процентов, кстати это можно легко узнать с помощью запуска тестов с покрытием — правый клик на зеленой галке — run with coverage).

Первый класс это валидатор года (да да да, по хорошему его надо было уже перебросить в слой презентации). Посмотрим на этот тест. Он принимает на вход строку и отдает 3 значения — null, true, false. По нашей логике, нам не нужно говорить юзеру, что но ввел нечто некорректное, если там всего 3 символа. Поэтому мы напишем тест под этот кейс. В цикле мы проверим все возможные строки меньше 4 символов, они могут содержать цифры, спец.символы и что угодно. Результат должен быть нул.

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

Второй и более важный класс это имплементация главного интерактора — LaunchesInteractorImpl. Он на вход принимает 2 класса в конструкторе, репозиторий и валидатор, значит мы сможем замокать ответы от методов этих классов легко и просто. Хотя ради интереса в этом классе мы обошлись без Мокито. Итак, метод принимает на вход год и отдает 4 статуса в зависимости от того, что отдает наш репозиторий. Значит у нас должно быть минимум 4 теста для всех статусов. Проверяем статус — нет результатов, значит нам нужно чтобы наш репозиторий отдал пустой список. Если нам нужен успешный статус — мы должны от репозитория отдать список с хотя б 1 элементом. Можно заметить, как мы даем на вход в реализацию интерактора нужную реализацию репозитория.

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

Казалось бы, все просто и понятно и зачем писать тесты, но я вас уверяю — они нужны. Завтра добавится еще один статус, или поменяется репозиторий, нужно чтобы сохранились ваши все остальные случаи в рабочем состоянии. А что может быть проще и легче чем просто запустить все тесты на том же CI (continuous integration). Ведь от вас ничего даже не требуется сделать, запушили код в ветку, все тесты автоматически прогнались. Красота!

И наконец посмотрим на тест в модуле презентации. Там у нас из сложных классов MainScreenViewModel. Логика юай такова, что мы вводим в поле ввода какие-то символы и мы должны начать обрабатывать ввод только спустя 300 миллисекунд после начала. Значит нам нужно в тестах это все проверить. Так как у нас здесь корутины и ливдата, то никаких особых сложностей замокировать нужный скоуп и мейн тред нет. Первое что мы сделаем это протестируем случай с невалидным вводом. Так как вьюмодель зависит от интерактора, то мы замокаем его через Мокито. Так же замокаем все ливдаты.

Итак, мы отдаем на вход невалидную строку, точнее заставлям наш валидатор отвечать нам false. После чего повторяем это все дело 10 раз и через полусекундную задержку проверяем, что на условном экране сработали методы от ливдаты — ошибка показалась 1 раз, результатов не было показано нисколько, ровно как и прогресс не начался крутиться. Ведь по нашей логике прогресс начинает отображаться, только если идет запрос в сеть. А если у нас невалижная строка, то и прогресс не должен отображаться в пустую.

Дальше мы тестируем кейс, когда ввод от юзера недостаточен для начала проверки, т.е. менее 4 символов. Это мы имитируем просто вернув от валидатора/интерактора нул. Проверяем, что все ливдаты не были стригернуты — verify (viewModel.someState, never()).method().

Дальше у нас есть кейс с пустыи данными и мхы проверим его. Значит мы возвращем true от интерактора на валидацию и также вернем статус нет результата. Тогда проверим, что прогресс сработал 1 раз со значением true, 1 раз со значением false, т.е. он отобразился и через время исчез. и после чего сработал метод для показа данных.

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

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

И после всего этого конечно же хочется проверить, что наш делей работает корректно. Т.е. мы будто вводим в поле ввода нужное количество символов, после 300 миллисекунд повторяем. Значит методы должны сработать по 2 раза. Для этого в цикле имитируем ввод несколько раз с разными временными задержками. Итого будет ввод и после 400 миллисекунд еще один. И тогда с помощью мокито times(N) можно проверить, что метод вызывался столько раз, сколько мы предположили.

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

Если у вас есть вопросы по данной теме, то я могу на них ответить в телеграме в личке @JohnnySC или же в чате андроид — t.me/android_ru

Запись опубликована в рубрике Программирование Java. Добавьте в закладки постоянную ссылку.