Введение
Мой новый рабочий проект радует потоком инсайтов :) На этой неделе откровением для меня стала мысль о том, что TDD (Test-Driven Development) - это безмасштабная штуковина, если немного “поиграть” с ее основополагающими принципами. Сейчас поясню.
TDD (если я правильно понимаю канон) рассматривает исключительно процесс написания кода для одного исполняемого процесса, т.е. не поднимается на уровень взаимодействия нескольких сервисов или, скажем, фронта и бэка. Поэтому многие “постулаты” сформулированы довольно категорично и с привязкой именно к этому масштабу. Мне кажется, что можно попробовать выделить некоторый базовый набор “постулатов”, ослабить их формулировки и тогда окажется, что они применимы к любому масштабу. Наверное :)
Провальный тест на минимальное приращение функциональности
Главное правило TDD - это обязательное наличие провального теста перед началом внесения изменений в код. Такой тест позволяет зафиксировать требуемое поведение, а также сформулировать гипотезу относительно того, какими должны быть интерфейсы, позволяющие это поведение реализовать. Фиксация поведения нам пригодится впоследствии, когда будем рефакторить, а вот гипотеза про интерфейсы важна прямо сейчас, пока никакого кода еще не написано, так как она стимулирует обсуждение. На уровне отдельных классов это могут быть вопросы типа “Нам точно нужно передавать этот объект в этот метод?”, а на уровне отдельных пользовательских историй - типа “Нам точно подходит этот эндпойнт?” или “Может быть, связывать эти две сущности на предыдущем этапе процесса?”.
На более высоких уровнях тест может быть представлен в виде концепции системы или варианта использования, но сути это не меняет - сначала у нас должно быть зафиксированное описание желаемого результата.
У главного правила есть дополнение мелким шрифтом, многие его упускают из виду. Это требование двигаться очень маленькими шагами, до смешного крохотными. Обычно это оправдывают легкостью поиска ошибок - если после маленького изменения какой-то тест начал “падать”, значит, ошибка где-то в этом маленьком изменении. Найти ошибку в условных трех строчках кода гораздо легче, чем в пяти новых классах по 100 строк каждый. Это действительно так, но это не вся польза от маленьких шагов. По-настоящему маленькие, атомарные изменения позволяют проверить все предположения, лежащие в основе кода, потому что каждое требует написания теста, а каждый тест - это гипотеза и обсуждение этой гипотезы.
У меня есть ощущение, что на всех системных уровнях возможно сформулировать минимальное приращение: на уровне кода это может быть поддержка коллекций вместо одного элемента, на уровне историй - возможность выбора чего-то вместо фиксированного значения, на уровне системы в целом - реализация сценария работы только для базового набора сущностей.
Для того, чтобы двигаться маленькими шагами, нужна хорошая дисциплина, потому что это очень контринтуитивная практика. Лично мне в этом помогают разного рода чек-листы, например, на уровне юнит-тестов хорошо работает акроним ZOMBIES, а на уровне историй - Шаблоны разбиения историй на более мелкие.
Для первого правила итог таков: что бы мы ни затевали, сначала нужно сформулировать способ проверки результата, при этом сократив скоуп планируемой работы до абсолютного минимума. То есть, мы хотим реализовать минимально возможное приращение функциональности и при этом точно знаем, как мы проверим правильность реализации. От уровня рассмотрения суть этого правила не зависит.
Минимум кода для прохождения теста
Итак, у нас есть минимальное приращение функциональности, описанное в виде теста на любом системном уровне, будь то юнит-тест или сценарий тестирования пользовательской истории. Второе правило TDD гласит, что необходимо как можно быстрее добиться прохождения этого теста (не сломав все остальные тесты, естественно), и для этого можно писать самый ужасный, грязный и неподдерживаемый код. Quick green excuses all sins - но только на время.
У этого правила тоже есть свои нюансы.
Во-первых, нужно написать ровно столько кода, сколько требуется для прохождения теста. Написать меньше не получится, так как тест не пройдет, а вот больше писать - нельзя по ряду причин. Прежде всего, YAGNI (You Ain’t Gonna Need It - тебе это не понадобится), так как мы еще “не знаем” следующих требований, поэтому лишний код ничем не оправдан. Более того, такой дополнительный код оказывается не покрыт никаким тестом (он, возможно, выполняется во время прогона тестов, но не факт, что проверяется корректность его выполнения), а значит, может содержать ошибки.
Во-вторых, на этом этапе нужно принимать наиболее простые проектные/архитектурные решения для нового кода. Не стоит создавать новые доменные сущности со своим жизненным циклом, если для прохождения теста достаточно булева флага у уже имеющегося класса. Прикручивать к проекту базу данных тоже пока рано, если можно хранить все объекты в памяти.
В-третьих, новый код можно писать сколь угодно плохо, лишь бы заставить тест пройти. На этом этапе можно бесстыдно копипастить куски кода, использовать неэффективные структуры данных и алгоритмы - дозволено все. Главная задача сейчас - реализовать функциональное требование, выраженное в виде теста, а красоту будем (обязательно!) наводить потом.
Я не зря сделал акцент на “новом коде”. Штука в том, что каждый новый тест, сформулированный на прошлом этапе, должен предъявить какое-то новое требование к нашей существующей кодовой базе. Другими словами, каждый новый тест сообщает нам что-то новое о системе. Сэнди Метц в своей чудесной книге 99 бутылок ООП выделяет два типа этой новой информации:
- Уточнение того, что именно должен делать код. Это функциональные (по большей части) требования, они диктуют содержание кода, это тот самый новый код, который мы должны написать.
- Уточнение требований к изменяемости кода. Это архитектурные требования, они определяют форму / структуру старого кода. Другими словами, они поясняют, как должен был быть написан старый код, чтобы поддержать простую интеграцию нового кода.
Так вот, прежде, чем писать новый код, нужно удовлетворить архитектурные требования. Кент Бек однажды сформулировал это так: First make the change easy, then make an easy change. Сначала нужно подготовить код к внесению изменений. Если на этом этапе обнаружатся какие-то новые концепции предметной области, еще не отраженные в коде, - их нужно ввести. Если новую функциональность будет проще вписать в более общий механизм, чем реализован сейчас, - нужно обобщить существующий механизм. И все это нужно сделать строго до написания нового кода, так как одновременно рефакторить и реализовывать новое поведение - это очень плохая идея.
Такая трактовка и формулировка правила вообще не привязана к масштабу и уровню вносимых изменений. Она одинаково применима и к отдельному классу, и ко всей системе в целом.
Вкратце второе правило TDD таково: (1) определяем самый простой способ реализации нового функционального требования, (2) рефакторим старый код так, чтобы выбранный способ было проще (но не факт, что легче) реализовать, и (3) пишем абсолютный минимум нового кода, достаточный для прохождения теста.
- Тестируемое приращение / изменение функциональности должно быть минимальным по объему. TDD - это история про крошечные шаги, после каждого из которых система работает чуть-чуть иначе.
- Объем вносимых изменений тоже должен быть минимальным, нельзя писать больше кода, чем реально нужно для прохождения теста. YAGNI в полный рост.
- Обязательный рефакторинг, как минимум устранение дублирования, после прохождения теста.
Идея в том, что TDD безмасштабно. То есть, принципы, которые работают на уровне юнит-тестов, вполне работают и на более высоких уровнях, вроде пользовательских историй. Главное в том, чтобы не писать больше кода, чем нужно для прохождения теста. И не придумывать более сложную архитектуру, чем нужна для реализации текущей истории. Ну и затем нужно грамотно создавать / делить истории на как можно более мелкие части.
Заодно подумать про то, как можно в это дело встроить идею дяди Боба о порядке преобразований в коде, возможно, они и к делению историй подойдут.
99 бутылок ООП - вот отсюда взять идею о минимальных шагах изменений, а также об общем отношении к тестам как к требованиям и источнику информации (см. раздел Shameless Green).