Юнит-тестирование, TDD

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

У меня вызывает небольшое удивление факт того, что при общении о тестировании у людей отключается критическое мышление. Не воспринимайте меня как фрика говорящего “нахуй тестирование, нахуй TDD” (я умею TDD и успешно применяю его в некоторых случаях). Я за здравый и адекватный подход.

TL;DR: тесты полезная штуковина, но тесты это не единственная полезная штука. Здравый смысл не должен вам позволять писать говно-тесты, даже если их практикуют другие. Каждый тест имеет цену и совокупная цена может оказаться вам не по карману.


Пожалуй стоит начать с того, зачем вообще люди пишут тесты. Некоторые конечно пишут исключительно из требований заказчика о code coverage. Но в целом причина написания тестов — желание убедиться, что код будет корректным (как и сразу после написания, так и некоторое время после). Некоторые добавляют “хороший код — это тестируемый код”. Т.е. пытаются добиться хорошего кода за счет написания тестов. Некоторые заявляют о том, что код покрытый тестами легче рефакторить, что, кхм, не соответствует моему опыту (я бы сказал, что юнит-тесты даже наоборот ухудшают возможности рефакторить код, но об этом потом).


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

  1. Статическая типизация. Эта штука в compile-time (до запуска программы) гарантирует корректность вашего кода с точки зрения типов.
  2. Контрактное программирование. Эта штука во время исполнения бросается исключениями, если не выполняется условие, заданное программистом. Assert на стероидах. В C# контракты могут проверяться в compile-time. Дополнительно очень важной штукой является понятие инварианта. Если вы не умеете рассуждать в терминах инвариантов, то ваша способность писать корректный код с циклами, с рекурсией или с изменяемыми данными сильно ограничена (я не говорю о знании слова, я говорю именно о том, что за ним стоит). См., например, “Code Contracts в .NET 4.0” и “Начать ли использовать контракты?”.
  3. Защитное программирование (которое частично можно считать системой контрактов). В защитное программирование входит всякое типа “если нам передали некорректные данные, то кинуть эксепшен”. Но также сюда причисляют неспособность системы прийти в некорректное состояние. Например, вы можете сделать классы EntityAKey и классы EntityBKey, которые не позволят вам перепутать случайно айдишники двух разных объектов и не позволят даже попытаться подумать о отрицательной айдишнике (см. “From Primitive Obsession to Domain Modelling by Mark Seemann” или “Одержимость элементарными типами”). Или вы можете сделать что-то типа union-type, чтобы у вас получился очень сложный тип, который нельзя сконструировать некорректно.
  4. Формальная верификация — доказательства корректности программ в математическом смысле. Увы, эта тема наиболее неизвестна массам. Самое впечатляющее что я видел из русскоязычных публикаций — это статья “Доказательство некорректности алгоритма сортировки Android, Java и Python”. Это пример мощи этого подхода.
  5. Юнит-тестирование — написание тестов на отдельные функции и методы. Иногда под юнит-тестирование подразумевают тестирование только класса. Для изоляции классов друг от друга обычно применяют моки и стабы.
  6. Интеграционное тестирование — написание тестов, которые проверяют, что модули работают корректно, если их совместить. Т.е. интеграционное тестирование — это не обязательно ходить в бд. И вовсе не обязательно проверять взаимодействие всего со всем.

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

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

Поэтому самое время поговорить о стоимости каждого метода из вышеприведенного списка:

  1. Статическая типизация не бесплатна. Многие языки имеют унылую систему типов, которая в некоторых случаях хуже чем динамическая типизация. Иногда статической типизации у вас просто нет, потому что вы пишите на Javascript в 2011 году (прим.: Typescript был представлен публике в 2012 году, Flow в 2014 году). Статическая типизация требует компиляции, что отнимает время. В целом, вы скорее всего очень привязаны к языку и не можете переключиться на другой язык программирования. Но без статической типизации вы просто задолбаетесь писать тесты, дублирующие систему типов (прим.: скорее всего вы этого делать не будете в надежде на то, что другие тесты всемогущи).
  2. Контрактное программирование не бесплатно. Оно засоряет ваш код контрактами и далеко не всегда вы хотите их видеть. Оно заставляет вас включать мозг и думать, что может быть трудной задачей. Проверка контрактов в compile-time замедляет компиляцию. Проверка контрактов в run-time замедляет ваш код.
  3. Защитное программирование не бесплатно. Корректный код написать проще чем код, который будет всегда корректным. Это требует другого стиля мышления.
  4. Формальная верификация вообще дорогая и непонятная. И применяется в основном в криптографии. Да и развиваться активно это направление стало недавно. Да и мало кого волнует, если какой-то говносайт содержит редкую ошибку, проявляющуюся у одного из тысячи пользователей, в следствии чего у пользователя уезжает на два пикселя кнопка.

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

Но есть еще один стиль гарантий — написание максимально понятного кода и изоляция сложного кода в отдельных частях. По этому пути идут практически все, но он тоже имеет свои ограничения. Даже ORM можно рассматривать как часть этого пути (вся сложность перенесена в ORM). В примитивных случаях вам достаточно пользоваться код-стилями и DRY+KISS+SOLID. В более сложных случаях вам нужно строить примитивные DSL внутри языка, в еще более сложных случаях вам может помочь идея строить полноценный DSL (прим.: это пока что еще не проработанная тема в индустрии, поэтому это пока что дорого).

Но даже примитивного DSL внутри языка хватает, чтобы писать бизнес-логику в стиле “какие выходные данные я хочу получить в зависимости от входных данных”. Ага-ага, это почти описания теста, но только мы используем этот подход для написания работающего кода. Когда у вас есть задача на поднятие code coverage и приходится писать тест на такой метод — это неприятно, ибо вы по сути дублируете код метода.


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

Вы задавались вопросом, зачем проводить такую грань между 1 и много в терминологии? Почему не назвать модульное и интеграционное тестированием одним словом “кодотестирование”? Кто-то говорит о том, что чем меньше покрывает тест, тем быстрее он позволяет найти и изолировать ошибку (т.е. падает меньше тестов). Но какое это дело имеет к названию? Тем более, даже при разработке юнит-теста есть риск, что вы в одном тесте можете пытаться проверить слишком много.

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

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

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


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

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


Итак, у всего есть цена. И у юнит/интеграционных тестов тоже есть цена.

Начнем с самого очевидного: за каждый тест вы платите временем на его написание. И если есть фича, которая не стоит того, чтобы быть реализованной и есть код, который не стоит того чтобы быть написанным, то должны быть и тесты, которые не стоят того, чтобы быть написанными. См. KISS и YAGNI.

А теперь я хочу поговорить о не очевидных моментах.


Тесты писать сложно. Я видел кучу способ налажать с написанием теста. Самая очевидная лажа — написать тест, который ничего не проверяет. И это не только Assert.Equals(result, result).

Написание теста, который гарантировано сломается как только сломается код — это сложно. Code coverage не панацея, потому что code coverage учитывает только строки кода, а не execution path. Если вы напишете два независимых if-оператора, code coverage не отловит разницу между “первый тест заходит в первый if, второй тест заходит во второй if” и “один тест приводит к заходу в оба if-оператора”.

Самая работающая идея, которую я видел — это “Мутационное тестирование” (или вот например, статья “Mutation testing на примере Pitest tutorial”). Также интересно выглядит Property-Based testing, но этот подход распространен пока что только в мире ФП (см. “Основы написания property-based тестов на ScalaCheck” и “An introduction to property-based testing for F#”). Также любопытно и многообещающее выглядит идея генерации тестов на основе методов — см. “Introducing: Code Digger, an extension for VS2012”.

Увы, на практике пока что дешевле и практичнее всего работает ручное мутационное тестирование. Примерно так:

  1. Пишем тесты;
  2. Прогоняем тесты;
  3. Убеждаемся, что они зеленые;
  4. Ломаем код, который тестируется;
  5. Убеждаемся, что они красные (если нет, то тест бесполезен);
  6. Откатываем поломку, возвращаемся на шаг 4, пока не надоест;
  7. Ломаем сам тест;
  8. Убеждаемся, что тест красный (если нет, то тест бесполезен);
  9. Откатываем поломку, возвращаемся на шаг 7, пока не надоест;
  10. Дебажим тест, чтобы проверить, что мы адекватно понимаем ту херню, которую написали.

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


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

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

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


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

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

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


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

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

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

Ах да, тесты должны проходить код-ревью, иначе вы успеете написать сотню говно-тестов. И гляньте, пожалуйста, на количество нарушений DRY в ваших тестах.


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

Часто это происходит потому что инфраструктура не очень развита. Например, поднятие тестовой бд может занимать минуту.

Еще есть забавный момент в том, что тесты могут запускаться долго, потому что делают одни и те же дорогие действия (например, в TestInitialize). Потому что кто-то сказал “тесты должны быть независимы друг от друга”, но никто не услышал продолжение “чтобы их можно было запускать отдельно и статус теста не менялся в зависимости от порядка запуска”. Я в таких случаях делал так:

  1. В общей инициализации теста подымаем транзакцию в бд. Если транзакция уже была поднята — идем на шаг 4;
  2. Выполняем общие действия с бд для тестов класса;
  3. Вычисляем что-то комплексное и ложим в статическую переменную, чтобы получить аналог кэша;
  4. Делаем чекпоинт;
  5. Прогоняем тест;
  6. На cleanup-шаге теста откатывамся до чекпоинта;
  7. Если случилась какая-то странная ошибка, то откатываем всю транзакцию и возвращаемся на шаг 1.

Эта система показала себя очень хорошо, оно реально экономит время выполнения тестов, но такую систему нетривиально написать (первое время у меня не было шага 7).

Далеко не все люди знают например о такой штуке как TestCategoryAttribute. Этот атрибут позволяет группировать тесты (причем вы можете навешать несколько категорий), что позволяет раннить только те тесты, которые могли поломаться. Причем группировка по тест-классу не позволяет этого добиться в полной мере, потому что зачастую в TestInitialize приходится выносить общие действия. А это приводит к тому, что несколько тест-классов проверяют одну и ту же функциональность.


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

Если вы не понимаете зачем вам DI и чистота кода — ваш код не станет понятнее и более поддерживаемым.

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


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

Так вот, есть другие способы проектировать. Я предпочитаю бумагу и ручку (если их нет, мне хватает мозгов, notepad и draw.io).

Я видел людей, которые походу на полном серьезе считают TDD панацеей. Самое забавное, что даже автор TDD не считает, что этот подход подходит всем разработчикам под все задачи. Считаете меня идиотом? Спорьте с Кентом Беком. Ну или там с Сергеем Тепляковым: “Размышления о TDD”, “Is TDD Dead. Часть 5”.


P.S: Возможно, эта статья покажется вам чересчур однобокой. Но я реально видел много говно-тестов.


P.P.S: Из обсуждений статьи вк решил отдельно написать одну важную мысль.

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

Like what you read? Give Viktor Love a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.