Начинаем писать тесты (правильно)

Как начать писать тесты? Сколько нужно писать? На что их нужно писать, а на что — не нужно? Стоит ли всегда применять TDD? Если вас интересуют ответы на эти вопросы, то вы читаете правильную статью. В своей жизни я написал не одну тысячу тестов всех мастей для разных платформ, использовал во всех полях TDD и ставил процесс тестирования в командах, проектах и даже целых компаниях. И теперь я попробую обобщить этот опыт и поделиться им.
Тестирование, как и многое в программировании, стало культом карго. Вместо осознанного движения, разработчики пытаются следовать популярным методологиям, слепо верить тому, что пишут в документации, покрывать код на 100% тестами. Я был свидетелем удаления папки с тестами (в 40 тысяч строк кода) по причине того, что их стало невозможно поддерживать. Такое тестирование чаще приводит к обратному эффекту — разработка становится дороже, а процесс медленнее, и даже если наблюдается позитивный эффект, то он дается слишком дорого.
Основная цель этой статьи — дать вам целостное понимание смысла тестирования. Понимая суть, вы сможете лучше мыслить критически и понимать, к чему нужно идти. Ну и, конечно, будет немного практических советов.
Подписывайтесь на канал Кирилла Мокевнина в Telegram — чтобы узнать больше о программировании и профессиональном пути разработчика
Начнем, пожалуй, с самого главного вопроса: зачем нам вообще нужно тестировать?
Чтобы быть уверенными в работоспособности нашего продукта. Заметьте, что я не написал «функций», «модуля», «кода» или «проекта». В конечном итоге имеет значение только то, что конечный продукт, которым пользуются (не всегда пользователи), работает, и делает он это хорошо. Хотя прямо сейчас это может показаться капитанством, но, как вы увидите позже, ориентация на цель позволит нам принимать правильные решения.
Следующий ключевой тезис не является особенностью процесса тестирования. Задачи можно условно поделить на два типа: они либо завершены, либо нет, а завершенность задач второго типа — это шкала, где 0 — это «ничего не сделано», а 1 — это сделано на 100%. При решении таких задач 100%-решение часто оказывается недостижимым из-за сверхвысоких накладных расходов.
Приведу прекрасный пример. Для многих сервисов критично такое понятие как SLA или, проще говоря, доступность сервиса. Например, на хостинговых площадках пишут что-то в духе «мы обеспечиваем доступность 99.9% наших серверов». Давайте прикинем, сколько часов за год хостинг может оказаться недоступен в рамках его SLA: 0.001 * 365 * 24 = 8.7 . В принципе, неплохо.
Предположим, что обеспечение такого уровня доступности обходится компании в 1000$ . А во сколько обойдется компании добавление каждой новой девятки в конце? То есть обеспечение 99.99 , 99.999 и так далее. Насколько мне известно, на таком уровне обеспечения происходит экспоненциальный (взрывной) рост стоимости. Я уже не говорю про то, что 100%-доступность является фантастикой.
Этот пример ярко демонстрирует то, что в задачах с плавающим результатом главным принципом является «максимальный результат за минимальные ресурсы». Другими словами, ищется баланс, при котором мы получаем результат, удовлетворяющий стейкхолдеров (заинтересованных лиц), за приемлемый бюджет/сроки.
Теперь возвращаемся к нашим тестам и обнаруживаем, что тесты относятся именно к этому типу задач. Добавление первых тестов в проект дает невероятный эффект. Покрытие в 50% (половина кода вызывается в тестах) получается почти сразу, и по сравнению с отсутствием тестов — мы на два корпуса впереди. Дальше ситуация начинает меняться, и где-то на уровне 70-90% начинается резкое замедление роста покрытия, тесты становятся все более точечными, дорогими. Возрастает сложность их поддержки, рефакторинга.
Этот процесс бесконечен. Добиться 100% покрытия очень дорого и, скорее всего, неоправданно (см. пример выше). Кроме того, никакие тесты не дают вам полную гарантию работоспособности.
Кроме количества тестов и их качества, на стоимость также влияет то, какой тип тестов мы используем. Существует множество классификаций видов тестов, таких как: «по знанию системы», «по степени автоматизации», «по времени проведения тестирования». На этом этапе нас интересует только одна классификация: «по степени изолированности компонентов»:
- Модульное тестирование
- Интеграционное тестирование
- Системное тестирование (приемочное)
Именно здесь начинаются проблемы. Во-первых, идут настоящие религиозные войны на тему того, что называть модульным тестированием, а что не называть. Во-вторых, эти типы тестов подаются как нечто конкретное с большим количеством требований для того, чтобы соответствовать одной из этих категорий. Такое положение вещей приводит к тому, что программисты думают, в первую очередь, не о результате, а о том, пишут ли они юнит-тесты в соответствии с канонами, или нет.
В действительности же нет никаких четких разделений на три уровня. Даже если вы тестируете чистую функцию (модульное тестирование), она выполняется на конкретном железе, и потенциально, на другом может перестать работать как ожидалось (это тоже в каком-то смысле интеграционное тестирование).
Так вот, есть только шкала. Чем более простую и мелкую часть системы мы тестируем — тем дешевле тесты, чем более сложную (составную) — тем дороже. И ваша задача как специалиста — исходить не из того, чтобы соответствовать своим представлениям о видах тестирования, а писать тесты так, чтобы они в идеале покрывали большее число кейсов при небольших затратах. Я уверен, что на этой фразе некоторые разработчики напряглись, потому что в их картине мира нужно обязательно писать изолированные юнит-тесты, а приемочные должны писать только тестировщики. Не буду разводить полемику, просто скажу, что бывает по-разному. Есть проекты, в которых процент юнит-тестов (в самом жестком понимании) составляет доли процента от всех остальных тестов (как в Хекслете, хе-хе), а есть те, где пишут только приемочные тесты (отдельные тестировщики).
Теперь вы готовы, и я попробую ответить на вопросы, поставленные в начале статьи. Предположим, что вы пишете программу (утилиту командной строки), которая принимает на вход файл и слово, которое нужно найти в этом файле. В результате своей работы программа печатает на экран все строчки из файла, в которых встречается это слово. Такая утилита действительно существует и называется grep . С ней знакомо большинство разработчиков.
Обычно в таких программах не всегда сразу понятно, какой будет архитектура. Многое зависит от того, что будет добавлено в процессе, например, форматы вывода, поддерживаемые форматы входа, обход директорий (рекурсивный), нечеткий поиск и многое другое.
Основной наблюдаемый мной анти-паттерн в разработке подобных библиотек — это тесты на внутренние мелкие компоненты. Те самые юнит-тесты. Почему такой подход непродуктивен? Возможно, это и не очевидно, но такое тестирование, хоть и является модульным, но не является дешевым и качественным. Но, как…?
Мы уже говорили о том, что архитектура проекта еще неизвестна, и, как правило, внутреннее разделение на файлы/модули/классы/функции меняется с космической скоростью. В течение часа все может быть переписано несколько раз. Но теперь вместе с кодом нужно постоянно править тесты, что начинает раздражать. Программист начинает сомневаться в том, что они вообще ему нужны, и нередко просто перестает их писать. Другие продолжают мучаться и переписывать их, хотя чаще происходит другое. Написанные тесты начинают вас сковывать и мозг шлет команды «ты потратил время, оставь все как есть». Постепенно рефакторить становится все сложнее и ленивее. Это очень похоже на ситуацию, когда предприниматель инвестировал деньги в новое направление и, даже если бизнес уже тонет, ему тяжело отказаться, ведь было потрачено столько сил и средств (в экономике это называют sunk cost fallacy, — прим. ред.).
Так как же лучше написать тест? Надеюсь, вам уже стало очевидно, что нужно найти достаточно высокую точку входа в нашу программу, которая не зависит от внутренней реализации, и при этом выполняет поставленную задачу.
Если мы попробуем взять самый высокий уровень — прямой запуск программы в консоли, то, скорее всего, мы столкнемся с рядом проблем, такими как запуск отдельного процесса, чтение стандартных потоков и других. В случае нашей программы такое тестирование уже можно называть системным, ведь мы проверяем работу на максимально высоком уровне, вообще не касаясь внутренней реализации. Хотя такой тест и не является проблемой для опытного разработчика, в целом, стоимость подобного теста и для данной библиотеки можно назвать максимальной.
Более низкий уровень — это функция, которая принимает на вход путь до файла и подстроку для поиска, а на выходе (не печатает на экран!) отдает готовый результат, так, чтобы осталось только напечатать его. Такой вид тестов обладает самым лучшим балансом «убедиться в том, что все работает/стоимость». Они косвенно затрагивают все используемые внутренности, не зависят от реализации, очень просты в написании и крайне дешевы в поддержке. По мере стабилизации архитектуры можно добавлять тесты более низкого уровня (если становится понятно, что сложность системы слишком высока).
TDD
Описанная методика особенно хорошо работает в связке с подходом, когда тесты пишутся до кода (вместе с кодом).
Существует миф о том, что тесты нужны только для регресса, то есть для уверенности, что новый код не сломал старый. Это далеко не так. Более того, это следствие написания тестов как таковых. В некоторых ситуациях первостепенная цель написания тестов — это ускорение разработки. Да-да, вы не ослышались, написание тестов до кода/одновременно с кодом, приводит к серьезному ускорению разработки. Чаще всего такие ситуации связаны с тем, что на вход подаются сложные данные, которые как-то трансформируются и прокидываются дальше. Тестировать руками (во время разработки) такой код очень сложно, нужно подготавливать данные, нужно проверять, что результат соответствует ожидаемому.
Важно понимать, что ускорение возможно только после того, как вы наберетесь опыта и начнете чувствовать себя уверенно в мире автоматизированного тестирования. К тому же, есть виды тестирования, где писать тесты до кода сложно либо практически невозможно. К таким тестам, например, относится приемное тестирование через браузер.
Второе серьезное преимущество TDD заключается в том, что при проектировании кода мы начинаем думать не о том, как сейчас клево насоздаем файлов и разнесем по ним функции, создав десятки абстракций, и начнем думать о важных вещах: о том, как будет использоваться моя библиотека. Удивительно, но начать смотреть с такого угла (а этому учат всех стартаперов, customer development во все поля) непросто, все время хочется окунуться в прекрасный мир архитектуры.
Проектирование внешнего api — действительно важная задача, которой стоит уделить хотя бы немного времени. Именно она больше всего влияет на ту свободу действий при переработке, которую вы получите в будущем. И именно этот уровень чаще всего является той точкой, от которой стоит отталкиваться при формировании планов тестирования.
Дополнительные ссылки
- Видео версия статьи
- Бережливое тестирование
Болеутоляющее или статья о том, что можно писать тестируемый JavaScript
У всех наступал момент, когда ваше JavaScript-приложение, начавшееся с нескольких полезных строчек разрасталось на тысячу строк, затем на две, дальше — больше. Постепенно функция начинает принимать чуть больше параметров; ветки условий получают ещё немного условий. И в один прекрасный день появляется баг: что-то сломано. И нам предстоит распутать весь этот бардак в коде.
Сейчас код на фронтенде берёт на себя всё больше и больше ответственности — на самом деле, уже существует целый пласт приложений, существующих полностью на клиентской стороне — в связи со всем этим становятся очевидными две идеи. Первая — мы просто не можем прокликать все возможные варианты с помощью текущего метода тестирования. Вторая — возможно, нам потребуется изменить подход к тому, как мы привыкли писать код, в угоду возможности писать тесты.
Правда ли, что нам необходимо поменять то, как мы пишем код? Абсолютное да — так как мы осознаём пользу автоматического тестирования, но большинство из нас, возможно, сумеют прямо сейчас написать только интеграционные тесты. Интеграционные тесты важны тем, что отслеживают, насколько хорошо работают между собой отдельные части приложения, но в то же время они не несут никакой информации о том, работают ли отдельные части так, как от них ожидают.
В этот момент на сцену действия выходят модульные тесты (прим. переводчика: также известны как юнит-тесты и функциональные тесты). Нам придётся серьёзно потрудиться над написанием модульных тестов, пока мы не начнём писать тестируемый джаваскрипт.
Модульные тесты и интеграционные: в чём разница?
Обычно способ создания интеграционных тестов достаточно прямолинеен: мы просто пишем код, описывающий, как пользователь взаимодействует с нашим приложением, и то, что пользователь ожидает увидеть. Популярным средством автоматического тестирования в браузере является Selenium. Capybara для Ruby облегчает взаимодействие с Selenium, более того, существуют тысячи подобных инструментов на других языках.
Ниже представлен интеграционный тест для небольшой части поискового приложения:
def test_search fill_in('q', :with => 'cat') find('.btn').click assert( find('#results li').has_content?('cat'), 'Результаты поиска отображены' ) assert( page.has_no_selector?('#results li.no-results'), 'Результаты поиска отсутствуют' ) end
В то время как интеграционный тест заинтересован в проверке взаимодействия пользователя с приложением, внимание модульного теста лежит на небольших участках кода.
Если я вызову функцию с зафиксированными параметрами, то получу ли я ожидаемый результат?
Приложения, написанные в традиционном процедурном стиле, чаще всего трудно модульно тестировать — так же, как трудно поддерживать, отлаживать и расширять. Но если мы будем писать код, держа в уме необходимость модульного тестирования, то мы обнаружим не только то, что тестировать становится проще, но также то, что мы просто пишем аккуратный и более качественный код.
В качестве иллюстрации к тому, о чём я говорю, давайте взглянем на обычное поисковое приложение:

Когда пользователь начинает что-то искать, приложение отправляет XHR-запрос на сервер. Когда сервер отвечает данными в формате JSON, приложение принимает эти данные и отображает их на странице при помощи клиентской шаблонизации. Пользователь может кликнуть на элементе поисковой выдачи, чтобы показать, что ему понравился этот пункт; когда это происходит, имя человека, к которому пользователь проявил интерес, добавляется в список «Понравившиеся» в правой колонке приложения.
«Обычное» JavaScript приложение может выглядеть так:
Мой друг Адама Сонтега (Adam Sontag) называет это «Выбери себе приключение сам» — в каждой строчке мы с равной вероятностью можем иметь дело как с представлением, так и с информацией, обслуживанием логики пользовательского взаимодействия или проверкой состояния приложения. Остаётся только догадываться! Достаточно просто написать интеграционные тесты для этого кода, и в тоже время очень сложно написать тесты для тестирования отдельных функциональных частей приложения.
Почему это сложно? На это существуют четыре причины:
- общий недостаток структурированности; чаще всего всё происходит в коллбэке $(document).ready() — и, так как это анонимная функция, её невозможно протестировать ввиду невозможного к ней обращения;
- слишком сложные функции; Если в функции больше 10 строк кода, как в обработчике отправки формы, то эта функция, вероятно, выполняет и несёт отвественность за слишком много вещей;
- скрытые состояния; так как состояние pending помещено в замыкание, то нет никакой возможности проверить, правильно ли установлено это состояние;
- избыточная связанность функционала; к примеру, обработчику $.ajax совершенно нет необходимости иметь доступ к DOM;
Организация кода
Первое, что небходимо сделать — выбрать менее запутанный метод организации кода, разбить его на несколько зон ответственности:
- представление и пользовательское взаимодействие;
- управление данными;
- общее состояние приложения;
- настройка и код-прослойка, чтобы все части работали вместе;
В «традиционной» реализации, показанной выше, эти четыре категории перемешаны — на одной строчке мы работаем с представлением, двумя строчками ниже мы общаемся с сервером.

Несмотря на то, что мы можем без проблем писать интеграционные тесты для этого кода (и мы обязаны это делать!), писать модульные тесты действительно сложно. В наших функциональных тестах мы можем утверждать: «когда пользователь ищет что-то, он должен видеть соответствующие результаты», но мы не можем быть точнее. Если что-то пойдёт не так, нам следует определить, что именно пошло не так, и наши функциональные тесты не смогут помочь в этом.
Если мы переосмыслим то, как мы пишем код, то мы можем не только написать юнит- тесты, которые дадут нам лучшее представление о том, откуда все пошло не по плану, но и, в конечном итоге, писать более удобный код — поддерживаемый, расширяемый.
Каждая новая строчка кода будет следовать этому небольшому списку правил:
- каждый отдельный фрагмент кода должен быть отдельным объектом, попадающим в одну из четырёх зон ответственности, и не должен иметь ни малейшего представления о других объектах. Это поможет избегать запутанного кода;
- поддерживайте возможность настройки вместо того, чтобы задавать конкретные значения определённым параметрам. Это предотвратит необходимость повторения всего HTML-окружения, чтобы написать тесты;
- каждый метод любого объекта должен быть простым и коротким. Этот пункт позволит иметь простые и читаемые тесты;
- для создания объектов следует использовать конструкторы. Это поможет сделать возможным создание «чистых» копий объектов, необходимых для тестирования.
Для начала надо определиться, на какие части мы разобьём наше приложение. У нас есть три части, относящиеся к представлению и взаимодействию: поисковая форма, поисковые результаты и сектор для понравившегося.

Также существует кусок кода, относящийся к запросу данных с сервера, и часть, обеспечивающая возможность совместной работы остальных частей.
Начнём с наиболее простой части приложения — сектор для понравившегося. В оригинальном приложении следующий код отвечал за обновление этого сектора:
Код поисковой формы сильно переплетен с сектором понравившегося и также требует информации о том, как устроена разметка. Гораздо лучшим подходом (и для тестируемости тоже) будет создание объекта сектора понравившегося, ответственного за манипуляции с DOM:
В этом коде приведён конструктор, создающий новую копию объекта Likes Box. Созданная копия имеет метод .add() , используемый для добавления новых результатов. Мы можем написать немного тестов, чтобы проверить, работает ли этот метод:
var ul; setup(function( )< ul = $(' '); >); test('constructor', function ( ) < var l = new Likes(ul); assert(l); >); test('adding a name', function ( ) < var l = new Likes(ul); l.add('Дмитрий Менделеев'); assert.equal(ul.find('li').length, 1); assert.equal(ul.find('li').first().html(), 'Дмитрий Менделеев'); assert.equal(ul.find('li.no-results').length, 0); >);
Не так сложно, правда? Здесь используется Mocha в качестве тестирующего фреймворка и Chai как дополнительная библиотека. Mocha обеспечивает функции test и setup ; Chai — функцию assert . Существует бездна других фреймворков для тестирования, но я нахожу эти два достаточными для введения в предметную область. Вам же следует найти свой фреймворк по своим предпочтениям — Qunit популярен, а новый Intern подаёт большие надежды.
Приведённый код начинается с создания элемента, который будет использован как контейнер для сектора понравившегося. Затем запускается два теста: первая проверка на вменяемость — можем ли мы создать Like Box; вторая, чтобы удостовериться, что метод .add() имеет желаемый эффект. При наличии этих тестов, у нас появляется возможность безопасно рефакторить и быть уверенными, что мы сразу узнаем баге.
Код нашего приложения теперь выглядит так:
var liked = new Likes('#liked'); var resultsList = $('#results'); // … resultsList.on('click', '.like', function (e) < e.preventDefault(); var name = $(this).closest('li').find('h2').text(); liked.add(name); >);
Код, отвечающий за поисковые результаты, сложнее, чем Like Box, но давайте попробуем свои силы в рефакторинге. Точно так же, как мы создали метод .add() у Likes Box, мы хотим создать методы для общения с поисковыми результатами. Мы хотим добавлять новые результаты и удобные способы оповещения других частей приложения о событиях внутри поисковых результатов — например, когда кому-то понравился пункт поисковой выдачи.
var SearchResults = function (el) < this.el = $(el); this.el.on( 'click', '.btn.like', _.bind(this._handleClick, this) ); >; SearchResults.prototype.setResults = function (results) < var templateRequest = $.get('people-detailed.tmpl'); templateRequest.then( _.bind(this._populate, this, results) ); >; SearchResults.prototype._handleClick = function (evt) < var name = $(evt.target).closest('li.result').attr('data-name'); $(document).trigger('like', [ name ]); >; SearchResults.prototype._populate = function (results, tmpl) < var html = _.template(tmpl, < people: results >); this.el.html(html); >;
Теперь код нашего старого приложения, отвечающий за взаимодействие между поисковыми результатами и Likes Box, выглядит так:
var liked = new Likes('#liked'); var resultsList = new SearchResults('#results'); // … $(document).on('like', function (evt, name) < liked.add(name); >)
Такой код намного более простой и менее запутанный, потому что мы используем document как глобальный транспорт для сообщений, и, передавая данные через него, мы избавляем отдельные части приложения от необходимости знать друг о друге. (В реальной жизни мы использовали бы backbone или RSVP для управления событиями. В текущем демонстрационном приложении мы запускаем события в document для упрощения кода). Мы также спрячем всю рутинную работу — поиск имени понравившегося человека из поисковой выдачи — внутри объекта поисковых результатов, чтобы не загрязнять им код приложения. Наконец, хорошие новости — теперь мы можем писать тесты, чтобы доказать, что работа поисковых результатов соотствует нашим ожиданиям:
var ul; var data = [ /* ненастоящие данные */ ]; setup(function ( ) < ul = $(' '); >); test('constructor', function ( ) < var sr = new SearchResults(ul); assert(sr); >); test('display received results', function ( ) < var sr = new SearchResults(ul); sr.setResults(data); assert.equal(ul.find('.no-results').length, 0); assert.equal(ul.find('li.result').length, data.length); assert.equal( ul.find('li.result').first().attr('data-name'), data[0].name ); >); test('announce likes', function( ) < var sr = new SearchResults(ul); var flag; var spy = function ( ) < flag = [].slice.call(arguments); >; sr.setResults(data); $(document).on('like', spy); ul.find('li').first().find('.like.btn').click(); assert(flag, 'event handler called'); assert.equal(flag[1], data[0].name, 'обработчик события получил данные' ); >);
Взаимодействие с сервером — другая часть для обсуждения. Оригинальный код содержит в себе прямой вызов $.ajax() , и обработчик этого вызова работает напрямую с DOM:
$.ajax('/data/search.json', < data : < q: query >, dataType : 'json', success : function( data ) < loadTemplate('people-detailed.tmpl').then(function(t) < var tmpl = _.template( t ); resultsList.html( tmpl(< people : data.results >) ); pending = false; >); > >);
Повторюсь, что очень трудно писать модульные тесты для такого кода из-за того, что слишком много вещей происходит всего в нескольких строчках кода. Мы можем переделать объект с информацией в самостоятельный объект:
var SearchData = function ( ) < >; SearchData.prototype.fetch = function (query) < var dfd; if (!query) < dfd = $.Deferred(); dfd.resolve([]); return dfd.promise(); > return $.ajax( '/data/search.json', < data : < q: query >, dataType : 'json' >).pipe(function( resp ) < return resp.results; >); >;
Сейчас мы можем изменить код, чтобы получить результаты на странице:
var resultsList = new SearchResults('#results'); var searchData = new SearchData(); // … searchData.fetch(query).then(resultsList.setResults);
В который раз замечу, что мы невообразимо упростили код нашего приложениня, и спрятали всю сложность кода в объект Search Data вместо того, чтобы хранить его в общем коде. Также мы сделали наш поисковой интерфейс тестируемым, в тоже время надо помнить о некоторых оссобенностях при тестировании кода, взаимодействующего с сервером.
Первое — это то, что нам не нужно взаимодействовать с сервером по-настоящему — это проникновение в мир интеграционных тестов, а так как мы ответственные разработчики, то у нас уже есть тесты, сообщающие, что сервер работает правильно; всё верно? Вместо этого мы хотим создать заглушку для серверного взаимодействия, и это мы можем сделать с помощью библиотеки Sinion. Второй важный момент — нам также необходимо тестировать неидеальные случаи, например, пустой запрос.
test('constructor', function ( ) < var sd = new SearchData(); assert(sd); >); suite('fetch', function ( ) < var xhr, requests; setup(function ( ) < requests = []; xhr = sinon.useFakeXMLHttpRequest(); xhr.onCreate = function (req) < requests.push(req); >; >); teardown(function ( ) < xhr.restore(); >); test('fetches from correct URL', function ( ) < var sd = new SearchData(); sd.fetch('cat'); assert.equal(requests[0].url, '/data/search.json?q=cat'); >); test('вернуть promise', function ( ) < var sd = new SearchData(); var req = sd.fetch('cat'); assert.isFunction(req.then); >); test('нет ответа, если нет запроса', function ( ) < var sd = new SearchData(); var req = sd.fetch(); assert.equal(requests.length, 0); >); test('вернуть promise, даже если нет запроса', function ( ) < var sd = new SearchData(); var req = sd.fetch(); assert.isFunction( req.then ); >); test('no query promise resolves with empty array', function ( ) < var sd = new SearchData(); var req = sd.fetch(); var spy = sinon.spy(); req.then(spy); assert.deepEqual(spy.args[0][0], []); >); test('returns contents of results property of the response', function ( ) < var sd = new SearchData(); var req = sd.fetch('cat'); var spy = sinon.spy(); requests[0].respond( 200, < 'Content-type': 'text/json' >, JSON.stringify(< results: [ 1, 2, 3 ] >) ); req.then(spy); assert.deepEqual(spy.args[0][0], [ 1, 2, 3 ]); >); >);
Оставив это за пределами статьи, я провела рефакторинг объекта Search Form и упростила несколько участков кода и тестов, но если вам интересно, вы можете взглянуть на законченную версию приложения в моём репозитории на гитхабе.
По окончании переписывания приложения с использованием шаблонов тестируемого JavaScript, мы пришли к более ясному, чистому и поддерживаемому, в отличие от стартового, коду:
$(function( ) < var pending = false; var searchForm = new SearchForm('#searchForm'); var searchResults = new SearchResults('#results'); var likes = new Likes('#liked'); var searchData = new SearchData(); $(document).on('search', function (event, query) < if (pending) < return; > pending = true; searchData.fetch(query).then(function (results) < searchResults.setResults(results); pending = false; >); searchResults.pending(); >); $(document).on('like', function (evt, name) < likes.add(name); >); >);
Важнее того, что мы получили более аккуратный код, может быть только то, что он превосходно покрыт модульными тестами. Это означает, что мы можем смело рефакторить приложение без страха поломки чего-либо. Мы даже можем написать дополнительные тесты, если появится такая необходимость, и потом мы напишем код, который с успехом пройдёт все тесты.
Тестирование повышает качество жизни в долгосрочной перспективе
Несложно посмотреть на всё, что тут написано и спросить: «Подождите, вы хотите, чтобы я писал больше кода, который бы делал ту же самую работу?»
Причина в некоторых непререкаемых фактах относительно создания вещей в интернете. Вы потратите время, разрабатывая решение проблемы. Вы проверите своё решение, прокликав интерфейс в браузере и написав автоматические модульные тесты, или вероломно позволите пользователю тестировать ваше приложение в продакшене. И тем не менее, сколько бы вы не написали тестов, баги будут всегда.
Понимание тестирования заключается в том, что оно, возможно, и занимает чуть больше времени на старте, но в итоге оно экономит вам время в будущем. Вы будете прыгать от радости, когда тест, написанный вами, впервые обнаружит баг до того, как он попадёт в продакшн. Также вы будете счастливы, когда система тестов сможет подтвердить, что ваши правки действительно фиксят баг, для которого они предназначались.
Дополнительные источники
Эта статья только поверхностно описывает JavaScript-тестирование, но если вы заинтересовались и хотите изучить тему глубже, то обязательно проверьте следующие ссылки:
- Моя презентация с конференции Full Frontall (Брайтон, Великобритания, 2012);
- Grunt — инструмент, который поможет автоматизировать процесс тестирования;
- Книга Test-Driven JavaScript Development Кристиана Джохансона, создателя библиотеки Sinion. Это краткая, но очень информативная проверка по основным постулатам тестируемого JavaScript.
Написание интеграционных тестов с использованием PageObjects
Наши учебники классифицируются как «новичок», «средний» и «продвинутый». Этот уровень находится на «среднем» уровне. Если вы еще никогда не разрабатывали плагин, вы можете найти это немного сложнее.
Обзор
В этом учебном пособии показано, как писать страницы PageObjects для ваших плагинов и использовать их в тестах интеграции.
Вы должны быть уже знакомы с написанием плагина JIRA и с его модульным тестированием. Также вы можете прочитать о предыдущем методе создания интеграционных тестов с использованием FuncTestCase.
Существуют различные определения, доступные для тестирования интеграции. Для целей этого руководства тестирование интеграции определяется как запуск набора автоматических тестов против веб-интерфейса вашего плагина, используя HTTP-запросы и ответы, чтобы утверждать, что веб-интерфейс демонстрирует правильное поведение.
Шаг 1. Настройка вашего плагина
Лучший способ начать создание собственных объектов страницы — включить объекты страницы JIRA в качестве ссылки и базы для ваших объектов. Поэтому вам нужно изменить свой pom.xml и добавить следующее:
com.atlassian.jira atlassian-jira-pageobjects $ test
Шаг 2. Анатомия объекта страницы
Представим себе, что вы пишете интеграционный тест для CSV импортера. На странице есть несколько элементов, таких как ввод файлов, флажок, поля ввода и кнопки. Вот простой класс, который вы могли бы использовать для тестирования и управления этой страницей.
public class CsvSetupPage extends AbstractJiraPage < @ElementBy(id = "nextButton") protected PageElement nextButton; @ElementBy(cssSelector = "#advanced h3.toggle-title") private PageElement advanced; @Override public TimedCondition isAt() < return nextButton.timed().isVisible(); >@Override public String getUrl() < return "/secure/admin/views/CsvSetupPage!default.jspa?externalSystem=com.atlassian.jira.plugins.jira-importers-plugin:csvImporter"; >public CsvSetupPage setCsvFile(String resource) < csvFile.type(resource); return this; >public CsvSetupPage setDelimiter(String delimiter) < showAdvanced(); this.delimiter.clear(); this.delimiter.type(delimiter); return this; >public CsvProjectMappingsPage next() < Poller.waitUntilTrue(nextButton.timed().isEnabled()); nextButton.click(); return pageBinder.bind(CsvProjectMappingsPage.class); >>
Введем пару элементов:
- PageElement — это элемент DOM, с которым вы хотите взаимодействовать (изменять, кликать, читать или писать)
- Аннотирование @ElementBy определяет, как искать элемент; вы можете искать такие вещи, как id, cssSelector, name и tag
- Метод isAt используется AbstractJiraPage для определения того, была ли загрузка страницы завершена и загружена ожидаемая страница; он использует внутреннюю аннотацию @WaitUntil
- Метод getUrl , определенный в AbstractJiraPage , указывает, куда идти, чтобы открыть ожидаемую страницу
- Объект pageBinder отвечает за привязку объекта страницы к DOM, следующий метод показывает, как перемещаться с одной страницы на другую
Как вы можете видеть, вы можете набрать или щелкнуть элементы. Вы также можете получить их атрибуты, искать элементы страницы внутри элементов страницы и т. д. Самое замечательное, что объекты страницы запущены в реальном браузере, поэтому вы действительно имитируете пользователя и свой опыт.
Шаг 3. Использование объектов страницы в ваших тестах
Я предполагаю, что вы используете JIRA Func Test Basics для создания теста интеграции. Чтобы упростить ситуацию, давайте предположим, что вы расширили TestBase (проверьте документацию JIRA Func Test Basics для лучшего, предлагаемого способа написания интеграционных тестов).
public class TestCsvSetupPage extends TestBase < @Test public void testSetupPage() < CsvSetupPage setupPage = jira().gotoLoginPage() .loginAsSysAdmin(CsvSetupPage.class); CsvProjectMappingPage mappingPage = setupPage .setCsvFile("JIM-77.csv").setDelimiter(";").next(); >>
Этот простой тестовый случай заполнил форму и нажал следующую кнопку, а затем проверил, правильно ли перемещен пользователь на следующую страницу мастера, которая является CsvProjectMappingPage.
Шаг 4. Запись утверждений для элементов
Вот как вы можете проверить, делает ли ваш код то, что вы ожидаете от него. Чтобы избежать условий гонки, ваш объект страницы всегда должен возвращать TimedQuery или TimedCondtion . Давайте проверим, правильно ли прочитано userEmailSuffix из конфигурации.
public class CsvProjectMappingPage extends AbstractJiraPage < @ElementBy(name = "userEmailSuffix") PageElement userEmailSuffix; // removed for brevity public TimedQuerygetUserEmailSuffix() < return userEmailSuffix.timed().getAttribute("value"); >>
Ваш тест может выглядеть следующим образом:
public TestCsvProjectMappingPage extends TestBase < @Test public void testIfConfigurationIsReadProperly() < CsvSetupPage setupPage = jira().gotoLoginPage() .loginAsSysAdmin(CsvSetupPage.class); CsvProjectMappingPage projectMappingPage = setupPage .setCsvFile("JIM-80.csv") .setConfigurationFile("JIM-80.config").next(); Poller.waitUntil(projectMappingsPage.getUserEmailSuffix(), (Matcher) equalTo("atlassian.com")); > >
Используйте Poller.waitUntil для проверки утверждения. Он будет опрашивать объект страницы до тех пор, пока не будет выполнено предположение или не пройдет тайм-аут. Это здорово, если у вас есть элементы, которые динамически создаются, или вы используете AJAX для извлечения данных. Он будет следить за тем, чтобы в вашем коде не было условий гонки.
Шаг 5. Написание утверждений для коллекций
Представьте, что мы хотим проверить, отображаются ли на странице правильные подсказки для пользователя. Вот объект страницы:
public class CsvFieldMappingsPage extends AbstractJiraPage < @Inject private Timeouts timeouts; @Inject private ExtendedElementFinder extendedElementFinder; @ElementBy (className = "bottom-wizard-hints") PageElement bottomWizardHints; // removed for brevity public TimedQuery> getBottomWizardHints() < return Queries.forSupplier(timeouts, extendedElementFinder.within(bottomWizardHints) .newQuery(By.className("hint")) .supplier()); >>
Вы можете проверить, содержит ли страница правильные подсказки в вашем тесте следующим образом:
Poller.waitUntil(fieldMappingsPage.getBottomWizardHints(), IsIterableWithSize.iterableWithSize(3)); assertThat(PageElements.asText(fieldMappingsPage.getBottomWizardHints().now()), IsCollectionContaining.hasItems( containsString("For issues with multiple"), containsString("Existing custom fields must"), containsString("CSV file has at least one empty")));
Шаг 6. Выполнение тестов на CI
Существует также простой способ запуска интеграционных тестов в режиме без заголовков в CI, все, что вам нужно сделать, это отредактировать pom.xml и установить свойство xvfb.enable :
true
Atlassian Plugin SDK автоматически запустит Xvfb и проведет ваши тесты, используя его.
Шаг 7. Еще больше ресурсов для объектов страницы
Также есть подробное руководство, которое было опубликовано в блоге разработчика Дариушем Кордонски. Проверьте это, чтобы узнать больше!
Поздравляю, вот и все
Теперь вы можете написать интеграционные тесты для JIRA. О, и не забывайте, что есть шоколад!
ПОХОЖИЕ ТЕМЫ
- Написание интеграционных тестов для вашего JIRA-плагина
- Интеллектуальное тестирование с TestKit
Интеграционные тесты на платформе ASP.NET Core
С помощью интеграционных тестов можно проверить работу компонентов приложения на уровне, который включает инфраструктуру, поддерживающую приложение, такую как база данных, файловая система и сеть. ASP.NET Core поддерживает интеграционные тесты с помощью платформы модульного тестирования с тестовым веб-узлом и сервером тестирования в памяти.
В этой статье предполагается базовое понимание модульных тестов. Если не знакомы с понятиями тестирования, ознакомьтесь со статьей о модульном тестировании в .NET Core и .NET Standard и связанном с ним содержимом.
В качестве примера используется приложение Razor Pages (предполагается базовое понимание Razor Pages). Если вы не знакомы со Страницами, ознакомьтесь со Razor следующими статьями:
- Введение в Razor Pages
- Начало работы с Razor Pages
- Модульные тесты Razor Pages
Для тестирования spAs рекомендуется использовать такой инструмент, как Playwright для .NET, который может автоматизировать браузер.
Общие сведения об интеграционных тестах
Интеграционные тесты служат для оценки компонентов приложения на более широком уровне, чем модульные тесты. Модульные тесты используются для тестирования изолированных программных компонентов, таких как отдельные методы класса. Интеграционные тесты позволяют убедиться, что два или несколько компонентов приложения работают совместно для получения ожидаемого результата, включая, возможно, все компоненты, необходимые для полной обработки запроса.
Эти более широкие тесты используются для тестирования инфраструктуры приложения и всей платформы, зачастую включая следующие компоненты:
- База данных
- Файловая система
- Сетевые устройства
- Конвейер «запрос-ответ»
В модульных тестах вместо компонентов инфраструктуры используются структурные компоненты, известные как имитации или макеты объектов.
В отличие от модульных тестов, интеграционные тесты:
- Используют фактические компоненты, которые приложение использует в рабочей среде.
- Требуют больше кода и обработки данных.
- Выполняются дольше.
Поэтому используйте интеграционные тесты только для наиболее важных сценариев инфраструктуры. Если поведение можно проверить с помощью модульного теста или интеграционного теста, выбирайте модульный тест.
В обсуждениях тестов интеграции тесты тесты, тестируемый проект часто называется системным тестом или «SUT» для краткого. SUT используется в этой статье для ссылки на тестируемое приложение ASP.NET Core.
Не записывайте тесты интеграции для каждого перемещения данных и доступа к файлам с базами данных и файловыми системами. В скольких бы местах приложение ни взаимодействовало с ними, фокусный набор интеграционных тестов на чтение, запись, обновление и удаление обычно способен адекватно протестировать их компоненты. Используйте модульные тесты для стандартных тестов логики методов, взаимодействующих с этими компонентами. В модульных тестах использование поддельных или макетов инфраструктуры приводит к более быстрому выполнению тестов.
Интеграционные тесты ASP.NET Core
Для интеграционных тестов в ASP.NET Core требуется следующее:
- Тестовый проект, который используется для хранения и выполнения тестов. Тестовый проект содержит ссылку на ТС.
- Тестовый проект создает тестовый веб-узел для ТС и использует клиент тестового сервера для обработки запросов и ответов с помощью ТС.
- Средство запуска тестов используется для выполнения тестов и передачи результатов тестов.
Интеграционные тесты придерживаются последовательности событий из обычных шагов теста Подготовка, Выполнение и Проверка.
- Настраивается веб-узел ТС.
- Создается клиент тестового сервера для отправки запросов к приложению.
- Выполняется шаг «Упорядочить» — тестовое приложение подготавливает запрос.
- Выполняется шаг теста Act: клиент отправляет запрос и получает ответ.
- Выполняется шаг теста Assert: фактический ответ проверяется как проход или сбой на основе ожидаемого ответа.
- Процесс продолжается до тех пор, пока не будут выполнены все тесты.
- Выводятся результаты теста.
Как правило, тестовый веб-узел настраивается отлично от обычного веб-узла приложения для тестовых запусков. Например, для тестов может использоваться другая база данных или другие параметры приложения.
Компоненты инфраструктуры, такие как тестовый веб-узел и сервер тестирования в памяти (TestServer), предоставляются или управляются пакетом Microsoft.AspNetCore.Mvc.Testing. Использование этого пакета упрощает создание и выполнение тестов.
Пакет Microsoft.AspNetCore.Mvc.Testing выполняет следующие задачи:
- Копирует файл зависимостей ( .deps ) из SUT в каталог тестового проекта bin .
- Задает корневой каталог содержимого в корне проекта ТС, чтобы при выполнении тестов были найдены статические файлы и страницы или представления.
- Предоставляет класс WebApplicationFactory для упрощения начальной загрузки ТС с TestServer .
В документации модульные тесты описано, как настроить тестовый проект и средство запуска тестов, а также представлены подробные инструкции по выполнению тестов и рекомендаций по именованию тестов и тестовых классов.
Отделяйте модульные тесты от тестов интеграции в разные проекты. Разделение тестов:
- Помогает убедиться, что компоненты тестирования инфраструктуры не случайно включены в модульные тесты.
- Позволяет контролировать выполнение набора тестов.
В сущности, между конфигурацией для тестов приложений Razor Pages и приложений MVC нет никаких отличий. Единственное отличие заключается в том, как именуются тесты. В приложении Razor Pages тесты конечных точек страницы обычно именуются по классу модели страницы (например, IndexPageTests для тестирования интеграции компонентов на странице индекса). В приложении MVC тесты обычно упорядочены по классам контроллеров и именуются по контроллеру, который они проверяют (например, HomeControllerTests для тестирования интеграции компонентов на контроллере Home).
Проверка необходимых требований к приложению
Тестовый проект должен выполнять следующие требования.
- Ссылаться на пакет Microsoft.AspNetCore.Mvc.Testing .
- Указывать веб-пакет SDK в файле проекта ( ).
Выполнение необходимых требований можно посмотреть в примере приложения. Изучите файл tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj . В примере приложения используются платформа тестирования xUnit и библиотека средства синтаксического анализа AngleSharp, поэтому он также ссылается на:
В приложениях, которые используют xunit.runner.visualstudio версии 2.4.2 или более поздней версии, тестовый проект должен ссылаться на пакет Microsoft.NET.Test.Sdk .
В тестах также используется Entity Framework Core. См. файл проекта в GitHub.
Среда ТС
Если среда ТС не задана, то по умолчанию среда имеет значение Development.
Базовые тесты со стандартной WebApplicationFactory
Предопробуй неявно определенный Program класс для тестового проекта, выполнив одно из следующих действий:
-
Предоставить внутренние типы из веб-приложения в тестовый проект. Это можно сделать в файле проекта SUT ( .csproj ):
var builder = WebApplication.CreateBuilder(args); // . Configure services, routes, etc. app.Run(); + public partial class Program
WebApplicationFactory используется для создания TestServer для интеграционных тестов. TEntryPoint — это класс точки входа SUT, как правило Program.cs .
Тестовые классы реализуют интерфейс средства тестирования ( IClassFixture ), чтобы указать, что класс содержит тесты, и предоставить экземпляры общего объекта для тестов в классе.
Следующий тестовый класс, BasicTests , использует WebApplicationFactory для начальной загрузки ТС и предоставляет HttpClient тестовому методу Get_EndpointsReturnSuccessAndCorrectContentType . Метод проверяет успешное выполнение кода состояния ответа (200–299), а Content-Type заголовок предназначен text/html; charset=utf-8 для нескольких страниц приложения.
CreateClient() создает экземпляр класса HttpClient , который автоматически следует за перенаправлениями и обрабатывает файлы cookie.
public class BasicTests : IClassFixture> < private readonly WebApplicationFactory_factory; public BasicTests(WebApplicationFactory factory) < _factory = factory; >[Theory] [InlineData("/")] [InlineData("/Index")] [InlineData("/About")] [InlineData("/Privacy")] [InlineData("/Contact")] public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url) < // Arrange var client = _factory.CreateClient(); // Act var response = await client.GetAsync(url); // Assert response.EnsureSuccessStatusCode(); // Status Code 200-299 Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString()); >>
По умолчанию не важные cookieзначения не сохраняются во всех запросах, когда включена политика согласия общего регулирования защиты данных. Чтобы сохранить ненужные файлы cookie, например те, которые используются поставщиком TempData, пометьте их как основные в тестах. Инструкции по маркировке файла cookie в качестве основы см. в разделе Основные файлы cookie.
AngleSharp vs Application Parts для антифоргерийных проверка
В этой статье используется средство синтаксического анализа AngleSharp для обработки антифоргерийных проверка путем загрузки страниц и синтаксического анализа HTML. Для тестирования конечных точек представлений контроллера и Razor страниц на более низком уровне, не заботясь о том, как они отображаются в браузере, рассмотрите возможность использования Application Parts . Подход «Части приложения» внедряет контроллер или Razor страницу в приложение, которое можно использовать для выполнения JSзапросов ON для получения необходимых значений. Дополнительные сведения см. в блоге по тестированию интеграции ASP.NET основных ресурсов, защищенных с помощью антифоргерии с помощью частей приложений и связанного репозитория GitHub мартином Costello.
Настройка WebApplicationFactory
Конфигурацию веб-узла можно создать независимо от тестовых классов путем наследования от WebApplicationFactory для создания одной или нескольких пользовательских фабрик:
-
Наследование от WebApplicationFactory и переопределение ConfigureWebHost. Позволяет IWebHostBuilder настроить коллекцию служб с помощью IWebHostBuilder.ConfigureServices
public class CustomWebApplicationFactory : WebApplicationFactory where TProgram : class < protected override void ConfigureWebHost(IWebHostBuilder builder) < builder.ConfigureServices(services => < var dbContextDescriptor = services.SingleOrDefault( d =>d.ServiceType == typeof(DbContextOptions)); services.Remove(dbContextDescriptor); var dbConnectionDescriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbConnection)); services.Remove(dbConnectionDescriptor); // Create open SqliteConnection so EF won't automatically close it. services.AddSingleton(container => < var connection = new SqliteConnection("DataSource=:memory:"); connection.Open(); return connection; >); services.AddDbContext((container, options) => < var connection = container.GetRequiredService(); options.UseSqlite(connection); >); >); builder.UseEnvironment("Development"); > >
- Сошлитесь на пакет NuGet Microsoft.EntityFrameworkCore.SqlServer в файле проекта.
- Вызовите процедуру UseInMemoryDatabase .
public class IndexPageTests : IClassFixture> < private readonly HttpClient _client; private readonly CustomWebApplicationFactory_factory; public IndexPageTests( CustomWebApplicationFactory factory) < _factory = factory; _client = factory.CreateClient(new WebApplicationFactoryClientOptions < AllowAutoRedirect = false >); >
[Fact] public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot() < // Arrange var defaultPage = await _client.GetAsync("/"); var content = await HtmlHelpers.GetDocumentAsync(defaultPage); //Act var response = await _client.SendAsync( (IHtmlFormElement)content.QuerySelector("form[id='messages']"), (IHtmlButtonElement)content.QuerySelector("button[id='deleteAllBtn']")); // Assert Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.Equal("/", response.Headers.Location.OriginalString); >
Любой запрос POST к ТС должен соответствовать проверке защиты от подделки, которая автоматически вносится в систему защиты данных от подделки приложения. Чтобы упорядочить запрос POST теста, тестовое приложение должно:
- Выполнить запрос к странице.
- Проанализировать файл cookie для защиты от подделки и запросить маркер проверки из ответа.
- Выполните запрос POST с файлом cookie для защиты от подделки и запросом маркера проверки на месте.
Вспомогательные методы расширения SendAsync ( Helpers/HttpClientExtensions.cs ) и вспомогательный метод GetDocumentAsync ( Helpers/HtmlHelpers.cs ) в примере приложения используют средство синтаксического анализа AngleSharp для обработки защиты от подделки с помощью следующих методов:
- GetDocumentAsync : получает HttpResponseMessage и возвращает IHtmlDocument . GetDocumentAsync использует фабрику, которая подготавливает виртуальный ответ на основе исходного HttpResponseMessage . Дополнительные сведения см. в документации по AngleSharp.
- Методы расширения SendAsync для HttpClient составляют HttpRequestMessage и вызывают SendAsync(HttpRequestMessage) для отправки запросов к ТС. Перегрузки для SendAsync принятия HTML-формы ( IHtmlFormElement ) и следующих:
- Кнопка «Отправить» в форме ( IHtmlElement )
- Коллекция значений формы ( IEnumerable> )
- Кнопка «Отправить» ( IHtmlElement ) и значения формы ( IEnumerable> )
AngleSharp — это сторонняя библиотека синтаксического анализа, используемая для демонстрационных целей в этой статье и пример приложения. AngleSharp не поддерживается интеграционным тестированием приложением ASP.NET Core и не требуется для этого. Можно использовать и другие средства синтаксического анализа, такие как Html Agility Pack (HAP). Другой подход заключается в написании кода для непосредственной работы с маркером проверки запроса системы защиты от подделки и файлом cookie защиты от подделки. Дополнительные сведения см. в разделе AngleSharp и Application Parts для антифоргерийных проверка в этой статье.
Поставщик базы данных EF-Core в памяти можно использовать для ограниченного и базового тестирования, однако поставщик SQLite рекомендуется использовать для тестирования в памяти.
См. раздел «Расширение запуска с помощью фильтров запуска», в котором показано, как настроить по промежуточному слоям, IStartupFilterкоторое полезно, если тест требует пользовательской службы или ПО промежуточного слоя.
Настройка клиента с помощью WithWebHostBuilder
Если в методе теста требуется дополнительная настройка, WithWebHostBuilder создает новый объект WebApplicationFactory с IWebHostBuilder, который дополнительно настраивается в конфигурации.
Метод теста Post_DeleteMessageHandler_ReturnsRedirectToRoot в примере приложения демонстрирует использование WithWebHostBuilder . Этот тест выполняет удаление записи из базы данных, активируя отправку формы в ТС.
Поскольку другой тест в классе IndexPageTests выполняет операцию, которая удаляет все записи из базы данных и может выполняться до метода Post_DeleteMessageHandler_ReturnsRedirectToRoot , база данных повторно заполняется в этом методе теста, чтобы обеспечить наличие записи для ее удаления ТС. Выбор первой кнопки удаления в форме messages в ТС имитируется в запросе к ТС:
[Fact] public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot() < // Arrange using (var scope = _factory.Services.CreateScope()) < var scopedServices = scope.ServiceProvider; var db = scopedServices.GetRequiredService(); Utilities.ReinitializeDbForTests(db); > var defaultPage = await _client.GetAsync("/"); var content = await HtmlHelpers.GetDocumentAsync(defaultPage); //Act var response = await _client.SendAsync( (IHtmlFormElement)content.QuerySelector("form[id='messages']"), (IHtmlButtonElement)content.QuerySelector("form[id='messages']") .QuerySelector("div[class='panel-body']") .QuerySelector("button")); // Assert Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.Equal("/", response.Headers.Location.OriginalString); >Параметры клиента
См. WebApplicationFactoryClientOptions страницу по умолчанию и доступные параметры при создании HttpClient экземпляров.
WebApplicationFactoryClientOptions Создайте класс и передайте его в CreateClient() метод:
public class IndexPageTests : IClassFixture> < private readonly HttpClient _client; private readonly CustomWebApplicationFactory_factory; public IndexPageTests( CustomWebApplicationFactory factory) < _factory = factory; _client = factory.CreateClient(new WebApplicationFactoryClientOptions < AllowAutoRedirect = false >); >ПРИМЕЧАНИЕ. Чтобы избежать предупреждений перенаправления HTTPS в журналах при использовании ПО промежуточного слоя перенаправления HTTPS, задайте BaseAddress = new Uri(«https://localhost»)
Вставка служб имитации
Службы можно переопределить в тесте с помощью вызова ConfigureTestServices в построителе узлов.
Пример ТС включает службу с заданной областью, которая возвращает цитату. Цитата внедряется в скрытое поле на странице индекса при запросе страницы индекса.
public interface IQuoteService < TaskGenerateQuote(); >// Quote ©1975 BBC: The Doctor (Tom Baker); Dr. Who: Planet of Evil // https://www.bbc.co.uk/programmes/p00pyrx6 public class QuoteService : IQuoteService < public TaskGenerateQuote() < return Task.FromResult( "Come on, Sarah. We've an appointment in London, " + "and we're already 30,000 years late."); > >services.AddScoped();public class IndexModel : PageModel < private readonly ApplicationDbContext _db; private readonly IQuoteService _quoteService; public IndexModel(ApplicationDbContext db, IQuoteService quoteService) < _db = db; _quoteService = quoteService; >[BindProperty] public Message Message < get; set; >public IList Messages < get; private set; >[TempData] public string MessageAnalysisResult < get; set; >public string Quote < get; private set; >public async Task OnGetAsync()
При запуске приложения ТС создается следующая разметка:
Чтобы протестировать службу и внедрение цитат в интеграционный тест, служба имитации будет внедрена в ТС тестом. Служба имитации заменяет QuoteService приложения службой, предоставляемой тестовым приложением, с именем TestQuoteService :
// Quote ©1975 BBC: The Doctor (Tom Baker); Pyramids of Mars // https://www.bbc.co.uk/programmes/p00pys55 public class TestQuoteService : IQuoteService < public TaskGenerateQuote() < return Task.FromResult( "Something's interfering with time, Mr. Scarman, " + "and time is my business."); >>Вызывается ConfigureTestServices и регистрируется служба с заданной областью:
[Fact] public async Task Get_QuoteService_ProvidesQuoteInPage() < // Arrange var client = _factory.WithWebHostBuilder(builder => < builder.ConfigureTestServices(services =>< services.AddScoped(); >); >) .CreateClient(); //Act var defaultPage = await client.GetAsync("/"); var content = await HtmlHelpers.GetDocumentAsync(defaultPage); var quoteElement = content.QuerySelector("#quote"); // Assert Assert.Equal("Something's interfering with time, Mr. Scarman, " + "and time is my business.", quoteElement.Attributes["value"].Value); >Разметка, созданная во время выполнения теста, отражает текст цитаты, предоставленный TestQuoteService , поэтому утверждение передается следующим образом:
Имитация проверки подлинности
Тесты в классе AuthTests проверяют, что безопасная конечная точка:
- Перенаправляет пользователя без проверки подлинности на страницу входа приложения.
- возвращает содержимое для пользователя, прошедшего проверку подлинности.
В ТС на странице /SecurePage используется соглашение AuthorizePage, чтобы применить AuthorizeFilter к странице. Дополнительные сведения см. в разделе Соглашения проверки подлинности RazorPages.
services.AddRazorPages(options => < options.Conventions.AuthorizePage("/SecurePage"); >);В тесте Get_SecurePageRedirectsAnUnauthenticatedUser WebApplicationFactoryClientOptions настроен таким образом, чтобы запретить перенаправление (путем установки значения false для параметра AllowAutoRedirect):
[Fact] public async Task Get_SecurePageRedirectsAnUnauthenticatedUser() < // Arrange var client = _factory.CreateClient( new WebApplicationFactoryClientOptions < AllowAutoRedirect = false >); // Act var response = await client.GetAsync("/SecurePage"); // Assert Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.StartsWith("http://localhost/Identity/Account/Login", response.Headers.Location.OriginalString); >Если запретить клиенту следовать перенаправлению, можно выполнить следующие проверки:
- Код состояния, возвращаемый SUT, может быть проверка в ожидаемый HttpStatusCode.Redirect результат, а не окончательный код состояния после перенаправления на страницу входа, который будет иметь значениеHttpStatusCode.OK.
- Location Значение заголовка в заголовках ответа проверка, чтобы убедиться, что он начинается с http://localhost/Identity/Account/Login , а не окончательный ответ на страницу входа, где Location заголовок не будет присутствовать.
Тестовое приложение может имитировать макет AuthenticationHandler в ConfigureTestServices для тестирования аспектов проверки подлинности и авторизации. Для минимального сценария возвращается AuthenticateResult.Success:
public class TestAuthHandler : AuthenticationHandler < public TestAuthHandler(IOptionsMonitoroptions, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) < >protected override Task HandleAuthenticateAsync() < var claims = new[] < new Claim(ClaimTypes.Name, "Test user") >; var identity = new ClaimsIdentity(claims, "Test"); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, "TestScheme"); var result = AuthenticateResult.Success(ticket); return Task.FromResult(result); > >Для проверки подлинности пользователя вызывается TestAuthHandler , если для схемы проверки подлинности задано TestScheme , где AddAuthentication зарегистрировано для ConfigureTestServices . Важно, чтобы схема TestScheme соответствовала схеме, которую ожидает приложение. В противном случае проверка подлинности не будет работать.
[Fact] public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser() < // Arrange var client = _factory.WithWebHostBuilder(builder => < builder.ConfigureTestServices(services =>< services.AddAuthentication(defaultScheme: "TestScheme") .AddScheme( "TestScheme", options => < >); >); >) .CreateClient(new WebApplicationFactoryClientOptions < AllowAutoRedirect = false, >); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(scheme: "TestScheme"); //Act var response = await client.GetAsync("/SecurePage"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); >Дополнительные сведения о WebApplicationFactoryClientOptions см. в разделе Параметры клиента.
Базовые тесты для ПО промежуточного слоя проверки подлинности
См . этот репозиторий GitHub для базовых тестов ПО промежуточного слоя проверки подлинности. Он содержит тестовый сервер , который зависит от сценария тестирования.
Указание среды
Задайте среду в фабрике пользовательских приложений:
public class CustomWebApplicationFactory : WebApplicationFactory where TProgram : class < protected override void ConfigureWebHost(IWebHostBuilder builder) < builder.ConfigureServices(services => < var dbContextDescriptor = services.SingleOrDefault( d =>d.ServiceType == typeof(DbContextOptions)); services.Remove(dbContextDescriptor); var dbConnectionDescriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbConnection)); services.Remove(dbConnectionDescriptor); // Create open SqliteConnection so EF won't automatically close it. services.AddSingleton(container => < var connection = new SqliteConnection("DataSource=:memory:"); connection.Open(); return connection; >); services.AddDbContext((container, options) => < var connection = container.GetRequiredService(); options.UseSqlite(connection); >); >); builder.UseEnvironment("Development"); > >Определение тестовой инфраструктурой пути к корневому каталогу содержимого приложения
Конструктор WebApplicationFactory выводит путь к корневому каталогу содержимого приложения, выполняя поиск WebApplicationFactoryContentRootAttribute в сборке, содержащей интеграционные тесты с ключом, равным сборке System.Reflection.Assembly.FullName TEntryPoint . Если атрибут с правильным ключом не найден, WebApplicationFactory возвращается к поиску файла решения (SLN) и добавляет имя сборки TEntryPoint в каталог решения. Корневой каталог приложения (корневой путь к содержимому) используется для обнаружения представлений и файлов содержимого.
Отключение теневого копирования
Теневое копирование приводит к тому, что тесты выполняются в каталоге, отличном от выходного каталога. Если тесты зависят от загрузки файлов относительно Assembly.Location и возникают проблемы, может потребоваться отключение теневого копирования.
Чтобы сделать это во время использования xUnit, создайте файл xunit.runner.json в каталоге тестового проекта с правильными настройками конфигурации:
Удаление объектов
После выполнения тестов реализации IClassFixture TestServer и HttpClient удаляются, когда xUnit удаляет WebApplicationFactory . Если объекты, создаваемые разработчиком, требуется удалить, удалите их в реализации IClassFixture . Дополнительные сведения см. в разделе Реализация метода Dispose.
Пример интеграционных тестов
Пример приложения состоит из двух приложений:
App каталог проекта; Description Приложение для сообщений (ТС) src/RazorPagesProject Позволяет пользователю добавлять, удалять сообщения (по одному или все) и анализировать их. Тестирование приложения. tests/RazorPagesProject.Tests Используется для тестирования интеграции ТС. Тесты можно выполнять с помощью встроенных функций тестирования интегрированной среды разработки, таких как Visual Studio. В Visual Studio Code или в командной строке выполните следующую команду в каталоге tests/RazorPagesProject.Tests :
dotnet testОрганизация приложения для сообщений (ТС)
Тестируемая система (ТС) — это система сообщений Razor Pages со следующими характеристиками:
- Страница индекса приложения ( Pages/Index.cshtml и Pages/Index.cshtml.cs ) предоставляет методы модели пользовательского интерфейса и страницы для управления добавлением, удалением и анализом сообщений (средние слова для каждого сообщения).
- Сообщение описывается классом ( Data/Message.cs ) с двумя свойствами Message : Id (ключ) и Text (сообщение). Свойство Text является обязательным и ограничено 200 символами.
- Сообщения хранятся с помощью базы данных Entity Framework в памяти†.
- Приложение содержит уровень доступа к данным (DAL) в классе AppDbContext контекста базы данных ( Data/AppDbContext.cs ).
- Если база данных пуста при запуске приложения, то хранилище сообщений инициализируется тремя сообщениями.
- Приложение включает /SecurePage , доступ к которому может получить только пользователь, прошедший проверку подлинности.
† Статья EF( Test with InMemory) объясняет, как использовать базу данных в памяти для тестов с помощью MSTest. В этом разделе используется платформа тестирования xUnit. Концепции тестирования и реализации тестов в разных платформах тестирования похожи, но не идентичны.
Несмотря на то, что приложение не использует шаблон репозитория и не является эффективным примером шаблона «единица работы» (UoW), Razor Pages поддерживает такие шаблоны разработки. Дополнительные сведения см. в разделах Проектирование уровня сохраняемости инфраструктуры и Логика контроллера тестирования (пример реализует шаблон репозитория).
Организация приложения для тестирования
Тестовое приложение — это консольное приложение в папке tests/RazorPagesProject.Tests .
- доступа к защищенной странице пользователя, не прошедшего проверку подлинности;
- доступа к защищенной странице пользователя, прошедшего проверку подлинности с помощью макета AuthenticationHandler ;
- получения профиля пользователя GitHub и проверки имени входа пользователя профиля.
- Utilities.cs содержит метод, используемый InitializeDbForTests для заполнения базы данных тестовых данных.
- HtmlHelpers.cs предоставляет метод для возврата An AngleSharp IHtmlDocument для использования методами тестирования.
- HttpClientExtensions.cs предоставляют перегрузки для SendAsync отправки запросов в SUT.
Используемая платформа тестирования — xUnit. Интеграционные тесты проводятся с помощью Microsoft.AspNetCore.TestHost, который включает TestServer. Поскольку пакет Microsoft.AspNetCore.Mvc.Testing используется для настройки узла тестирования и тестового сервера, для пакетов TestHost и TestServer не требуются прямые ссылки на пакеты в файле проекта тестового приложения или в конфигурации разработчика в тестовом приложении.
Перед выполнением интеграционных тестов обычно требуется небольшой набор данных в базе данных. Например, при удалении теста вызывается удаление записей базы данных, поэтому в базе данных должна иметься по крайней мере одна запись, чтобы запрос на удаление был выполнен успешно.
Пример приложения заполняет базу данных тремя сообщениями в Utilities.cs этом тесте, которые могут использовать при выполнении:
public static void InitializeDbForTests(ApplicationDbContext db) < db.Messages.AddRange(GetSeedingMessages()); db.SaveChanges(); >public static void ReinitializeDbForTests(ApplicationDbContext db) < db.Messages.RemoveRange(db.Messages); InitializeDbForTests(db); >public static List GetSeedingMessages() < return new List() < new Message()< Text = "TEST RECORD: You're standing on my scarf." >, new Message()< Text = "TEST RECORD: Would you like a jelly baby?" >, new Message() < Text = "TEST RECORD: To the rational mind, " + "nothing is inexplicable; only unexplained." >>; >Контекст базы данных SUT зарегистрирован в Program.cs . Обратный вызов builder.ConfigureServices тестового приложения выполняется после выполнения кода Program.cs приложения. Чтобы использовать для тестов другую базу данных, необходимо заменить контекст базы данных приложения в builder.ConfigureServices . Дополнительные сведения см. в разделе Настройка WebApplicationFactory.
Дополнительные ресурсы
- Модульные тесты
- Модульные тесты Razor Pages в ASP.NET Core
- ПО промежуточного слоя ядра ASP.NET
- Логика контроллера тестирования в ASP.NET Core
- Базовые тесты для ПО промежуточного слоя проверки подлинности
В этом разделе предполагается базовое понимание модульных тестов. Если вы не знакомы с концепциями тестирования, ознакомьтесь с разделом Модульное тестирование в .NET Core и .NET Standard и связанным с ним содержимым.
В качестве примера используется приложение Razor Pages (предполагается базовое понимание Razor Pages). Если вы не знакомы с Razor Pages, см. следующие разделы:
- Введение в Razor Pages
- Начало работы с Razor Pages
- Модульные тесты Razor Pages
Для тестирования spAs рекомендуется использовать такой инструмент, как Playwright для .NET, который может автоматизировать браузер.
Общие сведения об интеграционных тестах
Интеграционные тесты служат для оценки компонентов приложения на более широком уровне, чем модульные тесты. Модульные тесты используются для тестирования изолированных программных компонентов, таких как отдельные методы класса. Интеграционные тесты позволяют убедиться, что два или несколько компонентов приложения работают совместно для получения ожидаемого результата, включая, возможно, все компоненты, необходимые для полной обработки запроса.
Эти более широкие тесты используются для тестирования инфраструктуры приложения и всей платформы, зачастую включая следующие компоненты:
- База данных
- Файловая система
- Сетевые устройства
- Конвейер «запрос-ответ»
В модульных тестах вместо компонентов инфраструктуры используются структурные компоненты, известные как имитации или макеты объектов.
В отличие от модульных тестов, интеграционные тесты:
- Используют фактические компоненты, которые приложение использует в рабочей среде.
- Требуют больше кода и обработки данных.
- Выполняются дольше.
Поэтому используйте интеграционные тесты только для наиболее важных сценариев инфраструктуры. Если поведение можно проверить с помощью модульного теста или интеграционного теста, выбирайте модульный тест.
В обсуждениях тестов интеграции тесты тесты, тестируемый проект часто называется системным тестом или «SUT» для краткого. SUT используется в этой статье для ссылки на тестируемое приложение ASP.NET Core.
Не записывайте тесты интеграции для каждого перемещения данных и доступа к файлам с базами данных и файловыми системами. В скольких бы местах приложение ни взаимодействовало с ними, фокусный набор интеграционных тестов на чтение, запись, обновление и удаление обычно способен адекватно протестировать их компоненты. Используйте модульные тесты для стандартных тестов логики методов, взаимодействующих с этими компонентами. В модульных тестах использование поддельных или макетов инфраструктуры приводит к более быстрому выполнению тестов.
Интеграционные тесты ASP.NET Core
Для интеграционных тестов в ASP.NET Core требуется следующее:
- Тестовый проект, который используется для хранения и выполнения тестов. Тестовый проект содержит ссылку на ТС.
- Тестовый проект создает тестовый веб-узел для ТС и использует клиент тестового сервера для обработки запросов и ответов с помощью ТС.
- Средство запуска тестов используется для выполнения тестов и передачи результатов тестов.
Интеграционные тесты придерживаются последовательности событий из обычных шагов теста Подготовка, Выполнение и Проверка.
- Настраивается веб-узел ТС.
- Создается клиент тестового сервера для отправки запросов к приложению.
- Выполняется шаг «Упорядочить» — тестовое приложение подготавливает запрос.
- Выполняется шаг теста Act: клиент отправляет запрос и получает ответ.
- Выполняется шаг теста Assert: фактический ответ проверяется как проход или сбой на основе ожидаемого ответа.
- Процесс продолжается до тех пор, пока не будут выполнены все тесты.
- Выводятся результаты теста.
Как правило, тестовый веб-узел настраивается отлично от обычного веб-узла приложения для тестовых запусков. Например, для тестов может использоваться другая база данных или другие параметры приложения.
Компоненты инфраструктуры, такие как тестовый веб-узел и сервер тестирования в памяти (TestServer), предоставляются или управляются пакетом Microsoft.AspNetCore.Mvc.Testing. Использование этого пакета упрощает создание и выполнение тестов.
Пакет Microsoft.AspNetCore.Mvc.Testing выполняет следующие задачи:
- Копирует файл зависимостей ( .deps ) из SUT в каталог тестового проекта bin .
- Задает корневой каталог содержимого в корне проекта ТС, чтобы при выполнении тестов были найдены статические файлы и страницы или представления.
- Предоставляет класс WebApplicationFactory для упрощения начальной загрузки ТС с TestServer .
В документации модульные тесты описано, как настроить тестовый проект и средство запуска тестов, а также представлены подробные инструкции по выполнению тестов и рекомендаций по именованию тестов и тестовых классов.
Отделяйте модульные тесты от тестов интеграции в разные проекты. Разделение тестов:
- Помогает убедиться, что компоненты тестирования инфраструктуры не случайно включены в модульные тесты.
- Позволяет контролировать выполнение набора тестов.
В сущности, между конфигурацией для тестов приложений Razor Pages и приложений MVC нет никаких отличий. Единственное отличие заключается в том, как именуются тесты. В приложении Razor Pages тесты конечных точек страницы обычно именуются по классу модели страницы (например, IndexPageTests для тестирования интеграции компонентов на странице индекса). В приложении MVC тесты обычно упорядочены по классам контроллеров и именуются по контроллеру, который они проверяют (например, HomeControllerTests для тестирования интеграции компонентов на контроллере Home).
Проверка необходимых требований к приложению
Тестовый проект должен выполнять следующие требования.
- Ссылаться на пакет Microsoft.AspNetCore.Mvc.Testing .
- Указывать веб-пакет SDK в файле проекта ( ).
Выполнение необходимых требований можно посмотреть в примере приложения. Изучите файл tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj . В примере приложения используются платформа тестирования xUnit и библиотека средства синтаксического анализа AngleSharp, поэтому он также ссылается на:
В приложениях, которые используют xunit.runner.visualstudio версии 2.4.2 или более поздней версии, тестовый проект должен ссылаться на пакет Microsoft.NET.Test.Sdk .
В тестах также используется Entity Framework Core. Ссылки на приложение:
- Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore
- Microsoft.AspNetCore.Identity.EntityFrameworkCore
- Microsoft.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.InMemory
- Microsoft.EntityFrameworkCore.Tools
Среда ТС
Если среда ТС не задана, то по умолчанию среда имеет значение Development.
Базовые тесты со стандартной WebApplicationFactory
WebApplicationFactory используется для создания TestServer для интеграционных тестов. TEntryPoint — это класс точки входа ТС, обычно класс Startup .
Тестовые классы реализуют интерфейс средства тестирования ( IClassFixture ), чтобы указать, что класс содержит тесты, и предоставить экземпляры общего объекта для тестов в классе.
Следующий тестовый класс, BasicTests , использует WebApplicationFactory для начальной загрузки ТС и предоставляет HttpClient тестовому методу Get_EndpointsReturnSuccessAndCorrectContentType . Метод проверяет, что код состояния ответа свидетельствует о выполнении (коды состояния в диапазоне от 200 до 299) и что заголовок Content-Type имеет значение text/html; charset=utf-8 для нескольких страниц приложения.
CreateClient() создает экземпляр класса HttpClient , который автоматически следует за перенаправлениями и обрабатывает файлы cookie.
public class BasicTests : IClassFixture> < private readonly WebApplicationFactory_factory; public BasicTests(WebApplicationFactory factory) < _factory = factory; >[Theory] [InlineData("/")] [InlineData("/Index")] [InlineData("/About")] [InlineData("/Privacy")] [InlineData("/Contact")] public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url) < // Arrange var client = _factory.CreateClient(); // Act var response = await client.GetAsync(url); // Assert response.EnsureSuccessStatusCode(); // Status Code 200-299 Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString()); >>По умолчанию некритические файлы cookie не сохраняются в запросах, если включена политика согласия GDPR. Чтобы сохранить ненужные файлы cookie, например те, которые используются поставщиком TempData, пометьте их как основные в тестах. Инструкции по маркировке файла cookie в качестве основы см. в разделе Основные файлы cookie.
Настройка WebApplicationFactory
Конфигурацию веб-узла можно создать независимо от тестовых классов путем наследования от WebApplicationFactory для создания одной или нескольких пользовательских фабрик:
-
Наследование от WebApplicationFactory и переопределение ConfigureWebHost. IWebHostBuilder позволяет настраивать коллекцию служб с помощью ConfigureServices:
public class CustomWebApplicationFactory : WebApplicationFactory where TStartup: class < protected override void ConfigureWebHost(IWebHostBuilder builder) < builder.ConfigureServices(services => < var descriptor = services.SingleOrDefault( d =>d.ServiceType == typeof(DbContextOptions)); services.Remove(descriptor); services.AddDbContext(options => < options.UseInMemoryDatabase("InMemoryDbForTesting"); >); var sp = services.BuildServiceProvider(); using (var scope = sp.CreateScope()) < var scopedServices = scope.ServiceProvider; var db = scopedServices.GetRequiredService(); var logger = scopedServices .GetRequiredService>(); db.Database.EnsureCreated(); try < Utilities.InitializeDbForTests(db); >catch (Exception ex) < logger.LogError(ex, "An error occurred seeding the " + "database with test messages. Error: ", ex.Message); > > >); > > - Сошлитесь на пакет NuGet Microsoft.EntityFrameworkCore.SqlServer в файле проекта.
- Вызовите UseSqlServer со строкой подключения к базе данных.
services.AddDbContext((options, context) => < context.UseSqlServer( Configuration.GetConnectionString("TestingDbConnectionString")); >);public class IndexPageTests : IClassFixture> < private readonly HttpClient _client; private readonly CustomWebApplicationFactory_factory; public IndexPageTests( CustomWebApplicationFactory factory) < _factory = factory; _client = factory.CreateClient(new WebApplicationFactoryClientOptions < AllowAutoRedirect = false >); >[Fact] public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot() < // Arrange var defaultPage = await _client.GetAsync("/"); var content = await HtmlHelpers.GetDocumentAsync(defaultPage); //Act var response = await _client.SendAsync( (IHtmlFormElement)content.QuerySelector("form[id='messages']"), (IHtmlButtonElement)content.QuerySelector("button[id='deleteAllBtn']")); // Assert Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.Equal("/", response.Headers.Location.OriginalString); >Любой запрос POST к ТС должен соответствовать проверке защиты от подделки, которая автоматически вносится в систему защиты данных от подделки приложения. Чтобы упорядочить запрос POST теста, тестовое приложение должно:
- Выполнить запрос к странице.
- Проанализировать файл cookie для защиты от подделки и запросить маркер проверки из ответа.
- Выполните запрос POST с файлом cookie для защиты от подделки и запросом маркера проверки на месте.
Вспомогательные методы расширения SendAsync ( Helpers/HttpClientExtensions.cs ) и вспомогательный метод GetDocumentAsync ( Helpers/HtmlHelpers.cs ) в примере приложения используют средство синтаксического анализа AngleSharp для обработки защиты от подделки с помощью следующих методов:
- GetDocumentAsync : получает HttpResponseMessage и возвращает IHtmlDocument . GetDocumentAsync использует фабрику, которая подготавливает виртуальный ответ на основе исходного HttpResponseMessage . Дополнительные сведения см. в документации по AngleSharp.
- Методы расширения SendAsync для HttpClient составляют HttpRequestMessage и вызывают SendAsync(HttpRequestMessage) для отправки запросов к ТС. Перегрузки для SendAsync принятия HTML-формы ( IHtmlFormElement ) и следующих:
- Кнопка «Отправить» в форме ( IHtmlElement )
- Коллекция значений формы ( IEnumerable> )
- Кнопка «Отправить» ( IHtmlElement ) и значения формы ( IEnumerable> )
AngleSharp — это сторонняя библиотека анализа, используемая для демонстрационных целей в этом разделе и в примере приложения. AngleSharp не поддерживается интеграционным тестированием приложением ASP.NET Core и не требуется для этого. Можно использовать и другие средства синтаксического анализа, такие как Html Agility Pack (HAP). Другой подход заключается в написании кода для непосредственной работы с маркером проверки запроса системы защиты от подделки и файлом cookie защиты от подделки.
Поставщик базы данных EF-Core в памяти можно использовать для ограниченного и базового тестирования, однако поставщик SQLite рекомендуется использовать для тестирования в памяти.
Настройка клиента с помощью WithWebHostBuilder
Если в методе теста требуется дополнительная настройка, WithWebHostBuilder создает новый объект WebApplicationFactory с IWebHostBuilder, который дополнительно настраивается в конфигурации.
Метод теста Post_DeleteMessageHandler_ReturnsRedirectToRoot в примере приложения демонстрирует использование WithWebHostBuilder . Этот тест выполняет удаление записи из базы данных, активируя отправку формы в ТС.
Поскольку другой тест в классе IndexPageTests выполняет операцию, которая удаляет все записи из базы данных и может выполняться до метода Post_DeleteMessageHandler_ReturnsRedirectToRoot , база данных повторно заполняется в этом методе теста, чтобы обеспечить наличие записи для ее удаления ТС. Выбор первой кнопки удаления в форме messages в ТС имитируется в запросе к ТС:
[Fact] public async Task Post_DeleteMessageHandler_ReturnsRedirectToRoot() < // Arrange var client = _factory.WithWebHostBuilder(builder => < builder.ConfigureServices(services =>< var serviceProvider = services.BuildServiceProvider(); using (var scope = serviceProvider.CreateScope()) < var scopedServices = scope.ServiceProvider; var db = scopedServices .GetRequiredService(); var logger = scopedServices .GetRequiredService>(); try < Utilities.ReinitializeDbForTests(db); >catch (Exception ex) < logger.LogError(ex, "An error occurred seeding " + "the database with test messages. Error: ", ex.Message); > > >); >) .CreateClient(new WebApplicationFactoryClientOptions < AllowAutoRedirect = false >); var defaultPage = await client.GetAsync("/"); var content = await HtmlHelpers.GetDocumentAsync(defaultPage); //Act var response = await client.SendAsync( (IHtmlFormElement)content.QuerySelector("form[id='messages']"), (IHtmlButtonElement)content.QuerySelector("form[id='messages']") .QuerySelector("div[class='panel-body']") .QuerySelector("button")); // Assert Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.Equal("/", response.Headers.Location.OriginalString); >Параметры клиента
В следующей таблице показаны WebApplicationFactoryClientOptions по умолчанию, доступные при создании экземпляров HttpClient .
Параметр Описание По умолч. AllowAutoRedirect Возвращает или задает, должны ли экземпляры HttpClient автоматически следовать ответам перенаправления. true BaseAddress Возвращает или задает базовый адрес экземпляров HttpClient . http://localhost HandleCookies Возвращает или задает, должны ли экземпляры HttpClient обрабатывать файлы cookie. true MaxAutomaticRedirections Возвращает или задает максимальное число ответов на перенаправление, которым должны следовать экземпляры HttpClient . 7 Создайте класс WebApplicationFactoryClientOptions и передайте его в метод CreateClient() (значения по умолчанию показаны в примере кода):
// Default client option values are shown var clientOptions = new WebApplicationFactoryClientOptions(); clientOptions.AllowAutoRedirect = true; clientOptions.BaseAddress = new Uri("http://localhost"); clientOptions.HandleCookies = true; clientOptions.MaxAutomaticRedirections = 7; _client = _factory.CreateClient(clientOptions);Вставка служб имитации
Службы можно переопределить в тесте с помощью вызова ConfigureTestServices в построителе узлов. Чтобы внедрить службы имитации, в ТС должен иметься класс Startup с методом Startup.ConfigureServices .
Пример ТС включает службу с заданной областью, которая возвращает цитату. Цитата внедряется в скрытое поле на странице индекса при запросе страницы индекса.
public interface IQuoteService < TaskGenerateQuote(); >// Quote ©1975 BBC: The Doctor (Tom Baker); Dr. Who: Planet of Evil // https://www.bbc.co.uk/programmes/p00pyrx6 public class QuoteService : IQuoteService < public TaskGenerateQuote() < return Task.FromResult( "Come on, Sarah. We've an appointment in London, " + "and we're already 30,000 years late."); > >services.AddScoped();public class IndexModel : PageModel < private readonly ApplicationDbContext _db; private readonly IQuoteService _quoteService; public IndexModel(ApplicationDbContext db, IQuoteService quoteService) < _db = db; _quoteService = quoteService; >[BindProperty] public Message Message < get; set; >public IList Messages < get; private set; >[TempData] public string MessageAnalysisResult < get; set; >public string Quote < get; private set; >public async Task OnGetAsync()
При запуске приложения ТС создается следующая разметка:
Чтобы протестировать службу и внедрение цитат в интеграционный тест, служба имитации будет внедрена в ТС тестом. Служба имитации заменяет QuoteService приложения службой, предоставляемой тестовым приложением, с именем TestQuoteService :
// Quote ©1975 BBC: The Doctor (Tom Baker); Pyramids of Mars // https://www.bbc.co.uk/programmes/p00pys55 public class TestQuoteService : IQuoteService < public TaskGenerateQuote() < return Task.FromResult( "Something's interfering with time, Mr. Scarman, " + "and time is my business."); > >Вызывается ConfigureTestServices и регистрируется служба с заданной областью:
[Fact] public async Task Get_QuoteService_ProvidesQuoteInPage() < // Arrange var client = _factory.WithWebHostBuilder(builder => < builder.ConfigureTestServices(services =>< services.AddScoped(); >); >) .CreateClient(); //Act var defaultPage = await client.GetAsync("/"); var content = await HtmlHelpers.GetDocumentAsync(defaultPage); var quoteElement = content.QuerySelector("#quote"); // Assert Assert.Equal("Something's interfering with time, Mr. Scarman, " + "and time is my business.", quoteElement.Attributes["value"].Value); >Разметка, созданная во время выполнения теста, отражает текст цитаты, предоставленный TestQuoteService , поэтому утверждение передается следующим образом:
Имитация проверки подлинности
Тесты в классе AuthTests проверяют, что безопасная конечная точка:
- перенаправляет пользователя, не прошедшего проверку подлинности, на страницу входа;
- возвращает содержимое для пользователя, прошедшего проверку подлинности.
В ТС на странице /SecurePage используется соглашение AuthorizePage, чтобы применить AuthorizeFilter к странице. Дополнительные сведения см. в разделе Соглашения проверки подлинности RazorPages.
services.AddRazorPages(options => < options.Conventions.AuthorizePage("/SecurePage"); >);В тесте Get_SecurePageRedirectsAnUnauthenticatedUser WebApplicationFactoryClientOptions настроен таким образом, чтобы запретить перенаправление (путем установки значения false для параметра AllowAutoRedirect):
[Fact] public async Task Get_SecurePageRedirectsAnUnauthenticatedUser() < // Arrange var client = _factory.CreateClient( new WebApplicationFactoryClientOptions < AllowAutoRedirect = false >); // Act var response = await client.GetAsync("/SecurePage"); // Assert Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.StartsWith("http://localhost/Identity/Account/Login", response.Headers.Location.OriginalString); >Если запретить клиенту следовать перенаправлению, можно выполнить следующие проверки:
- Код состояния, возвращаемый ТС, можно проверить на соответствие ожидаемому результату HttpStatusCode.Redirect, а не окончательному коду состояния после перенаправления на страницу входа, который будет равен HttpStatusCode.OK.
- Значение заголовка Location в заголовках ответа проверяется, чтобы подтвердить, что он начинается с http://localhost/Identity/Account/Login , а не с последнего ответа страницы входа, где отсутствует заголовок Location .
Тестовое приложение может имитировать макет AuthenticationHandler в ConfigureTestServices для тестирования аспектов проверки подлинности и авторизации. Для минимального сценария возвращается AuthenticateResult.Success:
public class TestAuthHandler : AuthenticationHandler < public TestAuthHandler(IOptionsMonitoroptions, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) < >protected override Task HandleAuthenticateAsync() < var claims = new[] < new Claim(ClaimTypes.Name, "Test user") >; var identity = new ClaimsIdentity(claims, "Test"); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, "Test"); var result = AuthenticateResult.Success(ticket); return Task.FromResult(result); > >Для проверки подлинности пользователя вызывается TestAuthHandler , если для схемы проверки подлинности задано Test , где AddAuthentication зарегистрировано для ConfigureTestServices . Важно, чтобы схема Test соответствовала схеме, которую ожидает приложение. В противном случае проверка подлинности не будет работать.
[Fact] public async Task Get_SecurePageIsReturnedForAnAuthenticatedUser() < // Arrange var client = _factory.WithWebHostBuilder(builder => < builder.ConfigureTestServices(services =>< services.AddAuthentication("Test") .AddScheme( "Test", options => <>); >); >) .CreateClient(new WebApplicationFactoryClientOptions < AllowAutoRedirect = false, >); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test"); //Act var response = await client.GetAsync("/SecurePage"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); >Дополнительные сведения о WebApplicationFactoryClientOptions см. в разделе Параметры клиента.
Указание среды
По умолчанию узел и среда приложений ТС настроены для использования среды Development. Чтобы переопределить среду ТС при использовании IHostBuilder , выполните следующие действия:
- Задайте переменную среды ASPNETCORE_ENVIRONMENT (например, Staging , Production или другое настраиваемое значение, например Testing ).
- Переопределите CreateHostBuilder в тестовом приложении, чтобы считать переменные среды с префиксом ASPNETCORE .
protected override IHostBuilder CreateHostBuilder() => base.CreateHostBuilder() .ConfigureHostConfiguration( config => config.AddEnvironmentVariables("ASPNETCORE"));Если ТС использует веб-узел ( IWebHostBuilder ), переопределите CreateWebHostBuilder .
protected override IWebHostBuilder CreateWebHostBuilder() => base.CreateWebHostBuilder().UseEnvironment("Testing");Определение тестовой инфраструктурой пути к корневому каталогу содержимого приложения
Конструктор WebApplicationFactory выводит путь к корневому каталогу содержимого приложения, выполняя поиск WebApplicationFactoryContentRootAttribute в сборке, содержащей интеграционные тесты с ключом, равным сборке System.Reflection.Assembly.FullName TEntryPoint . Если атрибут с правильным ключом не найден, WebApplicationFactory возвращается к поиску файла решения (SLN) и добавляет имя сборки TEntryPoint в каталог решения. Корневой каталог приложения (корневой путь к содержимому) используется для обнаружения представлений и файлов содержимого.
Отключение теневого копирования
Теневое копирование приводит к тому, что тесты выполняются в каталоге, отличном от выходного каталога. Если тесты зависят от загрузки файлов относительно Assembly.Location и возникают проблемы, может потребоваться отключение теневого копирования.
Чтобы сделать это во время использования xUnit, создайте файл xunit.runner.json в каталоге тестового проекта с правильными настройками конфигурации:
Удаление объектов
После выполнения тестов реализации IClassFixture TestServer и HttpClient удаляются, когда xUnit удаляет WebApplicationFactory . Если объекты, создаваемые разработчиком, требуется удалить, удалите их в реализации IClassFixture . Дополнительные сведения см. в разделе Реализация метода Dispose.
Пример интеграционных тестов
Пример приложения состоит из двух приложений:
App каталог проекта; Description Приложение для сообщений (ТС) src/RazorPagesProject Позволяет пользователю добавлять, удалять сообщения (по одному или все) и анализировать их. Тестирование приложения. tests/RazorPagesProject.Tests Используется для тестирования интеграции ТС. Тесты можно выполнять с помощью встроенных функций тестирования интегрированной среды разработки, таких как Visual Studio. В Visual Studio Code или в командной строке выполните следующую команду в каталоге tests/RazorPagesProject.Tests :
dotnet testОрганизация приложения для сообщений (ТС)
Тестируемая система (ТС) — это система сообщений Razor Pages со следующими характеристиками:
- Страница индекса приложения ( Pages/Index.cshtml и Pages/Index.cshtml.cs ) предоставляет методы модели пользовательского интерфейса и страницы для управления добавлением, удалением и анализом сообщений (средние слова для каждого сообщения).
- Сообщение описывается классом ( Data/Message.cs ) с двумя свойствами Message : Id (ключ) и Text (сообщение). Свойство Text является обязательным и ограничено 200 символами.
- Сообщения хранятся с помощью базы данных Entity Framework в памяти†.
- Приложение содержит уровень доступа к данным (DAL) в классе AppDbContext контекста базы данных ( Data/AppDbContext.cs ).
- Если база данных пуста при запуске приложения, то хранилище сообщений инициализируется тремя сообщениями.
- Приложение включает /SecurePage , доступ к которому может получить только пользователь, прошедший проверку подлинности.
†В разделе документации о EF Тестирование с помощью InMemory объясняется, как использовать базу данных в памяти для тестов с помощью MSTest. В этом разделе используется платформа тестирования xUnit. Концепции тестирования и реализации тестов в разных платформах тестирования похожи, но не идентичны.
Несмотря на то, что приложение не использует шаблон репозитория и не является эффективным примером шаблона «единица работы» (UoW), Razor Pages поддерживает такие шаблоны разработки. Дополнительные сведения см. в разделах Проектирование уровня сохраняемости инфраструктуры и Логика контроллера тестирования (пример реализует шаблон репозитория).
Организация приложения для тестирования
Тестовое приложение — это консольное приложение в папке tests/RazorPagesProject.Tests .
- доступа к защищенной странице пользователя, не прошедшего проверку подлинности;
- доступа к защищенной странице пользователя, прошедшего проверку подлинности с помощью макета AuthenticationHandler ;
- получения профиля пользователя GitHub и проверки имени входа пользователя профиля.
- Utilities.cs содержит метод, используемый InitializeDbForTests для заполнения базы данных тестовых данных.
- HtmlHelpers.cs предоставляет метод для возврата An AngleSharp IHtmlDocument для использования методами тестирования.
- HttpClientExtensions.cs предоставляют перегрузки для SendAsync отправки запросов в SUT.
Используемая платформа тестирования — xUnit. Интеграционные тесты проводятся с помощью Microsoft.AspNetCore.TestHost, который включает TestServer. Поскольку пакет Microsoft.AspNetCore.Mvc.Testing используется для настройки узла тестирования и тестового сервера, для пакетов TestHost и TestServer не требуются прямые ссылки на пакеты в файле проекта тестового приложения или в конфигурации разработчика в тестовом приложении.
Перед выполнением интеграционных тестов обычно требуется небольшой набор данных в базе данных. Например, при удалении теста вызывается удаление записей базы данных, поэтому в базе данных должна иметься по крайней мере одна запись, чтобы запрос на удаление был выполнен успешно.
Пример приложения заполняет базу данных тремя сообщениями в Utilities.cs этом тесте, которые могут использовать при выполнении:
public static void InitializeDbForTests(ApplicationDbContext db) < db.Messages.AddRange(GetSeedingMessages()); db.SaveChanges(); >public static void ReinitializeDbForTests(ApplicationDbContext db) < db.Messages.RemoveRange(db.Messages); InitializeDbForTests(db); >public static List GetSeedingMessages() < return new List() < new Message()< Text = "TEST RECORD: You're standing on my scarf." >, new Message()< Text = "TEST RECORD: Would you like a jelly baby?" >, new Message() < Text = "TEST RECORD: To the rational mind, " + "nothing is inexplicable; only unexplained." >>; >Контекст базы данных ТС регистрируется в методе Startup.ConfigureServices . Обратный вызов builder.ConfigureServices тестового приложения выполняется после выполнения кода Startup.ConfigureServices приложения. Чтобы использовать для тестов другую базу данных, необходимо заменить контекст базы данных приложения в builder.ConfigureServices . Дополнительные сведения см. в разделе Настройка WebApplicationFactory.
В тестируемых системах, которые по-прежнему используют веб-узел, обратный вызов builder.ConfigureServices тестового приложения выполняется до выполнения кода Startup.ConfigureServices тестируемой системы. Обратный вызов builder.ConfigureTestServices тестового приложения выполняется позже.
Дополнительные ресурсы
- Модульные тесты
- Модульные тесты Razor Pages в ASP.NET Core
- ПО промежуточного слоя ядра ASP.NET
- Логика контроллера тестирования в ASP.NET Core
Совместная работа с нами на GitHub
Источник этого содержимого можно найти на GitHub, где также можно создавать и просматривать проблемы и запросы на вытягивание. Дополнительные сведения см. в нашем руководстве для участников.