Компания
950,33
рейтинг
13 января в 11:44

Разработка → Детали test-first, которых так не хватало

Все мы не раз слышали о 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), три буквы с умляутом (Ä, Ö, Ü) и лигатуру эсцет (ß). Вот хотя бы несколько вещей, о которых можно забыть подумать: буквы с умляутом в юникоде присутствуют как в виде самостоятельных символов, так и в виде комбинации символа латинской буквы и символа умляута. У буквы ß есть только строчное начертание (если слово с ß записывается заглавными буквами, то ее заменяют на 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 используется, строки abc1Ö и 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, он помог мне преобразовать мой практический опыт в стройную (мы надеемся) теорию, а также привести текст статьи в порядок.
Автор: @pushtaev

Комментарии (37)

  • +3
    Мне нравится следующий подход:

    0. Продумываем интерфейс функции, пишем заглушку функции.
    1. Пишем простой тест с какими-то произвольными входными параметрами. Убеждаемся, что тест не проходит.
    2. Пишем минимальную реализацию, которая проходит тест.
    3. Думаем, можем ли мы придумать входные данные, исходя из написанной реализации, которые дадут неверный ответ. Если придумали — пишем тест, убеждаемся, что он не проходит, переходим к пункту 2. Если не придумали — переходим к пункту 4.
    4. Рефакторинг.

    Тест, безусловно, получается в стиле «белого ящика». В тестировании в стиле «чёрного ящика» я смысла не вижу. Можно такими тестами назвать функциональные тесты, которые проверяют конечный продукт. Но юнит-тесты неразрывно связаны с конкретной реализацией.
    • +2
      Меня всегда пугала формулировка про «минимальную реализацию». Минимальная реализация — это, может быть, захардкодить ответ на тестовые значения. Я понимаю, что подход предполагает не это, поэтому, думается, надо формулировку как-то переосмыслить.
      • +1
        При проектировании метода вы пишите первый тест, где на входном значении (2,2) вы ожидаете 4. В реализации вы хардкодите return 4. Это третий принцип TDD. Вы пишите только тот минимальный код реализации, чтобы текущий тест прошел вместе со всеми остальными. Потом вы пишите ещё один тест с входными значениями (1,0) ожидая return 1, и уже пишите код сложения.

        Ещё один момент: чтобы не словить ситуацию когда вы подбираете и бесконечно пишите бесполезные тесты вроде (1,0), (1,1), (1,2) и т.д, тест после написания обязательно должен провалиться (второй принцип TDD). Если тест после написания не проваливается значит он не нужен.
        • 0
          и уже пишите код сложения

          Или хардкожу 1+0=1, и все заново — речь про это.
          • 0
            Нет, в данном случае не пройдет (другой тест проверяет что функция должна вернуть 4), но это очень важный момент!

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

            Помните, вы пишите тесты перед реализацией для того чтобы облегчить себе жизнь. Вам не нужно тестировать в стиле «если функция работает с (1,1) то не факт что она будет работать с (100,100), напишу ка я тест». Вы понимаете что вам для удобства и удовлетворения требований бизнеса необходимо от метода конкретное поведение. Вы его фиксируете тестом. Если хотите протестировать ещё и пограничные состояния вы делаете ставку и пишите тест. Если тест в будущем сломается вы выиграли. Если нет, то тест оказался бесполезным. В любом случае тест должен обязательно провалиться, иначе это стопроцентно бесполезный тест.
            • +2
              Я все это прекрасно понимаю :). Я говорю о том, что формулировке «пишите минимальную реализацию» явно не хватает кое-каких деталей (примерно тех, которые вы сейчас добавляете).
              • +2
                Вряд ли вы можете утверждать, что реализация вида
                if (a == 0 && b == 0) return 0;
                if (a == 1 && b == 0) return 2;
                //Повторить для всех a, b принаждежащих R

                будет проще return a + b;

                В этом смысле, a + b — и есть минимальная реализация, и это станет понятно после того, как вы напишете три теста, складывающих разные числа. И если строго следовать принципам, вы должны будете «зарефакторить» кучу if-else в сложение только после пары тестов. на практике получается, что большая часть программистов — не идиоты и сразу видят тенденцию, заменяя операцию на сложение уже после первой реализации.
                • +1
                  К слову о переборе всех частных случаев. Одним из важных требований TDD, предотвращающих «застревание» разработчика является требование о том, что в стадии Green (написании кода реализации) по возможности нужно повышать уровень абстракции решения (ну или в худшем случае, не уменьшать его), что противоречит такому подходу к разработке как «перебрать всевозможные решения».

                  Роберт Си. Мартин в своей серии видеоуроков «Clean Code» (В эпизоде «Advanced TDD») говорил об этих принципах и рассказывал о том, о чем умалчивают три постулата TDD.
                  • 0
                    Да-да, я ровно об этом: явно нужны какие-то дополнительные пояснения, ибо постулаты слишком уж о много о чем умалчивают. Эта статья, кстати, как раз этим и занимается: вносит дополнительные пояснения к постулатам test-first.
                • +1
                  Не будем лукавить — каждую новую строчку в эту череду if-oв, вообще говоря, добавлять проще, чем вот это общее «а+b». Здесь просто вырожденный случай, функция "+" уже написана, так что ее вызов получается короче, чем расширение хардкодингом. Обычно же, все наоборот, пишут функции которых еще нету, так что придется поднапрячься, что приведет к нарушению принципа «самая простая реализация», которая удовлетворяла бы тестам.
                  Вы можете возразить, что «это должно быть сделано на стадии рефакторинга, где это требование уже не действует», но тогда мы возвращаемся сразу к нашей статье, где и сказано о том(в терминах TDD), что непонятно, а после скольких тестов и захардкоженных ветвлений, нужно этим рефакторингом заниматься. Ведь с такой трактовкой, как ваша, настоящая программистская работа, where rubber hits the ground, где мы пишем алгоритм, происходит именно на стадии рефакторинга. И это все равно сводится к тому, что нужное количество тестов будет очевидно после фазы рефакторинга, что будет нарушением test-first. Это точно та же проблема, но просто несколько отложенная.
                  • 0
                    Ведь с такой трактовкой, как ваша, настоящая программистская работа, where rubber hits the ground, где мы пишем алгоритм, происходит именно на стадии рефакторинга.


                    Нет. Рефакторинг подразумевает изменение структуры программы без изменения её поведения. На стадии рефакторинга вы не можете писать новые тесты по определению. Настоящая работа происходит на всех этапах.

                    … что непонятно, а после скольких тестов и захардкоженных ветвлений, нужно этим рефакторингом заниматься


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

                    Не будем лукавить — каждую новую строчку в эту череду if-oв, вообще говоря, добавлять проще, чем вот это общее «а+b».


                    Не проще, потому что у вас дедлайн и вам платят за разработку ценного функционала.

                    TDD это строгая дисциплина и она требует определенного последовательного процесса работы. Я сам прошел через это. «у меня метод уже полностью в голове реализован и он точно будет корректен, зачем писать эту пачку бесполезных тестов?». Лень. Медленные интегрированные тесты, когда желания писать много тестов нет вообще. И минимальная реализация с хардкодом при первом тесте, это же для дураков ведь, да? Я же умный, могу писать правильную реализацию сразу и очнуться через пол-часа с десятью интегрированными тестами, которые не принуждают дизайн системы быть удобным для тестирования, не дают по нему никакой обратной связи и не отлавливают все ошибки в коде. Зато «сэкономил» 15 минут, о да.
      • +1
        Я под этим понимаю такую реализацию, которая с одной стороны делает примерно то же, что и предполагаемый конечный вариант (всё равно в голове же есть примерный код, который решает эту задачу), с другой стороны не утруждает себя обработкой разных нестандартных вариантов (но что приходит в голову — хорошо в этот момент писать в TODO-комментариях, чтобы потом не забыть). Хардкодить ответ смысла не вижу, т.к. это подразумевает переписывание реализации после каждого теста, лишняя работа без особого смысла. Но можно и так, конечно.
  • 0
    Как правило, в современной промышленной разработке теоретическое доказательство верности программ (а) практически невозможно и (б) не требуется.


    Что такое «теоретическое доказательство верности программ»?

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


    … и это действительно очень плохо.

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


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

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


    Я вижу тут или противоречие, или «аргумент» против TDD.

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


    Нормальным образом можно это зафиксировать написав все тесты строго до реализации.
    • +2
      Что такое «теоретическое доказательство верности программ»?

      Формальная_верификация

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

      Вот об этом как раз статья. Зафиксировать столько, сколько нужно раньше реализации — не получается.

      Я вижу тут или противоречие, или «аргумент» против TDD.

      Почти: это аргумент против test-first в его классическом понимании, да.
  • 0
    Спасибо за формальную верификацию, изучу вопрос.

    Зафиксировать столько, сколько нужно раньше реализации — не получается.

    Почему бы не фиксировать одно требование за раз?

    черт, промазал
  • +1
    Спасибо за формальное описание проблем с тестированием «чёрного ящика», классы эквивалентности и вот это вот всё.
    • +1
      Спасибо! Я ждал вашего комментария, рад, что понравилось :).
  • 0
    В примере с number_of_german_letters нужно считать функцию is_german_letter частью ее интерфейса. Т.е. это функция высшего порядка с двумя аргументами: функцией определения буквы и строкой. Интерфейс будет что-то типа
    number_of_specific_letters (is_specific_letter, s)
    а вызывать ее будем как
    number_of_specific_letters (is_german_letter, «abc1»)
    Тогда можно рассматривать ее как черный ящик?
    • 0
      Это любопытный взгляд на вещи, но легко видеть, что с ним в терминах статьи все еще хуже: в этом случае даже интерфейс до написания функции неизвестен (т. к. то, что надо использовать is_german_letter, я придумаю только на стадии реализации).

      А вот в процессе реализации как раз все становится, как вы говорите. Иначе это можно выразить так: формулировка функции с «посчитай немецкий буквы» меняется на «посчитай буквы, которые is_german_letter».
      • 0
        Да, согласен, хотя это немного другой вопрос — разбиение приложения на функции. Конечно, нужна какая-то итеративность тест-код-тест-код, невозможно заранее написать тесты для большого куска приложения.
    • 0
      Не получится еще по одной причине. «is_german_letter» мы выбрали как самый характерный пример, но то же верно относительно всех остальных используемых в ней функций. Там, по массиву наверняка придется поитерировать, числа поскладывать. И каждый раз мы упираемся в то, что "+" мы доверяем и итератеру мы тоже доверяем, что опять повлияет на итоговый выбор тестов, а следовательно затея с черным ящиком опять провалилась.

      Плюс, заменив техзадание на «напишите number_of_specific_letters» — мы даже с is_german_letter проблему не решаем. Мы просто подвинули ее на 1 уровень выше, в том место, где это number_of_specific_letters будет вызываться. Там точно также встанет вопрос, доверяем ли мы is_german_letters, которое использовали как параметр.
    • 0
      В примере с number_of_german_letters прежде всего нужно решить является ли функция is_german_letter внутренней декомпозицией логики функции number_of_german_letters или она является полноценной частью API разрабатываемого модуля.

      Если полноценная функция, то тестировать её нужно отдельно, а в (юнит-)тестах number_of_german_letters — стабить/мокать. Выносить её как явную зависимость или переопределять, пользуясь возможностями языка/фреймворка — чисто тактическое решение.
  • +3
    Одна из основных проблем при попытках применении test-first — стремление (у кого сознательное, у кого не очень) заменить тестами формальную верификацию, покрыть все различные варианты, все классы эквивалентности, нарушая принцип черного ящика (даже если реализация ещё только в голове). То есть не падающие тесты рассматриваются как доказательство работоспособности программы.

    Основное же назначение же тестов при test-first сначала (когда падает) — формальное доказательство отсутствия требуемой функциональности в каких-то частных случаях, а потом (когда проходит) — фиксация её наличия в этих случаях. При test-first не нужно писать тесты, которые почти гарантировано заработают без изменения кода. С другой стороны, не нужно писать код, отсутствие которого не повлияет на прохождение тестов (рефакторинг таким кодом не считается). Разработку теста следует рассматривать как формализацию нового требования (баг-репорта или фиче-реквеста), откуда-то (абсолютно не важно откуда, может фантазия заказчика, может анализ кода как белого ящика) полученного. Часто можно услышать «когда пишешь тест, представь себя тестировщиком», что сильно мешает, поскольку задача тестировщика (в представлении обычного разработчика :) ) — выявить ошибки, что неверно. Тестировщик при автоматическом тестировании — это тестирующий фреймворк :). Когда же придумываешь и пишешь тест, то представлять себя надо аналитиком, постановщиком задачи и тестировщику, и разработчику. Тогда многое становится на свои места.
  • НЛО прилетело и опубликовало эту надпись здесь
    • 0
      Спасибо! Учтите, что test-first ≠ TDD :).
  • +4
    Мне всё время было интересно как это, тестирование по принципу черного ящика. Везде пишут простые примеры сложения и перемножения переменых, а как на счёт чего-то действительно сложного, например:

    • Рассылка письма из параметров функции по базе из 100500 email адресов;
    • Парсинг стороннего RSS и сохранение новостей в бд;
    • Push данных в стороннее API.


    В случае например с email рассылкой. Понятно что функция использует какой-то драйвер БД и для отправки сообщения конкретному получателю используется какой-то сервис. Но если мы тестируем по принципу чёрного ящика, то мы ничего не знаем о внутренней реализации и завистмостях и мы должны вызывать эту функцию и проверять все 100500 ящиков.

    Понятно что в обычном тестировании мы можем мокнуть сервис отправки сообщений и драйвер БД, но в этом случае мы уже начинаем привязываться к реализации. Дальше больше. Не стоит забывать о том что в нормальных драйверах БД есть несколько способов получит данные, например в Doctrine:

    • Метод из репозитория;
    • ORM QueryBuilder;
    • DBAL QueryBuilder;
    • DQL;
    • SQL.


    Любой из этих способов может быть применен для получения списка адресов. Если мы функцию тестируем как черный ящик мы не знаем как нам замокать драйвер БД так что бы он не тянул даные из БД, а пытался отправить письмо на нужные нам адреса. А в случае изменения метода получения адресов нам придется менять тест, что противоречит принципу черного ящика.

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

    Я себе представляю как я буду писать тест на ещё не созданый запрос построеный с помощью QueryBuilder.

    PS: Если кому интересно, могу привести пример как запрос на 15 строк превращается в тест на 93 с жёсткой привязкой к порядку вызова методов.
    • 0
      Рассылка письма из параметров функции по базе из 100500 email адресов;

      Тут, как минимум, две отдельные функции:
      — рассылка письма по списку/массиву/коллекции/итератору/… адресов
      — получение списка/массива/коллекции/итератора/… адресов

      В случае test-first мы можем знать о том что методу понадобятся конкретные зависимости, но не имея реализации мы не можем точно знать как именно будут использоваться эти зависимости.

      Как это не можем точно знать? Зачем мы их вводим, если не знаем как будем использовать?
      • +1
        Тут, как минимум, две отдельные функции:
        — рассылка письма по списку/массиву/коллекции/итератору/… адресов
        — получение списка/массива/коллекции/итератора/… адресов

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

        • мы валидируем данные от пользователя;
        • создает сообщение на основе данные от пользователя;
        • получаем список получаетелей сообщения;
        • отправляем каждому получателю сообщение;
        • отдаем пользователю результат отправки.


        Я привожу очень условный пример. Понятно что если адресов 100500, то это как минимум cli команда, а в идеале еще и очередь используется что-то типа RabbitMQ, но суть то не в этом. Суть в том что функции высокого уровня абстракции может быть очень сложно протестировать по принципу black-box, а порой и вовсе невозможно.

        Как это не можем точно знать? Зачем мы их вводим, если не знаем как будем использовать?

        Пример я привел. Мы точно знаем что нам необходимо получить данные из бд, то есть будет использоваться драйвер бд (например EntityManager из Doctrine), но мы не знаем какой из методов получения будет использоваться и как. В некоторых случаях мы можем точно знать как будет использоваться зависимость, но не во всех.
        • 0
          Выносить рассылку в отдельную функцию не совсем разумно, особенно если это единственное место в проекте где есть рассылка

          1. Не разумно выносить в функцию? Вам необходимо вводить отдельный класс, обязанностью которого будет осуществление рассылки и подключать его как зависимость.
          2. Не единственное: тесты тоже будут использовать этот метод. Это также аргумент в сторону тех кто не хочет создавать интерфейсы для только одной реализации. Stubs тоже будут использовать этот интерфейс.

          Если вы пишите тесты перед реализацией то реализация представляет собой чистейший черный ящик, потому что реализации ещё нет.
          • 0
            1. Не разумно выносить в функцию? Вам необходимо вводить отдельный класс, обязанностью которого будет осуществление рассылки и подключать его как зависимость.

            А почему мне именно «необходимо» вводить отдельный класс. Если при рассылке делается что-то больше чем проход по циклу и передача каждого значения функции отправки, то да, скорей всего понадобится вводить еще один уровень абстракции.
            Если ваша функция называется «Получить всех пользователей И отправить им сообщение», то нужно разделять, а если функция называется «Отправить все пользователям сообщение» то разносить нечего. Я согласен что здесь можно, а иногда и нужно, вводить еще один уровень абстракции, но почему вы считаете что это единственно верный способ? Так ли необходимо создавать еще один класс из-за этих 3 строчек:

            foreach ($this->rep->getUserEmails() as $email) {
                $this->mailer->send($email, $message);
            }
            


            2. Не единственное: тесты тоже будут использовать этот метод. Это также аргумент в сторону тех кто не хочет создавать интерфейсы для только одной реализации. Stubs тоже будут использовать этот интерфейс.

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

            Если вы пишите тесты перед реализацией то реализация представляет собой чистейший черный ящик, потому что реализации ещё нет.

            Тут вы не правы. Тестирование по принципу black-box означает тестирование реакции на воздействие абстрагированное от внутренней реализации. То есть мы тестируем только выходные значения для заданных входных.
            В случае же test-first, мы действительно не знаем ничего о внутренней реализации, потому что ее еще нет, но мы можем управлять внутренней реализацией. Мокнуть сервис, проверить передаваемые ему параметры.

            Говоря другими словами, в случае black-box если тестируемая функция записывает что то в БД, то мы должны проверить наличие этой записи в бд, а в случае test-first мы должны проверить что сервису БД были переданные данные на запись.
            • 0
              Я предполагал что код метода `send` находится прямо внутри метода отправки сообщений пользователей, у вас всё верно. Мои аргументы предназначались для случая когда отправка выполнялась прямо в методе.

              а в случае test-first мы должны проверить что сервису БД были переданные данные на запись

              Верно.

              Тестирование по принципу black-box означает тестирование реакции на воздействие абстрагированное от внутренней реализации.

              Вот кстати я не до конца понимаю этот момент. Если мы мочим реализацию public методов зависимостей, которые потом использует
              тест, это считается black box или нет? С одной стороны мы предполагаем что должно происходить внутри метода, с другой стороны нас это не интересует, так как нам важно получить нужный assert и не важно как метод это будет делать.
              • +1
                Вот кстати я не до конца понимаю этот момент. Если мы мочим реализацию public методов зависимостей, которые потом использует
                тест, это считается black box или нет? С одной стороны мы предполагаем что должно происходить внутри метода, с другой стороны нас это не интересует, так как нам важно получить нужный assert и не важно как метод это будет делать.

                это считается white-box testing
              • +1
                в вики неплохо объясняется разница между black-box и white-box тестированием
            • +2
              Тут вы не правы. Тестирование по принципу black-box означает тестирование реакции на воздействие абстрагированное от внутренней реализации. То есть мы тестируем только выходные значения для заданных входных.
              В случае же test-first, мы действительно не знаем ничего о внутренней реализации, потому что ее еще нет, но мы можем управлять внутренней реализацией. Мокнуть сервис, проверить передаваемые ему параметры.

              Оба подхода прекрасно сочетаются, если считать вызов сервиса тестируемой выходной реакцией. То есть для метода из трёх строк типа:
              public function SendMessageToAllUsers($message) 
              {
                foreach ($this->rep->getUserEmails() as $email) {
                  $this->mailer->send($email, $message);
                }
              }
              

              ожидаемой реакцией будет:
              1. Вызов $this->rep->getUserEmails()
              2. Вызов $this->mailer->send() для каждого из возвращенных в шаге 1 адреса

              Мы не знаем как это будет реализовано (например, циклом, итератором или вообще рекурсией), поэтому это чёрный ящик для нас, но мы знаем, что вызов этих зависимостей есть цель написания данного метода, поэтому можем применить test-first. На уровне юнит-тестов мы мокаем репу и мэйлер и проверяем, что всё вызывается как нам надо. На уровне интеграционных тестов мы создаём в тестовой базе записи, создаём тестовый мэйлер, передаём их объекту, дергаем наш метод и сравниваем базу с логом мэйлера. На уровне функциональных — создаём пользователей, дергаем контроллер и проверяем почту юзеров. Главное не путать уровни и тогда у нас всегда будет чёрный ящик, пригодный для test-first. Мы не будем знать детали реализации, достаточно только интерфейса. На одном уровне интерфейс будет на уровне языка (вызов методов), а на другом на уровне пользователя (заполнили окошки, проверили почту).
              • 0
                Ну да. Понятно что в данном случае мы можем мокнуть наши сервисы, написать юнит-тест и применить test-first. Мне было интересно как применять black-box тестирование в данном случае. Получается нужно делать какие-то хуки на уровне окружения. Проверять лог отправки. В случае парсинга RSS и внешнего API подменять хост в hosts и дописывать дополнительный обработчик который будет отправлять/сохранять тестовые данные для подмененных внешних сервисов. Запускать придется в песочнице. А в случае запроса к сервисам по IP придется еще заморачиваться с перенаправлением трафика.
                Та еще развлекуха.

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

                Тест
                $channel = 'foo';
                $date_start = new \DateTime('2016-01-11');
                $date_end = clone $date_start;
                $date_end->modify('+7 day');
                $result = [['2016-01-11', 10]];
                
                $sth = $this->getMockBuilder(Statement::class)
                    ->disableOriginalConstructor()
                    ->getMock();
                $sth
                    ->expects($this->once())
                    ->method('fetchAll')
                    ->will($this->returnValue($result));
                
                $i = 0;
                $builder = $this->getMockBuilder(QueryBuilderDBAL::class)
                    ->disableOriginalConstructor()
                    ->getMock();
                $builder
                    ->expects($this->at($i++))
                    ->method('select')
                    ->with('e.date, COUNT(*) AS `total`')
                    ->will($this->returnSelf());
                $builder
                    ->expects($this->at($i++))
                    ->method('from')
                    ->with('schedule_event', 'e')
                    ->will($this->returnSelf());
                $builder
                    ->expects($this->at($i++))
                    ->method('where')
                    ->with('e.date >= :date_start')
                    ->will($this->returnSelf());
                $builder
                    ->expects($this->at($i++))
                    ->method('andWhere')
                    ->with('e.date < :date_end')
                    ->will($this->returnSelf());
                $builder
                    ->expects($this->at($i++))
                    ->method('andWhere')
                    ->with('e.channel = :channel')
                    ->will($this->returnSelf());
                $builder
                    ->expects($this->at($i++))
                    ->method('andWhere')
                    ->with('e.movie_id IS NOT NULL')
                    ->will($this->returnSelf());
                $builder
                    ->expects($this->at($i++))
                    ->method('groupBy')
                    ->with('date')
                    ->will($this->returnSelf());
                $builder
                    ->expects($this->at($i++))
                    ->method('setParameter')
                    ->with(':date_start', $date_start->format('Y-m-d'))
                    ->will($this->returnSelf());
                $builder
                    ->expects($this->at($i++))
                    ->method('setParameter')
                    ->with(':date_end', $date_end->format('Y-m-d'))
                    ->will($this->returnSelf());
                $builder
                    ->expects($this->at($i++))
                    ->method('setParameter')
                    ->with(':channel', $channel)
                    ->will($this->returnSelf());
                $builder
                    ->expects($this->at($i))
                    ->method('execute')
                    ->will($this->returnValue($sth));
                
                $exp = $this->getMockBuilder(ExpressionBuilder::class)
                    ->disableOriginalConstructor()
                    ->getMock();
                $exp
                    ->expects($this->once())
                    ->method('isNotNull')
                    ->with('e.movie_id')
                    ->will($this->returnValue('e.movie_id IS NOT NULL'));
                
                $conn = $this->getMockBuilder(Connection::class)
                    ->disableOriginalConstructor()
                    ->getMock();
                $conn
                    ->expects($this->once())
                    ->method('createQueryBuilder')
                    ->will($this->returnValue($builder));
                $conn
                    ->expects($this->once())
                    ->method('getExpressionBuilder')
                    ->will($this->returnValue($exp));
                
                $em = $this->getMockBuilder(EntityManager::class)
                    ->disableOriginalConstructor()
                    ->getMock();
                $em
                    ->expects($this->once())
                    ->method('getConnection')
                    ->will($this->returnValue($conn));
                
                $class = $this->getMockBuilder(ClassMetadata::class)
                    ->disableOriginalConstructor()
                    ->getMock();
                
                $rep = new ScheduleEvent($em, $class); // EntityRepository
                
                $this->assertEquals($result, $rep->getAllWeekEvents($day_start, $channel));
                


                Тестируемый код
                Это только треть всего метода. Там еще один такой запрос и обработка результата. Для примера я думаю и этого будет достаточно
                function getAllWeekEvents(\DateTime $date_start, $channel)
                {
                    $date_end = clone $date_start;
                    $date_end->modify('+7 day');
                
                    return $this
                        ->getEntityManager()
                        ->getConnection()
                        ->createQueryBuilder()
                        ->select('e.date, COUNT(*) AS `total`')
                        ->from('schedule_event', 'e')
                        ->where('e.date >= :date_start')
                        ->andWhere('e.date < :date_end')
                        ->andWhere('e.channel = :channel')
                        ->andWhere($this->getEntityManager()->getConnection()->getExpressionBuilder()->isNotNull('e.movie_id'))
                        ->groupBy('date')
                        ->setParameter(':date_start', $date_start->format('Y-m-d'))
                        ->setParameter(':date_end', $date_end->format('Y-m-d'))
                        ->setParameter(':channel', $channel)
                        ->execute()
                        ->fetchAll();
                }
                

                • +2
                  Я просто приведу пример простого на вид кода и его теста, возможно на их примере будет понятно что написать тест до реализации может быть не так уж и просто.

                  Во-первых, это узнаваемый DAL, который не факт, что надо тестировать.
                  Во-вторых, такие вещи лучше всего тестировать с помощью функционально эквивалентного стаба — т.е., у вас есть большая коллекция событий, есть поверх нее in-memory query engine, а потом вы просто сверяете, что ваш метод вернул именно то, что вы ожидаете.
  • 0
    deleted

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое Разработка