Про Unit тесты вообще и TDD в частности

Я уже говорил ранее – я не отношу себя к поклонникам test driven development (TDD), но в то же время считаю, что Unit tests – это вещь, которая архиважна практически для любой разработки.

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

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

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


Потом во взаимной любви пробежала трещина, когда я столкнулся с поклонниками TDD вживую. Этот подход хоть и научил меня многим тестовым приемам (например, как можно тестировать подсистему render или input), но казался мне априори неправильным, поскольку (в сочетании с pair programming) совсем сильно раздувал время разработки. Ну и окончательно я разочаровался в TDD во время GDC 2006, когда представители одной игровой компании активно реаламировали TDD и даже провели цикл разработки подсистемы Shield со значением armor от 0 до 100, но прямо на презентации допустили (и пропустили) грубейшую ошибку в коде.

Скамой привлекательной для меня идеей в юнит-тестировании является тот фактор, что при правильном применении оно позволяет существенным образом сократить количество выражений во время разработки “#%*, я вообще не понимаю, как оно тут работало” до приемлимого минимума. Но чтобы сделать это, нужно немного отвлечься от типичной практики разработки.

Допустим программист реализует функцию int func(int, int), основная задача которой – в ответ на 2 и 2 давать результат 4. Это – правильное поведение функции, которое, собственно, и реализует программист. Unit test на это поведение можно было бы сделать, но то, что функция с такими параметрами вызывается много миллионов раз за игру и ведет себя правильно, само по себе должно убедить нас в том, что функция написана правильно. Вот тут как раз и скрывается главное отличие от TDD – если для реализации функции в TDD надо сначала написать TEST(func(2, 2) == 4), а потом уже реализовывать функционал, то я априори предполагаю, что реализованная функция правильно работает в правильных “тепличных” условиях.

Проблемы на самом деле начинаются, когда в func() придут граничные значения 3 и 1. Если функция реализована как сложение, то результат снова будет равен 4, и поведение программы толком не изменится. Но вот как только программист произведет рефактор функции, и реализует алгоритм через умножение, результат станет 3. И это может привести нас к неожиданным результатам в других подсистемиах.

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

В результате была реализована своя система юнит-тестирования, которая интегрирована с cruise control, запускается после каждого коммита, собирает и прогоняет несколько сотен тестов в разных конфигурациях. Результаты пишутся в xml файл и отправляются по электронной почте. Своя система – решение вызванное необходимостью интергации с отладочными функциями ядра (юнит тест умеет ловить ассерты, и ему можно даже сказать, что следующий тест является запрещенным и ожидаемое поведение системы – это два ассерта и код возврата E_FAIL) и с контролем memory leaks (по умолчанию все тесты в конце должны оставлять глобальный хип неизменным).

Вообще простейшим unit-тестом, который применим почти к любому классу и который срубается несколько чаще, чем хотелось бы, может быть код вида { MyClass a; }. В зависимости от типа класса в нем возникают следующие ошибки.

  1. memory leaks на выходе из области видимости;
  2. падение деструктора, так как после конструирования ожидается вызов метода с инициаизацией полей;
  3. экзотический вариант, котороый тем не менее, случается в многопоточной разработке – это падение из-за слишком быстрого деструктора. В одной из библиотек возникала подобная ситуация, когда приложение бросало исключение и время жизни объекта исчислялось микросекундами. Причина была в том, что созданный в конструкторе второй тред не вышел еще в “рабочее” состояние. К сожалению, программист, писавший библиотеку, не сделал подобного теста, и в результате багу случайно нашли уже при полноценном тестировании приложения.

Резюмирую. Юнит тесты – вещь хорошая и нужная. В большинстве случаев их написание и отладка являются более приятным и быстрым занятием, чем долгая отладка игрового приложения. Ну а сама библиотека после юнит тестирования выходит заметно более чистой, чем без оного. Я как-то набросал несколько десятков тестов на функцию “грамотной” конкатенации имен файлов (с вставкой при не обходимости символа \ ). Юнит тест выявил 5 сбойных вариантов (в основном на неожиданных комбинациях аргументов) – когда поведение функции отличалось от того, что ожидалось. Через сколько лет и в какой ситуации это могло бы случиться в релизном приложении – неизвестно. А так – потенциальные ошибки были найдены и прибиты задолго до их появления в игре.

Наверное, это все же можно считать Test Driven Development. Ну или почти Test Driven?

  • look4awhile

    страшное слово side-effects значительно снижает осмысленность происходящего.

    при этом юнит-тест – вещь хорошая и нужная. там где ей (вещи) хорошо и там где она (вещь) нужна.

    панацеи, впрочем, не получится. совсем никак. хуже _очевидно_ что результат “тест пройден” – исключительно илюзорен. даже если тестируем одну функцию. даже если тест покрывает все входные параметры. потому что они, side-effect-ы.

  • dDIMA

    Side effects надо лечить. “Разделяй и властвуй”.
    Чем больше Side effects, тем сложнее степень подсистемы и приложения в целом. Например, глобальный хип – это чудовищный side effect, с которым надо бороццо всеми возможными способами.

    Unit tests – не панацея, согласен полностью. Но он (в расширенном варианте) позволяет вселять некоторую уверенность в том, что функция делает то что надо и не делает того, чего не надо, даже если ее поставили раком. Иллюзорно? Не согласен.