Держим дизайн системы под контролем, используя изолированное юнит-тестирование

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

vvtvaail8shjor4_p78zgx-xykg.png

Сегодня мы поговорим о том,

  • Как делать тестирование сложными зависимостями?
  • Как добиться большого тестового покрытия?
  • Как тесты влияют на дизайн?
  • Что делать, когда много логики в базе?
  • Как соблюсти компромисс между дизайном и «не дизайном».




О спикере: Андрей Коломенский — Agile Coach в компании OnAgile, пишет код уже более 10 лет, работал как над сложными доменными моделями — такими, как платежные системы, так и над разработкой сложных legacy кодов, когда их приходилось спасать и восстанавливать продуктивность работы над ними.

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

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

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

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

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

  • Наш код был слишком жестким. Жесткость (rigidity) говорит о том, что система сопротивляется внесению изменений. Чтобы внести изменения в систему, мы должны затронуть очень много компонент.
  • Наша система была хрупкой (fragility). Это тенденция системы ломаться в самых различных местах при внесении, казалось бы, небольших изменений.
  • Наша система была непереносима — immobility. Это качество системы, которое говорит о том, что мы не можем повторно использовать код из одной системы в другой. Точнее, можем, но затраты на извлечение будут дороже, чем затраты на написание кода с самого начала.
  • Наша система содержала ненужные дупликации (needless repetition) и избыточную сложность (needless complexity).
  • Ясность (opacity) выражения намерений у нас была достаточно низкой, хоть мы на ней и фокусировались. Частая ошибка программистов — когда мы запускаем новый продукт и долго не выходим в продакшн, мы делаем задел на будущее с целью экономии. Из-за этого мы не понимали, как именно должны быть устроены те части системы, которые были заглушками, что конкретно те элементы должны из себя представлять, и какими поведениями и зависимостями обладать. Дисфункции которые я перечислил выше мешали достижению ясности и в остальных частях системы.
  • Последний параметр viscosity я вынес отдельно. Это атрибут качества, который говорит о том, насколько система сопротивляется применению качественных архитектурных решений. Допустим, если тесты проходят час и ни о каком TDD речи быть не может, — это система с огромным показателем viscosity.


Вопрос: как узнать, как должна выглядеть наша система в точке максимально высокой продуктивности?

Тот код, который мы выкинули, был покрыт тестами. Мы заботились о его качестве, рефакторили, но в результате выкинули почти весь.

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

Юнит-тесты — основной инструмент получения обратной связи от системы


Когда мы пишем юнит-тесты, мы как минимум можем гарантировать корректность системы и какое-то небольшое качество ее тестопригодности.

Давайте посмотрим на один тест.

5p4zhmllbtmovucdfnahvj0srke.png

Есть пример в вакууме BuyProductsAction — мы покупаем какие-то продукты. К этому тесту у меня есть вопросы, главный из которых: что я могу узнать о качестве системы из этого теста? Практически ничего: я могу перебирать входные параметры, добавить больше ассертов, каким-то образом обеспечить дополнительные проверки. Причем проверять нужно достаточно много, потому что у нас слишком большое количество характеристик продукта и пользователя и слишком большое количество параметров в базе данных.

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

Что может делать BuyProductsAction внутри себя? Создавать заказ, отсылать уведомления, списывать деньги со счета, начислять процентные бонусы — он может внутри себя делать очень многое.

Что такое интегрированний тест?


Я сейчас уйду от понятия Юнит-тест, потому это оно слишком расплывчато, каждый его понимает по-своему.

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


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

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


Фактически это тест на один метод или на один участок системы, а остальное нетривиальное поведение подменено на моки.

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

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

mbsvs1pjsxk6n1evhoe5ycgusms.png

Мы начинаем с того, что у нас есть какой-то AnaliticsComponent, куда передаются входные параметры. Мы это дело засовываем в сервис-локатор. У нас есть еще какие-то компоненты, поведение которых мы задаем.

Еще компоненты, которые не нужно читать — они просто для того, чтобы был понятен объем.
ym6r4gzt5r0_ouj-rtel8yylulm.png

-1sqqvsc76kadrj0hzyvi87ynfa.png

jj8dkii6fmeg3n5o0xv8bponcka.png

5bh-8z3gng9bjuka7zseaww3swk.png


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

f981gghpqjtasr8y3pps4oris0m.png

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

Главный вопрос, который можно задать, глядя на этот тест, —что я узнаю о качестве системы из этого теста?

Я вижу, что у меня явно нарушается принцип единственной ответственности. Здесь уже не отвертеться субъективными рассуждениями о «понятности». Я вижу, что мне этот тест тяжело писать и читать. Я вижу, что этот тест будет постоянно падать, потому что любое изменение у меня будет вноситься в этот класс. Мне этот вымышленный тест было тяжело готовить даже для презентации.

Если бы мы писали так продакшн код, мы бы просто сошли с ума.

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


Они дают максимально полную обратную связь о моей системе.

on9bew-q7bw3y_1kp5pddour0c0.png

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

Загнивание системы


У нас прошли все юнит-тесты, но QA отдел обнаружил какой-то дефект. Мы понимаем, что проблема находится на стыке двух компонентов, и решаем написать интегрированный тест, потому что так проще, и потому что должны проверить реальную работу — как конкретно работает наша система, потому что все-таки у нас был баг.

Кстати, не рекомендую использовать слово баг. Это мушка, которая
залетела в серваки на заре нашей индустрии. Пример бага из IT
разработки — это когда я скопировал из скайпа SQL запрос, вставил его в код, и он там не работает, потому что скайп вместо пробела вставил
неразрывный пробел. Когда такое происходит — это баг. В остальных
случаях я предпочитаю использовать слово дефект, так
как некорректное поведение программы — это не случайность, а прямая ответственность программистов. Дефект гораздо более мощная формулировка
чем снимающий ответственность «баг». Конкретных пруфов нет, но одна
команда умудрилась просто за счет того, что перешла от слова баг на слово дефект, увеличить качество, просто за счет повышения осознанности и ответственности.


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

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

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

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

kunouncnwtoebstessrgpzqhc2q.png

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

Альтернативный вариант.


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

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

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

x5mx2t7xfqqkywjiklor3h7seh0.png

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

Непрерывно поддерживать высокую продуктивность возможно только
практикуя дисциплину Test Driven Development.

Хотя противоположное мнение тоже довольно распространено (например, видео на это тему).

Test Driven Development


Test Driven Development — это дисциплина. Дисциплина подразумевает ограничение, которое мы на себя накладываем, применяя ее. Это не Red-Green-Refactoring, а ряд конкретных правил:

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


Мы не просто пишем тест, а потом реализацию — не просто Red-Green-Refactoring, это дополнительный ограничения. Это — Test Driven Development — то, что позволяет поддерживать нашу систему в качественном состоянии и поддерживать максимально высокую продуктивность на протяжении длительного периода времени.

Легаси код


Согласно определению, данному Michaels C. Feathers, легаси-код — это код без юнит-тестов. Все просто. Я в своей практике замечаю прямую зависимость между отсутствием тестов и наличием огромнейшего числа проблем с дизайном системы, а также наличием интегрированных тестов и примерно такой же зависимостью с проблемами дизайном системы. Чем меньше маленьких изолированных тестов, тем больше проблем с дизайном системы.

zbul0uu3qjeoqys5kqeqscuyvug.png

Когда тестов нет, это легаси.

Мне нравится другое определение, которое менее точное, но отражает действительность.

Легаси код — это код который страшно изменять.


Дейв Томас как-то сказал примерно следующее: «Я в некоторых случаях вообще не пишу тесты, я и так могу хорошо спроектировать качественную систему.»

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

Роберт Мартин предлагает, чтобы в нашей профессии мы давали клятву, аналогичную клятве Гиппократа для врачей. Я привожу ее здесь для того, чтобы наглядно продемонстрировать, почему юнит-тесты, как минимум, важны для нашей индустрии, и, как максимум, Test Driven Development, как дисциплина, важна для нас, как программистов.

Клятва программиста


С целью защитить и сохранить честь профессии программистов, я обещаю, что в меру своих возможностей и суждений:

1. Я не буду создавать вредоносный код.

Это относится не только к вирусам, но и к коду, который создает убытки для нашей компании. Если мы написали код, который принес компании убытки — это вредоносный код.

2. Код, который я создаю, всегда будет моей лучшей работой.

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

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

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

4. Я буду делать частые, небольшие релизы, чтобы не мешать прогрессу других.

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

5. Я буду бесстрашно и неустанно улучшать свой код при каждой возможности. Я никогда не буду снижать его качество.

Для того, чтобы рефакторить код, нам нужно не бояться его изменять. Мой старый паттерн выглядел следующим образом: я вижу место в системе и 2 способа внести изменения в эту систему: легкий способ («костыль») и сложный способ, когда мне нужно провести серьезный рефакторинг.

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

С юнит-тестами у меня такой проблемы нет. Практикуя Test Driven Development, проблем нет вообще — у меня код всегда корректен. Если там что-то ломается, это боль для меня как для профессионала, потому что я чувствую, что я где-то облажался, так как ситуация была под моим полным контролем. Лично для меня возникновение дефектов — это прямо серьезный вызов.

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

Часто говорят, что Test Driven Development снижает продуктивность, или повышает ее, когда мы его долго практикуем. На самом деле он сохраняет продуктивность на постоянном уровне.

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

Test Driven Development позволяет нам это делать.

7. Я буду постоянно следить за тем, чтобы другие могли подменить меня, а я мог бы подменить их

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

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

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

9. Я никогда не перестану изучать свое ремесло

Маленькое упражнение


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

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

Если вы поймете, что система спроектирована не очень, это повод задуматься о том, чтобы начать применять Test Driven Development.

Простейший пример


csd97cywn7figts10-e-bosjsfw.png

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

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

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

-p8jjd8mfml-m8wbl0wajfzw5is.png

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

Допустим, мы просим интерфейс, вернуть активных пользователей. Не достаточно просто проверить, что у нас в выходящем параметре массив пользователей. Нам нужны именно активные. Поэтому мы пишем тест, в котором мы спрашиваем: «Интерфейс, пожалуйста, дай нам активных пользователей!»

И имитируем реальную работу — потому что нам реальную работу делать не нужно, мы пишем изолированный тест.

cfyinygnxvqnm_6wqikrnt3dtmq.png

Stub-ом возвращаем какое-то значение:

  • Пустой массив —значит, пользователей нет.
  • 1 пользователь — сразу вводим в профиль одного пользователя.
  • Если пользователей много, выводим список таблиц.


Все — мы написали три теста. Теперь наша задача сделать так, чтобы контракт выполнялся в какой-то реализации. Мы реально лезем в базу данных, что-то достаем и проверяем, что мы действительно достаем активных пользователей. Действуем в соответствии с контрактом интерфейса.

0smpzrvy9wdsa3qrjxi8avwvloe.png

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

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

  • правильно спрашиваем,
  • правильно обрабатываем,
  • правильно действуем,
  • правильно проверяем.


Этого достаточно.

The 4 Rules of Simple Design


Эта концепцию придумал Кент Бек. В порядке приоритета система может считаться простой, если она проходит все тесты.

joua5r8wxve8z8rlolcno7q3k1u.png

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

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

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

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

e3ppzmohx7alcjocdgcnbgixq-q.png

Я советую вам добавить этих ребят в друзья в соц. сетях:

  • Kent Beck — основатель движения экстремального программирования и дисциплины Test Driven Development.
  • J.B. Rainsberger — основатель компании JetBrains.
  • Robert Martin — уверен, вы про него знаете, если нет — дайте максимальный приоритет чтению его блога и книг.


Подпишитесь на них, берите с них пример, читайте блоги, изучайте то, что они пишут.

Что дальше?


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

Скорее всего, вы столкнетесь с тем, что вы это сделать не можете, и если вы решите что-то с этим сделать, то советую вам посмотреть на эти ресурсы:

https://cleancoders.com

https://online-training.jbrains.ca/p/wbitdd-01

Спасибо вам за приверженность в прочтении этой статьи.

Вы можете написать Андрею в телеграмм https://t.me/akolomensky для того чтобы задать вопросы или спросить совета по инженерным, процессным или продуктовым кейсам, он обещал всем ответить, поэтому не стесняйтесь.

Хотим заметить, разве не для того, чтобы соответствовать пункту клятвы »Я никогда не перестану изучать свое ремесло», и непрерывно совершенствоваться, мы с вами встречаемся на конференциях. Ведь интенсивный поток идей и случаев из практики, получаемый на конференциях, дает толчок к самосовершенствованию, как минимум на полгода. А чтобы, график развития не выглядел, как плохой график продуктивности, пора получить новый заряд — фестиваль РИТ++ будет 28 и 29 мая, а Highload++ Siberia 25 и 26 июня в Новосибирске. На последнюю до 30 апреля можно успеть подать заявки.

Программу РИТ++ по направлениям, в том числе BackendConf, мы уже начали формировать и можно начинать прикидывать, что вам будет особенно полезно, и бронировать билеты. Например, в тему этой статьи заявка Юрия Бадальянца из 2ГИС на тему «Интеграционное тестирование микросервисов на Scala». Он тоже считает, что одного юнит-тестирования часто недостаточно, и необходимо применять и интеграционное тестирование.

© Habrahabr.ru