Детали test-first, которых так не хватало +39


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

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

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



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

  1. В общем случае написать тест для функции наперед возможно, только если рассматривать ее как черный ящик.
  2. В общем случае рассматривать в тесте функцию как черный ящик не следует (или даже невозможно).
  3. Из пунктов 1 и 2 немедленно следует, что в общем случае писать тест для функции заранее не следует (или даже невозможно).
  4. Что же делать?

1. Ненаписанную функцию можно тестировать только как черный ящик


Термин test-first тесно связан с другим, куда более популярным на сегодняшний день: TDD. Не буду останавливаться на отличиях одной техники от другой, достаточно сказать, что test-first — неотъемлемая часть TDD (хотя может применяться и отдельно от него). Далее в статье я буду говорить о test-first, имея, однако, в виду, что все сказанное с минимальными уточнениями справедливо и для TDD.

На момент, когда test-first предлагает мне написать для функции тест, все, что я знаю о ней, — это ее интерфейс. Он может не быть окончательным, но, чтобы начать разработку, предполагается определиться хоть с какой-то его версией. Традиционно можно рассматривать две основные части интерфейса: входные и выходные данные. Но следует понимать, что для функции входные данные — это не только параметры, с которыми она вызвана, а выходные — не только то, что она непосредственно возвращает. У функции может быть несколько технических способов возвращать значения: например, обычный return, исключения и запись в параметры (все это может по-разному называться). Кроме того, в качестве входных и выходных данных может также выступать состояние тестируемой системы. (Простейшим примером подобного взаимодействия с состоянием системы может служить функция, манипулирующая глобальными переменными. Несмотря на вырожденность, эта ситуация не является исключительной: точкой взаимодействия могут выступать объекты, из которых вызывается метод, синглтоны, глобальные пулы, базы данных — в любом их виде — и т. д.)

Итак, все знания, которые мне доступны о функции перед началом написания теста, — ее интерфейс в широком смысле слова. Причем в слово «интерфейс» в данном контексте я вкладываю знания не только о том, как функция работает с данными, но и о том, что она с ними делает (мне, само собой, известно назначение функции). Очевидно, что тесты, написанные с учетом только интерфейса, — это тестирование черного ящика: мне недоступна внутренняя логика и исходный код функции хотя бы просто потому, что я еще не разработал эту логику и код еще не написал.

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

2. Ненаписанную функцию не стоит тестировать как черный ящик


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

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

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

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

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

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

Поговорим о каждом из них подробнее.

С черным ящиком неизвестно, что уже протестировано


Позволю себе начать с примера. Я собираюсь написать функцию number_of_german_letters(str), которая возвращает количество букв немецкого алфавита, содержащихся в строке str.

Эта задача, кстати, не так проста, как может показаться. Немецкий алфавит содержит все латинские буквы (A—Z), три буквы с умляутом (A, O, U) и лигатуру эсцет (?). Вот хотя бы несколько вещей, о которых можно забыть подумать: буквы с умляутом в юникоде присутствуют как в виде самостоятельных символов, так и в виде комбинации символа латинской буквы и символа умляута. У буквы ? есть только строчное начертание (если слово с ? записывается заглавными буквами, то ее заменяют на SS), но в юникоде 5.1 есть заглавное начертание эсцет: ?). Уверен, что я что-то еще не учел (например, я просто не знаю, используется ли старая версия лигатуры — ?s — и считать ли это немецким языком).

Сразу возникает вопрос: а есть ли у меня функция, которая проверит, принадлежит ли буква немецком алфавиту (например, is_german_letter)? Если есть и я воспользуюсь ей в number_of_german_letters, то мне не нужно будет заново проверять распознавание немецких букв. Нужно проверить только код, который считает немецкие буквы: то, что он их верно распознает, уже «доказывает» тест на is_german_letter. Повторная проверка не только бесполезна, но и, скорей всего, вредна.

Если вам повторная проверка не кажется вредной, вот несколько аргументов, которые могут вас убедить:

  • Если я перепроверяю работу функции is_german_letter в number_of_german_letters, то по логике должен перепроверять все еще более низкоуровневые функции, в том числе библиотеку работы с юникодом, бессмысленность чего более очевидна.
  • Точно та же логика верна и при использовании самой number_of_german_letters в более высокоуровневых функциях, в том числе в тех, что отдают эти данные пользователю в виде картинки (например), что будет лишней тратой сил. Ошибочность этого подхода особенно хорошо заметна, когда речь идет о функции-обертке, которая не добавляет ничего или почти ничего. Если я обзаведусь функциями number_of_german_letters_int, number_of_german_letters_float и number_of_german_letters_str, то мне придется в каждой из них повторить все тесты для number_of_german_letters (ну и, если следовать той же логике, для всех еще более низкоуровневых функций, включая библиотеку работы с юникодом).
  • Хотя контраргументом может служить то, что у полного перетестирования есть свой бонус. В условиях, когда каждый тест перепроверяет функцию целиком, без знания о ее зависимостях и внутреннем устройстве, тест действительно сообщает, работает ли данная функция. В условиях же проверки только того нового, что функция привносит, любой красный тест может означать неработоспособность любой другой функции (так как зависимости неизвестны, а от «покрасневшей» функции может зависеть сколь угодно много других). Впрочем, обычно такой трактовки результатов вполне достаточно и это не является проблемой — можно просто починить первым то, что сломалось. Полное перетестирование на каждом уровне того не стоит.

Однако, напоминаю, мы имеем дело с тестированием черного ящика, а значит, мы не знаем, будет ли использована функция is_german_letter. Но это знание играет решающую роль в выборе наборов входных данных, о которых говорилось выше. Если is_german_letter используется, строки abc1O и abc1? проверяют фактически одно и то же, т. е. представляют один и тот же набор входных условий (эквивалентность внутри которого постулируется моей гипотезой). Однако если number_of_german_letters определяет «немецкость» букв самостоятельно, вполне можно счесть, что эти строки тестируют разные аспекты функции.

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

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

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

Неизвестны детали алгоритма


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

Интересным примером может служить оптимизация. Код функции может работать иным способом на значениях, внешне кажущихся однородными. Например, умножать на 2n на двоичном процессоре я могу с помощью операции сдвига, а не умножения: такая оптимизация делает необходимыми отдельные проверки, однако сама постановка задачи (перемножение двух чисел) совершенно никак не выделяет степени двойки. Порой исключительность и неоднородность тех или иных значений может быть совершенно неочевидна до реализации.

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

  • При установке времени жизни значения в memcached любое значение больше 60*60*24*30 считается количеством секунд с начала эпохи UNIX, а остальные — количеством секунд с текущего момента.
  • В Ruby строки до 23 символов длиной хранятся в памяти не так, как те, что длинней.

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

3. Выводы из пунктов 1 и 2


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

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

4. Как работать с test-first


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

Что дает нам test-first? Это довольно-таки обширная тема, разные авторы указывают на разные достоинства, сравнительный анализ которых выходит за рамки этой статьи, поэтому я просто приведу неисчерпывающий перечень:

  • Test-first помогает программисту яснее осознать, что должна делать функция, еще перед тем, как он начал ее писать.
  • Программист также может опробовать интерфейс еще перед началом реализации и, возможно, обнаружить в нем какие-то проблемы на ранней стадии, когда отказаться от него еще почти ничего не стоит.
  • Вы, скорее всего, не напишете нетестируемый или плохо тестируемый код, используя test-first.
  • Test-first в целом дисциплинирует разработку: с ним у вас не будет возможности «забыть» о каких-то тестах, а также вам, скорее всего, не придет в голову писать функции по 500 строк (протестировать такую функцию, как правило, — чудовищно трудоемкая задача).

Далее я приведу набор техник, которыми я пользуюсь в своей повседневной работе и которые позволяют мне совместить прелесть и пользу test-first, избегая, однако, тех отрицательных последствий, о которых говорилось в предыдущих пунктах. Они основаны на том, что понятие «тест» включает в себя как компоненты, которые можно написать прежде кода, так и те, которые нельзя. Их надо друг от друга отделить, введя еще один уровень абстракции.

Пишите шаблон теста до функции


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

Поговорим подробнее о том, что я называю таким шаблоном. Строго говоря, любой тест можно представить как код, который итерирует по набору пар (IN, OUT) и проверяет, что при входных данных IN функция выдает выходной результат OUT. Этот набор пар будем далее называть таблицей. Напомню, что речь идет об интерфейсе функции в широком смысле слова (см. пункт 1). На практике IN и OUT могут быть сколько угодно сложными, но в нашем примере с number_of_german_letters это, по всей видимости, будут пары (исходная_строка, количество_букв). Так вот, учитывая все, что было сказано в предыдущих пунктах, составить таблицу до написания кода представляется затруднительным, однако написать код, который будет проверять очередную пару IN и OUT, я могу, зная только интерфейс. Или, говоря менее формально, я могу выбрать, что и как именно я хочу проверять, но еще не могу знать, на каких именно значениях.

Если смотреть на тест как на код, обслуживающий подобную таблицу, то до функции я уже могу написать этот код, но еще не могу наполнить таблицу данными. То есть, повторюсь, я уже знаю, как устанавливать исходные параметры функции и как снимать с тестируемой системы результат ее работы, но я все еще не знаю, какие из этих пар включить в свой тест. Как выглядит такой тест на практике? Применяя этот подход к нашей функции number_of_german_letters, в случае наличия внутренней функции, определяющей «немецкость», мы сможем обойтись малым количеством строк в этой таблице, тогда как, если ее нет, таблицу придется наполнить значительно больше. Но шаблон теста в обоих этих случаях будет одинаков.

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

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

Возвращайтесь к тесту после написания функции


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

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

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

Итак, в качестве вывода можно сказать, что мы превратили test-first в test-template-first, так как разработчику рекомендуется писать до кода не тест, а лишь его шаблон. У меня нет строгого определения шаблона теста, однако суть данного приема должна быть понятна из изложенных пунктов.

Срезайте углы


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

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

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

Главное, что требуется понять: test-first — это не игра в написание кода, это конкретная техника с конкретными целями, и стоит стремиться достигать этой цели, а не просто повторять шаги, описанные в тех или иных статьях.

Этот пункт — не часть парадигмы (напротив, он даже местами предлагает ее нарушать), а лишь совет не гнаться за формальными признаками техники.

Выводы


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

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




К сожалению, не доступен сервер mySQL