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

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

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

Отсюда сформулируем главные вопросы, на которые мы ответим в этой статье:

  • Нужно ли вам писать юнит тесты под ваши задачи? Или лучше выбрать другой тип тестирования и ограничиться только им?

  • Если все же да, то как тогда писать юнит-тесты? Какие есть подходы, и какой лучше выбрать?

Короче, всё как обычно: как и нафига?

Целеполагание

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

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

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

Когда это полезно?

  1. Во-первых, если у вас нет и не предвидится Continuous Delivery, то, возможно, вам и нет смысла писать приёмочные тесты на запущенном приложении. В таком случае быстрые тесты будут иметь преимущество за счёт скорости, простоты реализации, отсутствия необходимости перезапускать приложение и сбрасывать состояние приложения между тестами.

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

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

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

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

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

    Канонический CI подразумевает опору именно на юнит-тесты. Впрочем, мне знаком пример команды, которая практикует CI без использования юнит-тестов, только за счет параллелизации приёмочных тестов и высокой скорости их выполнения на стендах.

Вывод по целеполаганию

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

А когда можно забить на юниты? Если у вас элитные процессы, настоящий CD, несколько поставок в пром в день, но при этом нет супер-активного рефакторинга, и вы не стремитесь к каноничному CI.

Техники реализации юнит-тестов

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

Вариант 1. Моки на моках моками погоняют

Начну с "Лондонской Школы". Точнее, с той её интерпретации, что мне регулярно встречается.

Детройтская школа vs Лондонская школа
Детройтская школа vs Лондонская школа

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

Шикарная иллюстрация этого подхода
Шикарная иллюстрация этого подхода

Недостаток 1. Хрупкие тесты

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

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

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

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

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

Недостаток 2. Ничего не тестируют

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

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

Типовая проблема такого вида тестов
Типовая проблема такого вида тестов

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

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

Вариант 2. Копия приёмочных тестов

Второй поход - полная противоположность. Приложение тестируется буквально от края до края. От входа: контроллеров, консьюмеров и шедуллеров до конечных точек - репозиториев, продюсеров и клиентов. Реальные сервера баз, брокеров и REST-эндпоинтов заменяются разными тестовыми инструментами типа Wiremock и in-memory технологиями. Вот это уже вообще не антипаттерн, а вполне себе жизнеспособный вариант организации тестов. Этот способ, например, весьма успешно использует в своих проектах мой товарищ и коллега, Алексей из Эргономичного кода.

У этого подхода есть несколько очень серьёзных преимуществ: 

  • тесты действительно проверяют то, что нужно, а именно поведение приложения;

  • тесты никак не привязываются к структуре приложения, только к его контрактам, что позволяет без проблем рефакторить;

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

  • если вам не нужны приёмочные автотесты, то это наиболее надёжный вариант тестирования.

Капля дёгтя в цистерне мёда

У этого подхода есть и один неприятный недостаток: из-за работы с очередями, базами и иными технологиями время одного теста в лучшем случае будет составлять примерно 0,1 с. А это значит, что уже даже на средней кодовой базе в 300-400 тестов вы будете ждать полного прогона по 30-40 секунд. А это уже потихоньку приближается ко времени, когда запуск тестов вырывает вас из состояния потока: пока они едут, вы можете отвлечься на телефон, потерять контекст и сломать темп разработки.

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

Итог по второму варианту

В целом, подход весьма и весьма рабочий, а проблемы проявляются лишь в определённых условиях. 

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

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

Вариант 3. Гибридная схема.

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

Гексагональная архитектура

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

Пример гексагональной архитектуры
Пример гексагональной архитектуры

Слой приложения абстрагирован от технологий: баз данных, рест-контроллеров, рест-клиентов, брокеров сообщений и т.п. Тут лежит вся ваша бизнес-логика, то есть, по идее, большая часть вашего приложения.

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

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

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

Адаптерные тесты

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

Простенькие примеры адаптерных тестов на репозиторий, контроллер, рест-клиент, продюсер и консьюмер
Простенькие примеры адаптерных тестов на репозиторий, контроллер, рест-клиент, продюсер и консьюмер

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

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

Тесты слоя приложения

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

Простенький пример сервис-теста. Обратите внимание, что тестирование, где это возможно, ведется через внешние интерфейсы приложения, а не через контроль состояния фейков. System Under Test, то есть слой приложения, воспринимается не как совокупность сервисных и доменных объектов, а именно, как единая система. Ну и конкретный пример я для наглядности сделал слегка неправильным. Тут и регистрация в тесте, хотя, скорее всего, должна быть в сетапе теста. И два ассерта в одном тесте, хотя я бы рекомендовал разделить тест на два отдельных. И поиск зачем-то тестируется в этом тесте, хотя это уже, наверное, тема для совсем отдельного теста. Но, в целом, получился настоящий User Journey. Старайтесь свои тесты писать тоже с точки зрения именно клиента вашего приложения.
Простенький пример сервис-теста. Обратите внимание, что тестирование, где это возможно, ведется через внешние интерфейсы приложения, а не через контроль состояния фейков. System Under Test, то есть слой приложения, воспринимается не как совокупность сервисных и доменных объектов, а именно, как единая система. Ну и конкретный пример я для наглядности сделал слегка неправильным. Тут и регистрация в тесте, хотя, скорее всего, должна быть в сетапе теста. И два ассерта в одном тесте, хотя я бы рекомендовал разделить тест на два отдельных. И поиск зачем-то тестируется в этом тесте, хотя это уже, наверное, тема для совсем отдельного теста. Но, в целом, получился настоящий User Journey. Старайтесь свои тесты писать тоже с точки зрения именно клиента вашего приложения.

Рекомендации:

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

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

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

Итог по гибридной схеме

Гибридная схема является компромиссом между предыдущими двумя вариантами и имеет явные недостатки:

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

  • эти тесты сильно тяжелее объединить с приёмочными;

  • тесты привязаны к контрактам адаптеров и слоя приложения. Если какая-то логика оказалась не в том слое, то перенос этой логики будет сопряжён с переписыванием тестов.

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

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

  • вы знаете точно, что кодовая база не превысит сотни тестов;

  • бизнес-логика - меньшая часть ваших тестов.

Итого по юнит-тестам

По большей части юнит-тесты - это самый простой и дешевый способ иметь полностью автоматическое тестирование:

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

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

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

Из рабочих технологий есть 2 варианта:

  • От края до края - дешевый и простой подход, который позволяет все более-менее надёжно проверить.

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

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


Меня зовут Саша Раковский. Работаю техлидом в расчетном центре одного из крупнейших банков РФ, где ежедневно проводятся миллионы платежей, а ошибка может стоить банку очень дорого. Законченный фанат экстремального программирования, а значит и DDDTDD, и вот этого всего. Штуки редкие, крутые, так мало кто умеет, для этого я здесь - делюсь опытом. Если стало интересно, добро пожаловать в мой блог.