Что такое тестируемый код? Каких правил стоит придерживаться для его написания? Как начать писать такой код, если кодовая база к этому не готова?
Статья с большим количеством примеров кода и иллюстраций, в основе которой – выступление Антона на конференции Mobius 2017 в Питере. Антон является разработчиком Android-приложений в Juno, и в своей работе затрагивает множество смежных технологий. Этот доклад не об Android и не о Kotlin, он о тестировании в целом, об идеях, которые лежат над платформой и над языком и которые могут быть адаптированы к любому контексту.
Для начала стоит определиться с тем, зачем мы пишем или хотим писать тесты для своего кода. Причин может быть несколько:
И, возможно, самая важная причина – для того, чтобы проект мог долго жить и развиваться (то есть изменяться). Под развитием имеется в виду добавление новых фич, исправление ошибок, рефакторинг.
Когда наша кодовая база растет, шансы сделать ошибку повышаются, потому что база становится более сложной. А когда она выходит в продакшн, цена ошибки увеличивается. В результате нередко возникает страх модификаций, с которым очень сложно бороться.
Вот две глобальные задачи, которые мы решаем, когда пишем долгоживущий проект:
Что же может пойти не так при попытке написания теста? Часто система просто бывает к этому не готова. Она может быть настолько связана с соседними частями, что мы не можем задать какие-то входные параметры, чтобы проверить, что все работает корректно.
Чтобы избежать таких ситуаций нужно писать код правильно, то есть делать его тестируемым.
Что такое тестируемый код? Чтобы ответить на этот вопрос, нужно для начала разобраться в том, что такое тест. Скажем, есть система, которую надо протестировать (SUT – System Under Test). Тестирование – это передача неких входных данных и валидация результатов на соответствие ожидаемым. Тестируемый код означает, что у нас есть полный контроль над входными и выходными параметрами.
Чтобы сделать код тестируемым, важно придерживаться трех правил. Давайте рассмотрим каждое из них подробно на примерах.
Посмотрим на тестирование функции (некой функции в вакууме, которая принимает N аргументов и возвращает какое-то количество значений):
И есть функция, которая не является чистой:
Рассмотрим, какие здесь входы. Во-первых, префикс, который передается как аргумент. Также входом является значение глобальной переменной, потому что оно тоже влияет на результат выполнения функции. Результатом функции является возвращаемое значение (строка), а также увеличение глобальной переменной. Это выход.
Схематически это выглядит так, как на рисунке ниже.
У нас есть входы (явный и неявные) и выходы (явный и неявные). Чтобы из такой функции сделать чистую функцию, нужно убрать неявные входы и выходы. В этом случае она контролируемо тестируется. Например, так:
Иными словами, функцию просто протестировать, если все ее входы и выходы переданы явно, то есть через список аргументов и возвращаемые значения.
Для того чтобы понять второе правило, предлагаю подумать о модуле как о функции. Предположим, модуль – это функция, вызов которой растянут во времени, то есть часть входных параметров переданы в какой-то момент времени, часть – в следующей строчке, часть – через какой-то тайм-аут, потом еще какая-то часть и т.д. И то же самое с выходами: часть сейчас, часть – чуть позже и т.д.
Как могли бы выглядеть входы и выходы такой функции-модуля? Давайте попробуем сначала посмотреть на код, а потом сделаем более общую картину:
Сам факт вызова конструктора такого класса является входом нашей функции, и передача строки на выход, очевидно, тоже является входом. Факт вызова какого-то метода нашего класса тоже будет являться входом функции, потому что от того, вызвали метод или нет, будет зависеть наш результат.
Получение какого-то значения из явной зависимости тоже является входом. Я называю зависимость явной, если она до использования была передана через API модуля.
Получение какого-то входа из неявной зависимости – это тоже инпут.
Перейдем к выходам. Возвращение некоего значения из поля – это выход. Модификация этого значения является выходом функции, так как мы можем потом проверить ее извне.
Модификация какого-то внешнего состояния тоже является выходом. Она может быть явной, как здесь:
Или неявным, как здесь:
Теперь давайте обобщим.
Входами такой функции-модуля могут быть:
Примерно то же с выходами:
Выходами функции-модуля могут быть:
Если мы таким способом определяем модуль, то мы видим, что процесс тестирования модуля, то есть написанный на этот модуль тест, является вызовом этой функции и валидации результатов. То есть то, что мы пишем в блоках given и when (если мы используем given и when-аннотацию), – это процесс вызова функций, а then – процесс валидации результатов.
Таким образом модуль становится простым для тестирования, если все его входы и выходы переданы либо через API модуля, либо через API его явных зависимостей.
Даже при наличии явных аргументов и явных зависимостей мы все равно не получаем полный контроль, и вот почему.
Например, в модуле есть явная зависимость. Модуль не делает ничего, кроме умножения ее на три и записи в какое-то поле.
Пишем на это тест:
Как-то подготавливаем наш модуль, берем у него значение поля Tripled, записываем в результат, ожидаем, что он будет 15, и проверяем, что 15 равняется результату:
Самый большой вопрос в том, как мы подготовим нашу явную зависимость для того, чтобы сказать, что она возвращает пятерку и нам нужно в результате получить 15? Это сильно зависит от того, чем является явная зависимость.
Если явная зависимость – это синглтон, то в тестах мы ему не можем сказать: «Возвращай пятерку!», потому что код уже написан, и код в тестах мы модифицировать не можем.
Соответственно, тест у нас не работает – мы не можем передать туда нормальную зависимость.
То же самое с final-классами – мы не можем модифицировать их поведение.
Последний и хороший случай, когда явная зависимость – это интерфейс, который имеет какую-то реализацию:
Тогда мы можем уже в тесте подготовить этот интерфейс, создать тестовую реализацию, которая будет возвращать пятерку и, наконец, передать его в наш класс-модуль и выполнить тест.
Иногда функции являются приватными, и тут нужно посмотреть, что такое приватная реализация, и убедиться, что в ней нет неявных зависимостей, что там ничего не приходит из синглтонов, еще из каких-то неявных мест. И тогда в принципе не должно быть проблем с тем, чтобы протестировать код через публичный API. То есть если публичный API полностью описывает входы и выходы (других нет), то публичного API хватает де-факто.
Мне сложно представить тестируемый код без какой-то архитектуры, поэтому как пример буду использовать MVP. Тут есть пользовательский интерфейс, слой View, модели, где условно собрана бизнес-логика, прослойка платформенных адаптеров (предназначены для изоляции моделей от API), а также платформенные API и third-party API, которые не связаны с пользовательским интерфейсом.
Тестируем мы здесь Model и Presenter, потому что они полностью изолированы от платформы.
У нас есть класс и самые разные явные входы: строка на вход, лямбда, Observable, вызов метода, а также все то же самое, сделанное через явную зависимость.
Очень похожа ситуация с выходами. Выходом может быть возвращение какого-то значения из метода, возвращение какого-то значения через лямбду, через Observable и через явную зависимость:
Неявными входами и выходами могут быть:
Теперь о каждом из них подробнее.
Мы не можем модифицировать поведение синглтонов в тестах.
Поэтому их нужно выносить как явную зависимость:
В примере ниже мы не вызываем синглтон, а создаем объект класса random. А вот он у себя уже внутри дергает какие-то статические методы, на которые мы никак повлиять не можем (например, текущее время).
Поэтому такие сервисы, которые мы не контролируем, есть смысл выносить за интерфейсы, которые мы могли бы контролировать.
У нас есть некий модуль, который инициализирует storage. Все, что он делает – создает файл по какому-то пути.
Но этот API очень коварный: он при успехе возвращает true, а при наличии такого же файла — false. А нам, к примеру, нужно не просто создать файл, а еще понять: создался он или нет, и если нет, то по какой причине. Соответственно, мы создаем типизированную ошибку и хотим вернуть ее на выход. Или же, если ошибки нет, то вернуть null.
Кроме этого, этот API кидает два исключения. Мы их кетчим и, опять же, возвращаем типизированные ошибки.
Ок, обработали. А теперь хотим протестировать. Проблема в том, что создание файла – это штука, которая имеет побочные эффекты (то есть создает файл в файловой системе). Поэтому нам нужно или как-то подготавливать файловую систему, или выносить все, что имеет побочные эффекты, за интерфейсы.
Не сразу очевидно, что это вход, но мы уже выше разобрались, что это так.
К примеру, в модуле есть логика, которая ждет полминуты. Если вы планируете писать на это тест, вы не хотите, чтобы тест ждал полминуты, потому что все юнит-тесты должны проходить в целом за полминуты. Мы хотим иметь возможность контролировать время, поэтому, опять же, всю работу со временем имеет смысл вынести за интерфейс, чтобы это была одна точка в системе, и вы понимали бы, как вы работаете со временем и при необходимости могли крутить время вперед или даже назад. Тогда вы, скажем, сможете протестировать, как ваш модуль поведет себя, если переведут часы.
Это самый коварный неявный вход. Скажем, обычный Presenter берет какую-то метку времени, форматирует ее по строго заданному шаблону (никаких AM или PM, никаких запятых, все вроде бы задано) и сохраняет в поле:
Пишем на это тест. Мы не хотим думать о том, чему это равняется в отформатированном виде, нам проще запустить на это модуль, посмотреть, что он нам выведет, и записать сюда.
Посмотрели, что Mobius начинается 21 апреля в 10 часов:
Ок, запускаем это на локальной машине, все работает:
Запускаем это на CI, а там почему-то Mobius начинается в 7.
На CI другой часовой пояс. Он в часовом поясе UTC+0 и, соответственно, там время отформатируется по-другому, потому что SimpleDateFormat использует часовой пояс, установленный по умолчанию. В тестах мы это не переопределили, соответственно, в CI-серверах, которые находятся в нуле по Гринвичу, у нас другой выход. И этим коварны все входы, которые связаны с местоположением, в том числе:
Говорят, что «серебряной пули нет», но мне кажется, что относительно мокирования она есть. Потому что интерфейсы работают везде. Если вы спрятали свою реализацию за интерфейсом, вы можете быть уверены в том, что в тестах вы сможете ее заменить, потому что интерфейсы заменяются.
Интерфейсы даже иногда помогают что-то сделать с синглтонами. Скажем, в проекте есть синглтон, который GodObject. Вы не можете разобрать его на несколько отдельных модулей сразу, но хотите потихоньку внедрять какой-то DI, какую-то тестируемость. Для этого вы можете завести интерфейс, который будет повторять публичный API этого синглтона либо часть публичного API и сделать так, чтобы синглтон реализовывал этот интерфейс. И вместо того, чтобы использовать в модуле сам синглтон, вы можете передавать как явную зависимость уже этот интерфейс. Снаружи это будет, конечно, передача того же самого GetInstance, но внутри вы уже будете работать с чистым интерфейсом. Это может быть промежуточным шагом, пока все не перешло на модули и DI.
Есть, конечно, и другие альтернативы. Я выше говорил, что нельзя мокировать final-классы, static-методы, синглтоны. Их мокировать, конечно, можно: есть Mockito2 для final-классов, есть PowerMоck для final-классов, static-методов, синглтонов, но с ними есть ряд проблем:
Абстрагирование происходит на слое View и на прослойке платформенных адаптеров.
Слой View – это изоляция UI-фрейморка от модуля Presenter. Тут существует два основных подхода:
Давайте сначала посмотрим на первый вариант: Activity реализует View. Тогда у нас есть некий тривиальный Presenter, который принимает на вход интерфейс вьюшки и вызывает у нее какой-то метод.
У нас есть тривиальный интерфейс View:
И у нас есть Activity, в которой есть вход в виде метода onCreate, и есть реализация интерфейса SplashView, которая уже реализует непосредственно платформенным способом то, что ей нужно сделать, например, отобразить какой-то прогресс.
Соответственно, Presenter мы делаем следующим образом: создаем в OnCreate и в качестве View передаем this. Так многие делают, вполне валидный вариант:
Есть второй вариант – View как отдельный класс. Тут Presenter точно такой же, интерфейс точно такой же, но реализация является отдельным классом, не связанным с Activity.
Соответственно, чтобы он мог работать с платформенными компонентами, ему на вход передается платформенная вьюшка. И там он уже делает все, что нужно.
В этом случае Activity немного разгружается. То есть, вместо того чтобы организовывать интерфейс в ней, остается получение этой платформенной вьюшки и создание SplashPresenter, где в качестве View создается отдельный класс.
На самом деле с точки зрения тестирования два этих подхода одинаковы, потому что мы все равно работаем от интерфейса. Мы создаем мокированное View, создаем Presenter, в который его передаем, и проверяем, чтобы был вызван некий метод.
Разница только в том, как вы смотрите на роли Activity и View. Если вам кажется, что роль View достаточно большая, чтобы не смешивать ее с другими ролями Activity, тогда вынести ее в отдельный класс – это хорошая идея.
Теперь то, что касается абстрагирования от платформы на слое платформенных адаптеров. Платформенные обертки – это изоляция слоя Модели. Проблема в том, что за этим слоем на стороне платформы стоит платформенный API и API третьей стороны, и мы их в общем случае не можем модифицировать, потому что они приходят в разных формах. Они могут приходить как статические методы, как синглтоны, как final-классы и как не final-классы. В первых трех случаях мы не можем влиять на их реализацию, мы не можем подменить их поведение в тестах. И только если они являются не final-классами, мы можем как-то повлиять на их поведение в тестах.
Поэтому вместо того, чтобы использовать такие API напрямую, может иметь смысл создать обертку. Вот здесь используется API напрямую:
Вместо того, чтобы делать так, мы создаем обертку, которая в самом тривиальном случае не делает ничего, кроме проброса методов нашего API третьей стороны.
Мы получили обертку с реализацией, спрятали ее за интерфейсом и, соответственно, в модуле уже вызываем Wrapper, который приходит как явная зависимость.
Помимо гарантированной тестируемости это дает следующее:
Множественные статические вызовы могут быть Design Smell, но это сильно зависит от того, что в этих статических вызовах. Мы подразумеваем, что статические вызовы – это чистые функции. Если они меняют глобальные переменные, то это Smell. Если в них не происходит ничего гиперсложного и вы готовы функциональность этого статического метода покрывать в каждом месте его использования тестами на весь модуль, где он вызывается, то это не Smell, а нахождение баланса. А от правил можно и нужно отступать.
В Android есть ID-шники строк и других ресурсов. Иногда в презентерах или в других местах нам нужно иметь доступ к чему-то, что зависит от платформы. Вопрос в том, как это абстрагировать, ведь R-класс приходит из фреймворка.
Ресурсы – это уже наш интерфейс, это не интерфейс Android, но мы в него передаем все тот же самый эндовый ID-шник. И вот заметьте, что, по сути, это просто эндовый айдишник:
И тут уже вопросы вкуса, достаточно ли вам того, что пришел этот ID, чтобы проверить, что все ведет себя корректно. Нам обычно бывает достаточно.
На мой взгляд, имеет смысл более глубоко работать с этим, только если вы делаете какую-то кроссплатформенную логику. Там механизм доступа к ресурсам будет разным для iOS и Android, и уже гарантированно нужно это изолировать.
У нас есть модуль, у него есть вход. Он внутри себя из этого входа посчитал какое-то состояние, записал в поле.
Все хорошо, мы написали на это тест, и потом у нас появился другой модуль, в котором очень похожая логика.
Соответственно мы видим, что это повторение того же кода, выносим эту логику по подсчету начального состояния куда-то в отдельное место и покрываем ее тестом.
Но как в таком случае мы пишем тесты на оба модуля? В обоих из них нам нужно проверить функционал calculateInitialState, если она является условной частью реализации. Если это достаточно сложная штука, то, возможно, имеет смысл вынести ее как явную зависимость и передать как интерфейс.
Не всегда это имеет смысл, так как при таком подходе мы можем просто проверить, что был вызван метод calculateInitialState с таким-то параметром. То же самое касается внутренних классов, extension-функций (если говорить о Kotlin), static-функций, то есть всего, что является деталью реализации и может дергаться из нескольких мест.
Логично начинать с моделей, то есть с того, что не имеет зависимостей (это или модели без зависимостей, или платформенные обертки). Мы написали их достаточное количество, а затем, используя их как зависимости, мы строим модели, которые принимают их на вход, и так понемногу выстраиваем наш глобальный граф зависимостей.
Это выглядит примерно так, как инструкция по рисованию совы из шуточного руководства для новичков.
В результате вы получите следующее:
Если мы все это качественно сделали, мы можем взять какую-то входную точку из фреймворка (Activity, сервис, broadcast-ресивер…), создать вокруг нее какую-то обертку (в случае с Activity это может быть View), взять наш граф зависимостей, который мы сделали ранее, и создать Presenter. Все зависимости уже удовлетворены, и мы можем создавать Presenter, передавая их на вход через DI.
Когда мы все это сделали, мы можем идти вверх по пирамиде тестирования к интеграционным тестам.
На этом этапе мы берем слои, где мы жестко заизолировались от платформы (обертка и View), и заменяем их на тестовые реализации. После этого мы можем интеграционно тестировать все, что находится между ними (а это иногда бывает полезно).
В конце я хочу привести известную цитату Джошуа Блоха: «Изучение искусства программирования, как и большинства других дисциплин, состоит из изучения правил на первом этапе и изучения того, как их нарушать – на втором».
Выше были изложены именно правила. Важная часть здесь в том, чтобы понимать, как они работают. И если вам нужно нарушить правила, это должно быть осознанное решение. Вы должны знать, какие последствия могут быть из-за нарушения правил. Если вы решаете нарушить правило, вы должны осознанно согласиться с последствиями. Если вы не можете мириться с последствиями, вы должны осознанно их не нарушать.
Если мобильная разработка – ваш основной профиль, вас наверняка заинтересуют вот эти доклады на нашей ноябрьской конференции Mobius 2017 Moscow:
Статья с большим количеством примеров кода и иллюстраций, в основе которой – выступление Антона на конференции Mobius 2017 в Питере. Антон является разработчиком Android-приложений в Juno, и в своей работе затрагивает множество смежных технологий. Этот доклад не об Android и не о Kotlin, он о тестировании в целом, об идеях, которые лежат над платформой и над языком и которые могут быть адаптированы к любому контексту.
Зачем нужны тесты?
Для начала стоит определиться с тем, зачем мы пишем или хотим писать тесты для своего кода. Причин может быть несколько:
- Чтобы получить доверие к коду;
- Для составления документации;
- Чтобы спать спокойно после рефакторинга;
- Чтобы писать код быстрее;
- Чтобы хвастаться перед коллегами.
И, возможно, самая важная причина – для того, чтобы проект мог долго жить и развиваться (то есть изменяться). Под развитием имеется в виду добавление новых фич, исправление ошибок, рефакторинг.
Когда наша кодовая база растет, шансы сделать ошибку повышаются, потому что база становится более сложной. А когда она выходит в продакшн, цена ошибки увеличивается. В результате нередко возникает страх модификаций, с которым очень сложно бороться.
Вот две глобальные задачи, которые мы решаем, когда пишем долгоживущий проект:
- Управление сложностью системы, то есть как сделать систему максимально простой для заданных бизнес-требований;
- Тестирование системы (сегодня мы говорим именно об этом).
Что такое тестируемый код?
Что же может пойти не так при попытке написания теста? Часто система просто бывает к этому не готова. Она может быть настолько связана с соседними частями, что мы не можем задать какие-то входные параметры, чтобы проверить, что все работает корректно.
Чтобы избежать таких ситуаций нужно писать код правильно, то есть делать его тестируемым.
Что такое тестируемый код? Чтобы ответить на этот вопрос, нужно для начала разобраться в том, что такое тест. Скажем, есть система, которую надо протестировать (SUT – System Under Test). Тестирование – это передача неких входных данных и валидация результатов на соответствие ожидаемым. Тестируемый код означает, что у нас есть полный контроль над входными и выходными параметрами.
Три правила написания тестируемого кода
Чтобы сделать код тестируемым, важно придерживаться трех правил. Давайте рассмотрим каждое из них подробно на примерах.
Правило 1. Передавать аргументы и возвращаемые значения явно
Посмотрим на тестирование функции (некой функции в вакууме, которая принимает N аргументов и возвращает какое-то количество значений):
f(Arg[1], ... , Arg[N]) -? (R[1], ... , R[L])
И есть функция, которая не является чистой:
fun nextItemDescription(prefix: String): String {
GLOBAL_VARIABLE++
return "$prefix: $GLOBAL_VARIABLE"
}
Рассмотрим, какие здесь входы. Во-первых, префикс, который передается как аргумент. Также входом является значение глобальной переменной, потому что оно тоже влияет на результат выполнения функции. Результатом функции является возвращаемое значение (строка), а также увеличение глобальной переменной. Это выход.
Схематически это выглядит так, как на рисунке ниже.
У нас есть входы (явный и неявные) и выходы (явный и неявные). Чтобы из такой функции сделать чистую функцию, нужно убрать неявные входы и выходы. В этом случае она контролируемо тестируется. Например, так:
fun ItemDescription(prefix: String, itemIndex: Int): String {
return "$prefix: $itemIndex"
}
Иными словами, функцию просто протестировать, если все ее входы и выходы переданы явно, то есть через список аргументов и возвращаемые значения.
Правило 2. Передавать зависимости явно
Для того чтобы понять второе правило, предлагаю подумать о модуле как о функции. Предположим, модуль – это функция, вызов которой растянут во времени, то есть часть входных параметров переданы в какой-то момент времени, часть – в следующей строчке, часть – через какой-то тайм-аут, потом еще какая-то часть и т.д. И то же самое с выходами: часть сейчас, часть – чуть позже и т.д.
M(In[1], ... , In[N]) -? (Out[1], ... , Out[L])
Как могли бы выглядеть входы и выходы такой функции-модуля? Давайте попробуем сначала посмотреть на код, а потом сделаем более общую картину:
class Module(
val title: String //input
){}
Сам факт вызова конструктора такого класса является входом нашей функции, и передача строки на выход, очевидно, тоже является входом. Факт вызова какого-то метода нашего класса тоже будет являться входом функции, потому что от того, вызвали метод или нет, будет зависеть наш результат.
class Module(
val title: String // input
){
fun doSomething() { // input
// …
}
}
Получение какого-то значения из явной зависимости тоже является входом. Я называю зависимость явной, если она до использования была передана через API модуля.
class Module(
val title: String // input
val dependency: Explicit // dependency
){
fun doSomething() { // input
val explicit = dependency.getCurrentState() //input
// …
}
}
Получение какого-то входа из неявной зависимости – это тоже инпут.
class Module(
val title: String // input
val dependency: Explicit // dependency
){
fun doSomething() { // input
val explicit = dependency.getCurrentState() //input
val implicit = Implicit.getCurrentState() //input
// …
}
}
Перейдем к выходам. Возвращение некоего значения из поля – это выход. Модификация этого значения является выходом функции, так как мы можем потом проверить ее извне.
class Module(
){
var state = "Some state"
fun doSomething() {
state = "New state" // output
// …
}
}
Модификация какого-то внешнего состояния тоже является выходом. Она может быть явной, как здесь:
class Module(
val dependency: Explicit // dependency
){
var state = "Some State"
fun doSomething() {
state = "New State" // output
dependency.setCurrentState("New state") //output
// …
}
}
Или неявным, как здесь:
class Module(
val dependency: Explicit // dependency
){
var state = "Some state"
fun doSomething() {
state = "New state" // output
dependency.setCurrentState("New state") //output
Implicit.setCurrentState("New state") //input
// …
}
}
Теперь давайте обобщим.
In[1], … , In[N]
Входами такой функции-модуля могут быть:
- Взаимодействия с API модуля и API его зависимостей;
- Значения, которые мы в них передали;
- Порядок, в котором мы делали эти взаимодействия;
- Время между этими взаимодействиями.
Примерно то же с выходами:
Out[1], … , Out[N]
Выходами функции-модуля могут быть:
- Взаимодействия с API модуля и API его зависимостей;
- Значения, которые мы в них передали;
- Порядок, в котором мы делали эти взаимодействия;
- Время между этими взаимодействиями;
- Модификация некоего состояния модуля, которое затем можно пронаблюдать, получить извне.
Если мы таким способом определяем модуль, то мы видим, что процесс тестирования модуля, то есть написанный на этот модуль тест, является вызовом этой функции и валидации результатов. То есть то, что мы пишем в блоках given и when (если мы используем given и when-аннотацию), – это процесс вызова функций, а then – процесс валидации результатов.
Таким образом модуль становится простым для тестирования, если все его входы и выходы переданы либо через API модуля, либо через API его явных зависимостей.
M(In[1], ... , In[N]) -? (Out[1], ... , Out[L])
Правило 3. Контролировать заменяемость зависимостей в тестах
Даже при наличии явных аргументов и явных зависимостей мы все равно не получаем полный контроль, и вот почему.
Например, в модуле есть явная зависимость. Модуль не делает ничего, кроме умножения ее на три и записи в какое-то поле.
class Module(explicit: Explicit) {
val tripled = 3 * explicit.getValue()
}
Пишем на это тест:
class Module(explicit: Explicit) {
val tripled = 3 * explicit.getValue()
}
@Test
fun testValueGetsTripled() {
}
Как-то подготавливаем наш модуль, берем у него значение поля Tripled, записываем в результат, ожидаем, что он будет 15, и проверяем, что 15 равняется результату:
class Module(explicit: Explicit) {
val tripled = 3 * explicit.getValue()
}
@Test
fun testValueGetsTripled() {
// prepare Explicit dependency
val result = Module( ??? ).tripled
val expected = 15
assertThat(result).isEqualTo(expected)
}
Самый большой вопрос в том, как мы подготовим нашу явную зависимость для того, чтобы сказать, что она возвращает пятерку и нам нужно в результате получить 15? Это сильно зависит от того, чем является явная зависимость.
Если явная зависимость – это синглтон, то в тестах мы ему не можем сказать: «Возвращай пятерку!», потому что код уже написан, и код в тестах мы модифицировать не можем.
// 'object' stands for Singleton in Kotlin
object Explicit {
fun getValue(): Int = ...
}
Соответственно, тест у нас не работает – мы не можем передать туда нормальную зависимость.
// 'object' stands for Singleton in Kotlin
object Explicit {
fun getValue(): Int = ...
}
@Test
fun testValueGetsTripled() {
val result = Module( ??? ).tripled
val expected = 15
assertThat(result).isEqualTo(expected)
}
То же самое с final-классами – мы не можем модифицировать их поведение.
// Classes are final by default in Kotlin
Class Explicit {
fun getValue(): Int = ...
}
@Test
fun testValueGetsTripled() {
val result = Module( ??? ).tripled
val expected = 15
assertThat(result).isEqualTo(expected)
}
Последний и хороший случай, когда явная зависимость – это интерфейс, который имеет какую-то реализацию:
interface Explicit {
fun getValue(): Int
class Impl: Explicit {
override fun getValue(): Int = ...
}
}
Тогда мы можем уже в тесте подготовить этот интерфейс, создать тестовую реализацию, которая будет возвращать пятерку и, наконец, передать его в наш класс-модуль и выполнить тест.
@Test
fun testValueGetsTripled() {
val mockedExplicit = object : Explicit {
override fun getValue(): Int = 5
}
val result = Module(mockedExplicit).tripled
val expected = 15
assertThat(result).isEqualTo(expected)
}
Иногда функции являются приватными, и тут нужно посмотреть, что такое приватная реализация, и убедиться, что в ней нет неявных зависимостей, что там ничего не приходит из синглтонов, еще из каких-то неявных мест. И тогда в принципе не должно быть проблем с тем, чтобы протестировать код через публичный API. То есть если публичный API полностью описывает входы и выходы (других нет), то публичного API хватает де-факто.
Три правила написания тестируемого кода на практике
Мне сложно представить тестируемый код без какой-то архитектуры, поэтому как пример буду использовать MVP. Тут есть пользовательский интерфейс, слой View, модели, где условно собрана бизнес-логика, прослойка платформенных адаптеров (предназначены для изоляции моделей от API), а также платформенные API и third-party API, которые не связаны с пользовательским интерфейсом.
Тестируем мы здесь Model и Presenter, потому что они полностью изолированы от платформы.
Как выглядят явные входы и выходы
У нас есть класс и самые разные явные входы: строка на вход, лямбда, Observable, вызов метода, а также все то же самое, сделанное через явную зависимость.
class ModuleInputs(
input: String,
inputLambda: () -> String,
inputObservable: Observable<String>,
dependency: Explicit
) {
private val someField = dependency.getInput()
fun passInput(input: String) { }
}
Очень похожа ситуация с выходами. Выходом может быть возвращение какого-то значения из метода, возвращение какого-то значения через лямбду, через Observable и через явную зависимость:
class ModuleOutputs(
outputLambda: (String) -> Unit,
dependency: Explicit
) {
val outputObservable = Observable.just("Output")
fun getOutput(): String = "Output"
init{
outputLambda("Output")
dependency.passOutput("Output")
}
}
Как выглядят неявные входы и выходы и как их преобразовать в явные
Неявными входами и выходами могут быть:
- Синглтоны
- Генераторы случайных чисел
- Файловая система и другие средства хранения
- Время
- Форматирование и локали
Теперь о каждом из них подробнее.
Синглтоны
Мы не можем модифицировать поведение синглтонов в тестах.
class Module {
private val state = Implicit.getCurrentState()
}
Поэтому их нужно выносить как явную зависимость:
class Module (dependency: Explicit){
private val state = dependency.getCurrentState()
}
Генераторы случайных чисел
В примере ниже мы не вызываем синглтон, а создаем объект класса random. А вот он у себя уже внутри дергает какие-то статические методы, на которые мы никак повлиять не можем (например, текущее время).
class Module {
private val fileName = "some-file${Random().nextInt()}"
}
Поэтому такие сервисы, которые мы не контролируем, есть смысл выносить за интерфейсы, которые мы могли бы контролировать.
class Module(rng: Rng) {
private val fileName = "some-file${Random(rng.nextInt()}"
}
Файловая система и другие средства хранения
У нас есть некий модуль, который инициализирует storage. Все, что он делает – создает файл по какому-то пути.
class Module {
fun initStorage(path: String) {
File(path).createNewFile()
}
}
Но этот API очень коварный: он при успехе возвращает true, а при наличии такого же файла — false. А нам, к примеру, нужно не просто создать файл, а еще понять: создался он или нет, и если нет, то по какой причине. Соответственно, мы создаем типизированную ошибку и хотим вернуть ее на выход. Или же, если ошибки нет, то вернуть null.
class Module {
fun initStorage(path: String): FileCreationError? {
return if (File(path).createNewFile()) {
null
} else {
FileCreationError.Exists
}
}
}
Кроме этого, этот API кидает два исключения. Мы их кетчим и, опять же, возвращаем типизированные ошибки.
class Module {
fun initStorage(path: String): FileCreationError? = try {
if (File(path).createNewFile()) {
null
} else {
FileCreationError.Exists
}
} catch (e: SecurityException) {
FileCreationError.Security(e)
} catch (e: Exception) {
FileCreationError.Other(e)
}
}
Ок, обработали. А теперь хотим протестировать. Проблема в том, что создание файла – это штука, которая имеет побочные эффекты (то есть создает файл в файловой системе). Поэтому нам нужно или как-то подготавливать файловую систему, или выносить все, что имеет побочные эффекты, за интерфейсы.
class Module (private val fileCreator: FileCreator){
fun initStorage(path: String): FileCreationError? = try {
if (fileCreator.createNewFile(path)) {
null
} else {
FileCreationError.Exists
}
} catch (e: SecurityException) {
FileCreationError.Security(e)
} catch (e: Exception) {
FileCreationError.Other(e)
}
}
Время
Не сразу очевидно, что это вход, но мы уже выше разобрались, что это так.
class Module {
private val nowTime = System.current.TimeMillis()
private val nowDate = Date()
// and all other time/date APIs
}
К примеру, в модуле есть логика, которая ждет полминуты. Если вы планируете писать на это тест, вы не хотите, чтобы тест ждал полминуты, потому что все юнит-тесты должны проходить в целом за полминуты. Мы хотим иметь возможность контролировать время, поэтому, опять же, всю работу со временем имеет смысл вынести за интерфейс, чтобы это была одна точка в системе, и вы понимали бы, как вы работаете со временем и при необходимости могли крутить время вперед или даже назад. Тогда вы, скажем, сможете протестировать, как ваш модуль поведет себя, если переведут часы.
class Module (time: TimeProvider) {
private val nowTime = time.nowMillis()
private val nowDate = time.nowDate()
// and all other time/date APIs
}
Форматирование и локали
Это самый коварный неявный вход. Скажем, обычный Presenter берет какую-то метку времени, форматирует ее по строго заданному шаблону (никаких AM или PM, никаких запятых, все вроде бы задано) и сохраняет в поле:
class MyTimePresenter(timestamp: Long) {
val formattedTimestamp = SimpleDateFormat("yyyy-MM-dd HH:mm").format(timestamp)
}
Пишем на это тест. Мы не хотим думать о том, чему это равняется в отформатированном виде, нам проще запустить на это модуль, посмотреть, что он нам выведет, и записать сюда.
class MyTimePresenter(timestamp: Long) {
val formattedTimestamp = SimpleDateFormat("yyyy-MM-dd HH:mm").format(timestamp)
}
fun test() {
val mobiusConfStart = 1492758000L
val expected = ""
val actual = MyTimePresenter(timestamp).formattedTimeStamp
assertThat(actual).isEqualTo(expected)
}
Посмотрели, что Mobius начинается 21 апреля в 10 часов:
class MyTimePresenter(timestamp: Long) {
val formattedTimestamp = SimpleDateFormat("yyyy-MM-dd HH:mm")
.format(timestamp)
}
fun test() {
val mobiusConfStart = 1492758000L
val expected = "2017-04-21 10:00"
val actual = MyTimePresenter(timestamp).formattedTimeStamp
assertThat(actual).isEqualTo(expected)
}
Ок, запускаем это на локальной машине, все работает:
>> `actual on dev machine` = "2017-04-21 10:00" // UTC +3
Запускаем это на CI, а там почему-то Mobius начинается в 7.
>> `actual on CI` = "2017-04-21 07:00" // UTC
На CI другой часовой пояс. Он в часовом поясе UTC+0 и, соответственно, там время отформатируется по-другому, потому что SimpleDateFormat использует часовой пояс, установленный по умолчанию. В тестах мы это не переопределили, соответственно, в CI-серверах, которые находятся в нуле по Гринвичу, у нас другой выход. И этим коварны все входы, которые связаны с местоположением, в том числе:
- Валюта
- Формат чисел
- Часовой пояс
- Локали
Как мокировать зависимости в тестах
Говорят, что «серебряной пули нет», но мне кажется, что относительно мокирования она есть. Потому что интерфейсы работают везде. Если вы спрятали свою реализацию за интерфейсом, вы можете быть уверены в том, что в тестах вы сможете ее заменить, потому что интерфейсы заменяются.
interface MyService {
fun doSomething()
class Impl(): MyService {
override fun doSomething() { /* ... */ }
}
}
class TestService: MyService {
override fun doSomething() { /* ... */ }
}
val mockService = mock<MYService>()
Интерфейсы даже иногда помогают что-то сделать с синглтонами. Скажем, в проекте есть синглтон, который GodObject. Вы не можете разобрать его на несколько отдельных модулей сразу, но хотите потихоньку внедрять какой-то DI, какую-то тестируемость. Для этого вы можете завести интерфейс, который будет повторять публичный API этого синглтона либо часть публичного API и сделать так, чтобы синглтон реализовывал этот интерфейс. И вместо того, чтобы использовать в модуле сам синглтон, вы можете передавать как явную зависимость уже этот интерфейс. Снаружи это будет, конечно, передача того же самого GetInstance, но внутри вы уже будете работать с чистым интерфейсом. Это может быть промежуточным шагом, пока все не перешло на модули и DI.
interface StateProvider {
fun getCurrentState(): String
}
object Implicit: StateProvider {
override fun getCurrentState(): String = "State"
}
class SomeModule(stateProvider: StateProvider) {
init {
val state = StateProvider.getCurrentState()
}
}
Есть, конечно, и другие альтернативы. Я выше говорил, что нельзя мокировать final-классы, static-методы, синглтоны. Их мокировать, конечно, можно: есть Mockito2 для final-классов, есть PowerMоck для final-классов, static-методов, синглтонов, но с ними есть ряд проблем:
- Они чаще всего сигнализируют о проблемах с дизайном (это касается, в основном, PowerMоck)
- Они могут перестать работать в какой-то момент, например, на 1501 тесте, поэтому лучше сразу думать об архитектуре, пригодной для тестирования, и не использовать такие фреймворки.
Абстрагирование от платформы и зачем это нужно
Абстрагирование происходит на слое View и на прослойке платформенных адаптеров.
Абстрагирование на слое View
Слой View – это изоляция UI-фрейморка от модуля Presenter. Тут существует два основных подхода:
- Когда Activity реализует сам интерфейс View;
- Когда View является отдельным классом.
Давайте сначала посмотрим на первый вариант: Activity реализует View. Тогда у нас есть некий тривиальный Presenter, который принимает на вход интерфейс вьюшки и вызывает у нее какой-то метод.
class SplashPresenter(view: SplashView) {
init {
view.showLoading()
}
}
У нас есть тривиальный интерфейс View:
interface SplashView {
fun showLoading()
}
И у нас есть Activity, в которой есть вход в виде метода onCreate, и есть реализация интерфейса SplashView, которая уже реализует непосредственно платформенным способом то, что ей нужно сделать, например, отобразить какой-то прогресс.
interface SplashView {
fun showLoading()
}
class SplashActivity: Activity, SplashView {
override fun onCreate() {
}
override fun showLoading() {
findViewById(R.id.progress).show()
}
}
Соответственно, Presenter мы делаем следующим образом: создаем в OnCreate и в качестве View передаем this. Так многие делают, вполне валидный вариант:
interface SplashView {
fun showLoading()
}
class SplashActivity: Activity, SplashView {
override fun onCreate() {
SplashPresenter(view = this)
}
override fun showLoading() {
findViewById(R.id.progress).show()
}
}
Есть второй вариант – View как отдельный класс. Тут Presenter точно такой же, интерфейс точно такой же, но реализация является отдельным классом, не связанным с Activity.
class SplashPresenter(view: SplashView) {
init {
view.showLoading()
}
interface SplashView {
fun showLoading()
class Impl : SplashView {
override fun showLoading() {
}
}
}
}
Соответственно, чтобы он мог работать с платформенными компонентами, ему на вход передается платформенная вьюшка. И там он уже делает все, что нужно.
interface SplashView {
fun showLoading()
class Impl(private val viewRoot: View) : SplashView {
override fun showLoading() {
viewRoot.findViewById(R.id.progress).show()
}
}
}
В этом случае Activity немного разгружается. То есть, вместо того чтобы организовывать интерфейс в ней, остается получение этой платформенной вьюшки и создание SplashPresenter, где в качестве View создается отдельный класс.
class SplashActivity: Activity, SplashView {
override fun onCreate() {
// Platform View class
val rootView: View = ...
SplashPresenter(
view = SplashView.Impl(rootView)
)
}
}
На самом деле с точки зрения тестирования два этих подхода одинаковы, потому что мы все равно работаем от интерфейса. Мы создаем мокированное View, создаем Presenter, в который его передаем, и проверяем, чтобы был вызван некий метод.
@Test
fun testLoadingIsShown() {
val mockedView = mock<SplashView>()
SplashPresenter(mockedView)
verify (mockedView).showLoading()
}
Разница только в том, как вы смотрите на роли Activity и View. Если вам кажется, что роль View достаточно большая, чтобы не смешивать ее с другими ролями Activity, тогда вынести ее в отдельный класс – это хорошая идея.
Абстрагирование на слое Platform Wrappers
Теперь то, что касается абстрагирования от платформы на слое платформенных адаптеров. Платформенные обертки – это изоляция слоя Модели. Проблема в том, что за этим слоем на стороне платформы стоит платформенный API и API третьей стороны, и мы их в общем случае не можем модифицировать, потому что они приходят в разных формах. Они могут приходить как статические методы, как синглтоны, как final-классы и как не final-классы. В первых трех случаях мы не можем влиять на их реализацию, мы не можем подменить их поведение в тестах. И только если они являются не final-классами, мы можем как-то повлиять на их поведение в тестах.
Поэтому вместо того, чтобы использовать такие API напрямую, может иметь смысл создать обертку. Вот здесь используется API напрямую:
class Module {
init {
ThirdParty.doSomething()
}
}
Вместо того, чтобы делать так, мы создаем обертку, которая в самом тривиальном случае не делает ничего, кроме проброса методов нашего API третьей стороны.
interface Wrapper {
fun doSomething()
class Impl: Wrapper {
override fun doSomething() {
ThirdParty.doSomething()
}
}
}
Мы получили обертку с реализацией, спрятали ее за интерфейсом и, соответственно, в модуле уже вызываем Wrapper, который приходит как явная зависимость.
class Module(wrapper: Wrapper) {
init {
wrapper.doSomething()
}
}
Помимо гарантированной тестируемости это дает следующее:
- Возможность использовать удобный дизайн вместо привязки к дизайну платформенных API;
- Уменьшение сложности входного параметра (Single Responsibility вместо God Object);
- Более легкая смена API в случае необходимости (без переписывания всей кодовой базы).
Множественные статические вызовы могут быть Design Smell, но это сильно зависит от того, что в этих статических вызовах. Мы подразумеваем, что статические вызовы – это чистые функции. Если они меняют глобальные переменные, то это Smell. Если в них не происходит ничего гиперсложного и вы готовы функциональность этого статического метода покрывать в каждом месте его использования тестами на весь модуль, где он вызывается, то это не Smell, а нахождение баланса. А от правил можно и нужно отступать.
Доступ к ресурсам
В Android есть ID-шники строк и других ресурсов. Иногда в презентерах или в других местах нам нужно иметь доступ к чему-то, что зависит от платформы. Вопрос в том, как это абстрагировать, ведь R-класс приходит из фреймворка.
class SplashPresenter(view: SplashView, resources: Resources) {
init {
view.setTitle(resources.getString(R.string.welcome))
view.showLoading()
}
}
Ресурсы – это уже наш интерфейс, это не интерфейс Android, но мы в него передаем все тот же самый эндовый ID-шник. И вот заметьте, что, по сути, это просто эндовый айдишник:
class SplashPresenter(view: SplashView, resources: Resources) {
init {
view.setTitle(resources.getString(R.string.welcome))
view.showLoading()
}
}
interface Resources {
fun getString(id: Int): String
}
И тут уже вопросы вкуса, достаточно ли вам того, что пришел этот ID, чтобы проверить, что все ведет себя корректно. Нам обычно бывает достаточно.
public final class R {
public static final class string {
public static final int welcome=0x7f050000;
}
}
На мой взгляд, имеет смысл более глубоко работать с этим, только если вы делаете какую-то кроссплатформенную логику. Там механизм доступа к ресурсам будет разным для iOS и Android, и уже гарантированно нужно это изолировать.
Что такое деталь реализации
У нас есть модуль, у него есть вход. Он внутри себя из этого входа посчитал какое-то состояние, записал в поле.
class SomeModule(input: String) {
val state = calculateInitialState(input)
// Pure
private fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
Все хорошо, мы написали на это тест, и потом у нас появился другой модуль, в котором очень похожая логика.
class SomeModule(input: String) {
val state = calculateInitialState(input)
// Pure
private fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
class AnotherModule(input: String) {
val state = calculateInitialState(input)
// Pure
private fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
Соответственно мы видим, что это повторение того же кода, выносим эту логику по подсчету начального состояния куда-то в отдельное место и покрываем ее тестом.
class SomeModule(input: String) {
val state = calculateInitialState(input)
// Pure
private fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
object StateCalculator {
fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
Но как в таком случае мы пишем тесты на оба модуля? В обоих из них нам нужно проверить функционал calculateInitialState, если она является условной частью реализации. Если это достаточно сложная штука, то, возможно, имеет смысл вынести ее как явную зависимость и передать как интерфейс.
class SomeModule(input: String, stateCalculator: StateCalculator){
val state = stateCalculator.calculateInitialState(input)
}
interface StateCalculator {
fun calculateInitialState(input: String): String
class Impl: StateCalculator {
override fun calculateInitialState(input: String): String =
"Some complex computation for $input"
}
}
Не всегда это имеет смысл, так как при таком подходе мы можем просто проверить, что был вызван метод calculateInitialState с таким-то параметром. То же самое касается внутренних классов, extension-функций (если говорить о Kotlin), static-функций, то есть всего, что является деталью реализации и может дергаться из нескольких мест.
Как начинать, если наша кодовая база еще не готова
Логично начинать с моделей, то есть с того, что не имеет зависимостей (это или модели без зависимостей, или платформенные обертки). Мы написали их достаточное количество, а затем, используя их как зависимости, мы строим модели, которые принимают их на вход, и так понемногу выстраиваем наш глобальный граф зависимостей.
Это выглядит примерно так, как инструкция по рисованию совы из шуточного руководства для новичков.
В результате вы получите следующее:
- все, что было неявным (синглтоны), станет явным (начнет передаваться через DI);
- вы получите контроль и понимание процесса инициализации;
- модели станет легко тестировать.
Если мы все это качественно сделали, мы можем взять какую-то входную точку из фреймворка (Activity, сервис, broadcast-ресивер…), создать вокруг нее какую-то обертку (в случае с Activity это может быть View), взять наш граф зависимостей, который мы сделали ранее, и создать Presenter. Все зависимости уже удовлетворены, и мы можем создавать Presenter, передавая их на вход через DI.
Когда мы все это сделали, мы можем идти вверх по пирамиде тестирования к интеграционным тестам.
На этом этапе мы берем слои, где мы жестко заизолировались от платформы (обертка и View), и заменяем их на тестовые реализации. После этого мы можем интеграционно тестировать все, что находится между ними (а это иногда бывает полезно).
Вместо заключения
В конце я хочу привести известную цитату Джошуа Блоха: «Изучение искусства программирования, как и большинства других дисциплин, состоит из изучения правил на первом этапе и изучения того, как их нарушать – на втором».
Выше были изложены именно правила. Важная часть здесь в том, чтобы понимать, как они работают. И если вам нужно нарушить правила, это должно быть осознанное решение. Вы должны знать, какие последствия могут быть из-за нарушения правил. Если вы решаете нарушить правило, вы должны осознанно согласиться с последствиями. Если вы не можете мириться с последствиями, вы должны осознанно их не нарушать.
Если мобильная разработка – ваш основной профиль, вас наверняка заинтересуют вот эти доклады на нашей ноябрьской конференции Mobius 2017 Moscow:
- Пустота наследия: стратегия кардинальных улучшений в проекте (Владимир Иванов, EPAM Systems)
- Архитектура сервисного слоя с использованием составных операций (Глеб Новик, Тинькофф)
- Crash репорты Android NDK (Иван Пономарев, Akvelon)
Комментарии (4)
vtvz_ru
09.11.2017 00:22Почему-то очень сложно читать код. Какой-то инородный для меня язык // не критика, просто личные ощущения
Но за статью спасибо. Не так давно понял всю прелесть автоматических тестов. Когда выкатывание в продакшн почти перестало быть ритуалом. Полное покрытие кода всеми сценариями сделать очень сложно, но когда все основное проверено и хорошо работает, как-то крепче спится
IvanBulb
это похоже на обучение куриц яйцами)))