Unit Testing в C# с использованием NUnit и NSubstitute. Часть 2

Unit Testing в Visual Studio с использованием NUnit и NSubstitute. Часть 1
Unit Testing в C# с использованием NUnit и NSubstitute. Часть 3

Что ж, базис я дал, а теперь перейдем к реальному коду, написанному в стиле TDD.
Задача: необходимо было упростить управление web-пауком через файлы конфигурации, определять подходит нам web страница или нет по содержимому страницы и/или содержимому url. Изначально я планировал просто пошагово описать свои мысли и действия. Но подумав — решил все таки видоизменить уже написанный код, для того, чтобы можно было выложить на скачивание отдельный проект. Для этого вместо части классов используются заглушками, что не мешает их использовать в тестировании.
Так что создаем проект WebSpider так, как это было описано в первой статье. Создаем unit test проект WebSpider.UnitTests и добавляем NUunit и NSubstitute в зависимости.

Начнем проектировать.
Пусть класс отвечающий за задачу распознавания того, подходит ли нам полученная web страница или нет будет называться WebAnswerRecognizer, класс теста для него будет называться соответственно WebAnswerRecognizerTests
Какие методы будут присутствовать в классе?
Мы должны загрузить конфигурацию из файла (пусть метод называется Load), и протестировать полученный сетевой ответ (пусть метод называется Test)

Таким образом у нас получается следующий интерфейс

IRequestData в данном случае — интерфейс для моего класса-обертки над сетевым ответом, для простоты представим себе, что у этого класса всего 2 поля Url и Content (содержащих url сделанного запроса, и содержимое ответа соответственно)

Как должен выглядеть конфигурационный файл?
Так уж повелось, что на заре написания своей библиотеки, ставшей ядром для всех последующих проектов под C# в качестве формата конфигурационного файла был взят не xml или еще что то стандартное, а простейший собственный формат, по сути представляющий собой обычный текстовый файл у которого записи разделены переводом строки, а поля — каким то хитрым сочетанием символов, типа <|>, которые встретить в качестве самых данных практически нереально. Преимуществом данного подхода можно назвать то, что такой конфиг крайне легко править руками. Ну и еще момент — если переходить на xml полностью, надо делать единый конфиг для всех задач. У меня же сейчас — россыпь txt файлов — каждый из которых конфиг для своей задачи, что позволяет очень просто перемещать только нужные файлы конфигурации в другие проекты.

Т.е. в итоге формат будет следующий.
ключевое слово<|>условие
например
url_contains<|>success_part
url_ends<|>.aspx

и т.д.

В качестве ключевых слов я запланировал следующие:
content_contains — если IRequestData.Content будет содержать условие, — возвращаем IRequestData.Url
url_contains — если IRequestData.Url будет содержать условие, — возвращаем IRequestData.Url
url_ends — если IRequestData.Url будет оканчиваться на условие, — возвращаем IRequestData.Url
meta_redirect — если в IRequestData.Content будет содержать редирект через <meta refresh, — возвращаем url на который надо было перейти через meta refresh
regex — самое мощное правило, применяем указанный регексп на IRequestData.Content и на основании результата возвращаем Url
Но для экономии места и времени — покажу реализацию с тестами только для content_contains, т.к. другие ключевые слова делаются по аналогии.

Стоит отметить, что для regex и meta_redirect может в результате получиться относительная ссылка типа «/content», учитывая это следует делать корректировку результата с учетом изначальной IRequestData.Url Например пусть IRequestData.Url = «http://test.com/news», а полученный meta_redirect = «354.aspx», возвращаемое значение будет «http://test.com/news/354.aspx».

Так же я решил ввести поддержку # — в качестве комментария, строки в начале которых стоит этот символ игнорируются.
И ! для обратимости условия, т.е. !url_ends — соответственно отдаст url если url не содержит указанное условие

Следует предусмотреть, что условий может быть несколько, т.е. возвращаем Url, если Content содержит testWord и url НЕ содержит .aspx будет выглядеть следующим образом

Ну и будем руководствоваться правилом «все что не разрешено — запрещено», если ни одно из правил не сработало — возвращаем null. Учитывая этот момент — если мы укажем путь к несуществующему файлу конфигурации — exception’а не будет, просто будет пустой список с проверочными условиями и при любом вызове метода Test будет возвращен null.
Для начала это поведение и проверим.

Итак, у нас есть созданные классы WebAnswerRecognizer (с пустыми методами Load и Test).

и WebAnswerRecognizerTests.

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

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

В итоге получился следующий код

Наш интерфейс.

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

Класс, статические методы которого повсеместно используются в десятках моих проектов для работы с файловой системой. Обратите внимание на то, что свойство FileSystemHelper имеет setter, что позволяет задать его в тесте, заменив реализацию, которая создалась бы в реальном проекте на фейк.
В принципе есть метод Assert.DoesNotThrow, но на мой взгляд вызов его будет излишним, если exception‘а не случиться — тест завершится без ошибок, если будет неучтенный exception — тест завершится с ошибкой, смысла городить огород тогда.

Получаем первый тест.

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

Теперь класс WebAnswerRecognizer будет выглядеть следующим образом

Запускаем наш тест, и снова безмятежный зеленый свет. Т.к. GetListFromFile никогда не выбрасывает exception, если файла нет — возвращает null.
Но проблема существует, у нас происходит реальное обращение к файловой системе, чего мы конечно же допустить не можем. Но мы не даром извлекли интерфейс IFileSystemHelper, теперь мы можем сделать для него фейк. Т.к. по логике все тесты будут обращаться к фейковой файловой системе — хотелось бы написать вспомогательный метод который бы вызывался в каждом тесте, который в этом нуждается. Параметризируем его возвращаемым значением, добавив этот метод в класс WebAnswerRecognizerTests. В будущем на мой взгляд будет иметь смысл вынести такой метод в отдельный вспомогательный класс, если и в других классах тестов будет обращение к FileUtils.

И в самом тесте добавляем в начале вызов метода наш метод

Запускаем тест — и снова никаких падений. Как то слишком все хорошо, это связано с тем, что мы заранее обговорили возможность возвращени null внутри вызова Load. Ради интереса закомментируем строку if (rawConditions != null) в коде Load и наконец то тест падает с NullReferenceException. Таким образом мы убедились, что тест действительно работает с реальным методом и метод действительно использует наш фейк. Возвращаем проверку rawConditions на место.

Мы говорили, что строки с # — считаются комментариями и игнорируются, вот это мы и проверим

Запускаем тест и он падает. Мы же пока нигде не указывали, что нужно игнорировать комментарии. Исправляемся

Запускаем — что за черт, опять тест не проходит

Ах да, мы же забыли переопределить сравнение для класса ConditionData, естественно что в сравниваемых списках разные обьекты лежат, нам же нужно чтобы сравнение учитывало значение строк type и value
Добавляем в класс ConditionData

Запускаем тесты и наконец то видим зеленый свет. На самом деле вот это я и люблю больше всего в UnitTest‘ах — самоконтроль, причем максимально близко к новому коду. Не еще через 3 часа разработки и попытки запустить все это, а в момент написания кода. Конечно ошибок можно наделать и с тестами, но вероятность этого намного ниже.

Для закрепления давайте напишем еще простенький тест, который проверит, что если конфигурационный файл неверного формата — мы выбрасываем ArgumentException

Мы указываем content_contains но забываем дописать условие, на основании этого конфигурационного файла ConditionData не построить. А чтобы проверить что мы ожидаем ArgumentException перед тестом мы добавили строку

Запускаем тест и он не проходит.
System.ArgumentException was expected
Добавляем проверку на число элементов при считываниив метод Load

Повторно запускаем тест — бинго, тест проходит, мы ожидали exception — мы его получили.

Ладно, с загрузкой конфигурации все более менее понятно, давайте начинать тестировать метод Load. Чтобы не получить ту же проблему, что и с методом Load — напишем тест, который будет падать, и чтобы починить его — напишем недостающую имплементацию.

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

Мы используем ключевое слово content_contains для поиска в содержимом RequestData.Content наличие «anchor data»
Тест конечно не пройдет, сейчас метод Test всегда возвращает null. Исправим это.

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

Тест проходит. Проверим теперь, что если наши условия ошибочны — Test возвращает null

И этот тест проходит.

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

Для этого тесту передаем нужные нам параметры. Я передаю правила, url и content — с которыми создается RequestData, и url который мы ожидаем получить в результате вызова метода Test.
Ну а чтобы запустить конкретную проверку со своим набором данных существует атрибут TestCase

Код этого теста за счет выноса входных данных в атрибут стал намного чище и понятней.
Запускаем тест, он не проходит на наборе данных с !content_contains, т.к. мы не делали инверсию.

Добавляем поддержку в класс ConditionData

и в метод Test вместо hasCorrect = contains; подставляем

Запускаем все написанные нами тесты — все проходят, что не может не радовать.

Проект можно скачать здесь

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *