Unit-тестирование и TDD
О том, что такое Unit-тестирование и TDD, я в общем-то знал уже давно, года наверное два. Однако знать “в теории” и применять на практике - это оказались очень разные вещи.
Для ленивых ползти в гугл поясню: Unit-тесты это маленькие такие куски кода, которые запускают основной ваш код и проверяют правильность его работы. А TDD (Test Driven Development) - методология, по которой сначала пишется тест, а потом код, который удовлетворит этот тест, ни больше и не меньше. И никакого другого кода просто так! Утром тесты - вечером стулья код.
Однако я очень долго не понимал, как же писать эти самые тесты? Все статьи, все туториалы фреймворков тестирования показывали примеры, достойные Капитана Очевидность, вроде функция запихивает в массив переданный ей аргумент, а мы проверяем что до вызова массив был пустой, а после вызова в нем наш аргумент. Ага, и что? Это же и так очевидно, что оно будет работать, зачем повторяться? Ведь придется переписывать и тест, когда изменится сама функция? Мне и так видно, что она работает, она же такая простая. Окей, есть сложные алгоритмы, и их тестировать автоматически самое оно. Вот как на олимпиадах по программированию: ты фигачишь сложный алгоритм, у которого один вход и один выход. Системы проверки олимпиадных задач по своей сути и являются юнит-тестами. Однако… В самой что ни на есть реальной жизни олимпиадных задач практически не встречается. Задача программиста не запрограммировать вещь-в-себе, а автоматизировать нечто, часто даже объект реального мира. А это значит что в программе в основном не логика, а взаимодействие со внешним миром: базами данных, управляемым оборудованием, сетью, портами ввода-вывода. У программ уже нет “вывода” как такового, а результатом их работы становятся побочные эффекты в виде изменения состояния внешних объектов, которые тестированию подвергнуть очень сложно или вообще практически нереально.
Есть на свете книга такая: RSpec Book, которая освещает процессы тестирования, TDD, BDD и прочего в рамках Ruby и сопуствующих инструментов. И именно она дала мне то самое сакральное знание о самой сути автоматического тестирования. Дело в том, чтобы перестать думать о тестах как о “тестах”, то есть о кусках кода, проверяющих корректность программы. Да, они это тоже делают, но в TDD их первичное назначение иное - документирование и специфицирование кода еще до его написаия. В книге их даже называют не “тестами”, а “примерами” (examples) использования кода. И вот тут как раз и не нужно бояться “Капитанности” своих тестов, потому что вы их пишете в первую очередь. Если вы пишете тест, в котором сначала массив пустой, а после запуска функции у вас в массиве появился аргумент, то вы написали спецификацию того, как должна вести себя функция. Очевидно, что функция должна запихивать аргумент в массив. Запустите свой пример, посмотрите, что он еще не работает, а потом бегом реализовывать эту функцию так, чтобы она спецификации соответствовала. Запустите еще раз, и если он работает, пишите следующий пример. И поехали: пример, код, пример, код… Завораживает. Не забывайте рефакторить иногда, но только если все примеры успешно проходят. Так вы будете уверены, что ваш код всё еще соответствует спецификации после рефакторинга.
А что делать с внешним миром и побочными эффектами? А внешний мир заменяется mock и stub-объектами. Это такие заглушки, которые ведут себя похоже на внешние интерфейсы, но на самом деле никаких побочных эффектов не имеют, и предназначены только для сбора данных о правильности использования этих штук. А как подменить реальную вещь на заглушку? Для этого надо организовать код так, чтобы он мог принимать, например, в параметрах либо mock, либо реальный объект. Такой подход заставляет намного тщательней продумывать ваши API в коде, что улучшает архитектуру и поддерживаемость проекта.
А применять полученные знания я начал только вчера. Этому поспособствовал мой коллега, который закрыл тикет об ошибке в коде, закоммитив что-то невнятное, но на самом деле ничего не исправив. Я был недоволен, и на вопрос самому себе “а как сделать, чтобы такого не повторилось? как проверить, что проблема действительна исправлена?”, ответ пришел сразу: автоматическое тестирование. Проект у нас на PHP, и, хоть в самом распространенном фреймворке unit-тестирования нет такого синтаксического сахара, как в RSpec, но вполе юзабельно. Взял и написал два теста. Написать-то я написал, но в первый раз же десу! Надо быть уверенным, что они потом пройдут, когда подходящий код будет написан. Запустил. Он ругается на отсутствие функции. Написал функцию, запустил. Ругается на неверный результат. Подставил возврат тупо константы. Первый тест прошел! Написал еще тест… И пошло-поехало, перефигачил по TDD весь код, который должен был фиксить коллега. И это было классно!
Но это еще не все, впереди еще много TDD, BDD, всякие Agile, экстремальное программирование… Удачи мне в этом! ;)