Тестирование сервиса ASP.NET Core с помощью xUnit


Вступление и предварительные условия

Эта статья относится к серии, в которой мы создаём “ходячий скелет” (walking skeleton) приложения с помощью ASP.NET Core и Angular, а также других технологий, используемых для развёртывания и тестирования. На данный момент наше приложение уже представляет собой веб-API с минимальным функционалом, который организует и возвращает данные о погоде в заданной локации. В текущей статье мы будем использовать xUnit для тестирования обработки сервисом вызовов к сторонним API и последующей проверки реакции контроллера на успешный ответ. В следующей статье мы уже будем использовать эти тесты для организации обработки ошибок, которой сейчас недостаёт нашим классам.

Если вы желаете начать с начала, то эта статья представит вашему вниманию проект и подведёт уже к текущей точке. Если же вам просто интересно рассмотреть тестирование с помощью xUnit, то вы можете:

  • Начать с клонирования проекта вплоть до текущего его состояния и выполнения для него инструкции cd:
  • $ git clone -b 2_adding-async --single-branch [email protected]:jsheridanwells/WeatherWalkingSkeleton.git $ cd WeatherWalkingSkeleton
  • Зарегистрироваться для получения ключа к OpenWeatherMap API.
  • Добавить этот ключ API в хранилище секретов проекта:
  • $ dotnet user-secrets set "OpenWeather:ApiKey" "<YOUR-API-KEY>" --project ./Api/WeatherWalkingSkeleton.csproj
  • Проверить работоспособность этого веб-API:
  • $ dotnet restore $ dotnet run --project Api/WeatherWalkingSkeleton.csproj

    …и выполнить запрос в другом терминале, чтобы убедиться в получении результата:

    $ curl -k https://localhost:5001/weatherforecast?location=detroit

    Если в ответ вы получите нечто похожее на массив прогнозов погоды, то всё в порядке. 

    Цели тестирования

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

    Я прочёл множество мнений относительно тестирования ПО, высказанных гораздо более опытными инженерами, чем я сам. Эти мнения разнятся и охватывают диапазон от строгого следования разработке через тестирование до более прагматичных стратегий. Я же в качестве стратегии выбрал проверку того, как OpenWeatherService реагирует на разные возможные ответы от сторонних API. Поскольку мы не будем вызывать фактический OpenWeatherMap API, то настроим замещающий класс, в котором сможем симулировать ответы. Написав тесты для этого сервиса, мы настроим его с WeatherForecastController, чтобы проверить правильность возвращаемых им от API данных. Затем мы используем небольшой набор описывающих классы тестов, которые сможем запускать и в будущем, чтобы нововведения не помешали работе основного функционала.

    Этапы будут следующими:

  • Настройка тестового проекта с библиотеками xUnit и Moq.
  • Написание тестов для описания текущей функциональности классов.
  • Создание тестового проекта

    Dotnet CLI содержит шаблон для добавления тестового проекта xUnit, а также шаблоны для библиотек nUnit и MSTest. Давайте создадим этот проект:

    $ dotnet new xunit -o Tests -n WeatherWalkingSkeleton.Tests

    Далее добавим тестовый проект в решение WeatherWalkingSkeleton:

    $ dotnet sln WeatherWalkingSkeleton.sln add ./Tests/WeatherWalkingSkeleton.Tests.csproj

    Затем для проверки доступности тестов выполним:

    $ dotnet test

    Если всё работает как надо, вы увидите результат выполнения одного псевдо-теста. 

    Если вы используете Visual Studio, то в ней есть встроенный обозреватель тестов, который предоставит UI для их запуска и отладки. Аналогичную функциональность можно получить и в VS Code при помощи плагина .NET Core Test Explorer. В противном случае выполнения $ dotnet test из командной строки будет для этого урока вполне достаточно.

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

    $ dotnet add Tests/WeatherWalkingSkeleton.Tests.csproj reference Api/WeatherWalkingSkeleton.csproj

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

    $ mkdir ./Tests/Controllers_Tests ./Tests/Services_Tests

    Затем добавим тестовые классы. Вы можете либо добавлять классы через командную строку, либо сформировать класс в IDE:

    $ touch ./Tests/{/Controllers_Tests/WeatherForecastController_Tests.cs,/Services_Tests/OpenWeatherService_Tests.cs}

    Мы также создадим директорию Infrastructure для всех тестовых окружений и утилит, необходимых для поддержки тестовых классов:

    $ mkdir ./Tests/Infrastructure

    В завершении псевдо-пример теста можно удалить:

    $ rm ./Tests/UnitTest1.cs

    Настройка тестовых сред

    Самым изощрённым классом для тестирования будет OpenWeatherService, поскольку он создаёт объект HttpClient для совершения вызовов к стороннему API. Нам нужно протестировать, как он обрабатывает разные виды запросов от этого API, при этом не совершая эти запросы фактически. Нам потребуется симулировать варианты запросов, которые API может вернуть. Кроме того, наш класс сервиса использует объект IOptions, чтобы извлекать и использовать секретный ключ API. Нам же не нужно, чтобы какие-либо ключи API фигурировали в коде. На самом деле нет разницы в том, есть ли у нас реальный ключ API или нет, поэтому нужно создать сервис для тестирования с альтернативным объектом IOptions.

    Вот стратегия для описания и тестирования OpenWeatherService:

  • Создание пустого объекта IOptions<OpenWeather> для внедрения в сервис.
  • Создание макета HttpClient, способного возвращать симулированные запросы.
  • Обработка сценария “happy path”, т.е. определение того, как сервис возвращает успешный ответ от API.
  • 1. Настройка сборщика опций

    В директории Infrastructure добавляем класс OptionsBuilder.cs (сборщик опций). Этот класс будет статическим, и пока что нам нужно лишь возвращать объект Options с одним из объектов конфигурации OpenWeatherMap в качестве его значения:

    using Microsoft.Extensions.Options; using WeatherWalkingSkeleton.Config;namespace WeatherWalkingSkeleton.Tests.Infrastructure { public static class OptionsBuilder { public static IOptions<OpenWeather> OpenWeatherConfig() { return Options.Create<OpenWeather>(new OpenWeather { ApiKey = "00000"}); } } }

    Здесь происходит немногое, но важно то, что мы получаем объект, который сможем передать для сборки тестового OpenWeatherService

    2. Настройка сборщика HttpClient

    Этот компонент будет более весом. Наш сервис инстанцируется с IHttpClientFactory, и мы вызываем метод CreateClient из объекта для получения HttpClient. Эта стратегия является обходным путём, поскольку мы не можем создать макет HttpClient напрямую.

    Для облегчения создания макетов объектов мы добавим популярный пакет Moq:

    $ dotnet add Tests/WeatherWalkingSkeleton.Tests.csproj package Moq

    В Infrastructure создайте класс ClientBuilder.cs также в статическом виде. В нём будет размещаться статический метод OpenWeatherClientFactory. Чтобы сделать этот метод более гибким, мы определим в нём два аргумента: StringContent content будет отвечать за симуляцию ответа от API, а HttpStatusCode statusCode будет кодом ответа HTTP, например 200, 400, 401. Я приведу весь класс ниже и далее объясню каждую его часть:

    using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Moq; using Moq.Protected; namespace WeatherWalkingSkeleton.Tests.Infrastructure { public static class ClientBuilder { public static IHttpClientFactory OpenWeatherClientFactory(StringContent content, HttpStatusCode statusCode = HttpStatusCode.OK) { var handler = new Mock<HttpMessageHandler>(); handler.Protected() .Setup<Task<HttpResponseMessage>>( "SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>() ) .ReturnsAsync(new HttpResponseMessage { StatusCode = statusCode, Content = content }); var client = new HttpClient(handler.Object); var clientFactory = new Mock<IHttpClientFactory>(); clientFactory.Setup(_ => _.CreateClient(It.IsAny<string>())) .Returns(client); return clientFactory.Object; } } }

    HttpClient для выполнения действительного HTTP-запроса использует объект, внутренне именуемый как HttpMessageHandler. Его макет мы создадим при помощи библиотеки Moq:

    var handler = new Mock<HttpMessageHandler>();

    В handler есть метод SendAsync, который вызывается для отправки запроса, поэтому мы используем Moq, чтобы установить нужный нам ответ:

    handler.Protected() .Setup<Task<HttpResponseMessage>>( "SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>() ) .ReturnsAsync(new HttpResponseMessage { StatusCode = statusCode, Content = content });

    С помощью фиктивного обработчика сообщений мы создадим реальный объект HttpClient:

    var client = new HttpClient(handler.Object);

    И после создадим макет IHttpClientFactory, возвращающий HttpClient.

    var client = new HttpClient(handler.Object); var clientFactory = new Mock<IHttpClientFactory>(); clientFactory.Setup(_ => _.CreateClient(It.IsAny<string>())) .Returns(client); return clientFactory.Object;

    3. Генерация контролируемых сторонних ответов API

    В качестве заключительного элемента инфраструктуры нам понадобится статический класс, способный возвращать заготовленные ответы, похожие на те, что мы могли бы получать от API OpenWeatherMap. Из тестирования API в Postman видно, что успешный ответ возвращает массив объектов, напоминающих класс проекта WeatherForecast, со статусом HTTP 200.

    Мы также можем прогнозировать и некоторые другие сценарии:

  • Если ресурс вызывается с пустым или неверным ключом API, то мы получаем статус 401 с сообщением “Invalid API key” (недействительный ключ API).
  • Если ресурс вызывается без верного названия города, то мы получаем статус 404 с сообщением “city not found” (город не найден).
  • Из теста я не могу точно знать, как выглядит ошибка от стороннего сервера, поэтому, опираясь на принятые условные обозначения, предположу, что это статус HTTP 500 с сообщением “Internal error” (внутренняя ошибка).
  • Добавьте файл OpenWeatherResponses.cs в директорию ./Tests/Infrastructure. Затем вставьте следующий код, который создаст заготовленные ответы для возвращения макетом HTTP Factory:

    using System.Collections.Generic; using System.Net.Http; using System.Text.Json; using WeatherWalkingSkeleton.Models; namespace WeatherWalkingSkeleton.Tests.Infrastructure { public static class OpenWeatherResponses { public static StringContent OkResponse => BuildOkResponse(); public static StringContent UnauthorizedResponse => BuildUnauthorizedResponse(); public static StringContent NotFoundResponse => BuildNotFoundResponse(); public static StringContent InternalErrorResponse => BuildInternalErrorResponse(); private static StringContent BuildOkResponse() { var response = new OpenWeatherResponse { Forecasts = new List<Forecast> { new Forecast{ Dt = 1594155600, Temps = new Temps { Temp = (decimal)32.93 } } } }; var json = JsonSerializer.Serialize(response); return new StringContent(json); } private static StringContent BuildUnauthorizedResponse() { var json = JsonSerializer.Serialize(new { Cod = 401, Message = "Invalid Api key." }); return new StringContent(json); } private static StringContent BuildNotFoundResponse() { var json = JsonSerializer.Serialize(new { Cod = 404, Message = "city not found" }); return new StringContent(json); } private static StringContent BuildInternalErrorResponse() { var json = JsonSerializer.Serialize(new {Cod = 500, Message = "Internal Error."}); return new StringContent(json); } } }

    Тестирование сервиса

    Теперь, когда мы можем контролировать ответ, получаемый при имитации вызова API OpenWeatherMap, можно настроить тесты, которые опишут OpenWeatherService.

    До сих пор класс содержит один метод: GetFiveDayForecastAsync. Мы ожидаем, что он вернёт список объектов WeatherForecast. В дальнейшем нам потребуется обновить этот метод для обработки любых ошибок, возвращаемых от API, но на данный момент тест будет просто описывать, что этот метод должен делать. Кроме того, обратите внимание, что класс содержит приватный метод BuildOpenWeatherUrl. И поскольку он является приватным, мы не можем протестировать его в отдельности, но так как мы знаем, что от него зависит GetFiveDayForecastAsync, любые сбои в приватном методе будут отражены при тестировании публичного.

    Вот два из возможных ответов, которые можно ожидать от GetFiveDayForecastAsync:

  • Он вернёт список объектов WeatherForecast.
  • Свойства Date и Temp, поступающие из ответа API после создания списка WeatherForecast, будут такими же.
  • Добавьте тестовый файл в директорию .Tests/Services_Tests:

    $ touch ./Tests/Services_Tests/OpenWeatherService_Tests.cs

    Класс, содержащий все инструкции using, должен начинаться так:

    using System; using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using WeatherWalkingSkeleton.Infrastructure; using WeatherWalkingSkeleton.Models; using WeatherWalkingSkeleton.Tests.Infrastructure; using Xunit; namespace WeatherWalkingSkeleton.Services { public class OpenWeatherService_Tests { } }

    Добавим два метода внутри сервиса — по одному для каждого описания, которое хотим сделать.

    [Fact] public async Task Returns_A_WeatherForecast() { } [Fact] public async Task Returns_Expected_Values_From_the_Api() { }

    Заметьте, что аннотация [Fact] позволяет обозревателю тестов находить и запускать любые методы тестов.

    Теперь мы добавим код в первый метод. Поскольку в каждом тесте нам понадобится создать OpenWeatherService, сначала с помощью созданных ранее тестовых сред мы сгенерируем объекты IOptions<OpenWeather> и IHttpClientFactory, а затем создадим OpenWeatherService под именем sut для “system under test” (система тестируется). Инстанцировав сервис, мы вызовем GetFiveDayForecastAsync. Для тестирования возвращения методом ожидаемого типа объекта в последней строке используется класс Assert из xUnit:

    [Fact] public async Task Returns_A_WeatherForecast() { var opts = OptionsBuilder.OpenWeatherConfig(); var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.OkResponse); var sut = new OpenWeatherService(opts, clientFactory); var result = await sut.GetFiveDayForecastAsync("Chicago"); Assert.IsType<List<WeatherForecast>>(result); }

    Второй тест настроен точно так же, но в нём мы видим, совпадают ли текущие значения Date и Temp с использованными нами ранее при загрузке OpenWeatherResponses.BuildOkResponse():

    [Fact] public async Task Returns_Expected_Values_From_the_Api() { var opts = OptionsBuilder.OpenWeatherConfig(); var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.OkResponse); var sut = new OpenWeatherService(opts, clientFactory); var result = await sut.GetFiveDayForecastAsync("Chicago"); Assert.Equal(new DateTime(1594155600), result[0].Date); Assert.Equal((decimal)32.93, result[0].Temp); }

    Теперь запустите тесты в обозревателе тестов вашей IDE или в командной строке терминала ($ dotnet test), чтобы убедиться, что они проходят. 

    Тестирование контроллера

    Далее мы добавим файл для выполнения тестов контроллера:

    $ touch ./Tests/Services_Tests/WeatherForecastController_Tests.cs

    Так же как и у сервиса, у нашего WeatherForecastController, использующего OpenWeatherService, есть всего один метод Get для возвращения результата сервиса. В отличие от предыдущих строго изолированных модульных тестов, проводимых с использованием макетов API и HTTP клиента, тесты контроллера я хочу сделать больше похожими на интеграционные. Я бы хотел узнать, как он отвечает на разные результаты, получаемые от OpenWeatherService, от которого он зависит. Если мы внесём в OpenWeatherService изменения, способные нарушить работу WeatherForecastController, то в случае имитирования сервиса не узнаем об этом. Поэтому в этих тестах мы сначала создадим OpenWeatherService с ожидаемым ответом API, а затем соберём с ним контроллер.

    Наш WeatherForecastController требует наличия ILogger<WeatherForecastController> в конструкторе. К счастью, библиотека Microsoft.Extensions.Logging, из которой берётся этот интерфейс, также содержит класс под названием NullLogger<T>, который позволяет нам просто передать пустой логгер, поскольку логирование с тестируемой функциональностью никак не связано. Настройка для создания контроллера в качестве нашей тестируемой системы выглядит так (далее я скопирую весь метод):

    var opts = OptionsBuilder.OpenWeatherConfig(); var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.OkResponse); var service = new OpenWeatherService(opts, clientFactory); var sut = new WeatherForecastController(new NullLogger<WeatherForecastController>(), service);

    После создания контроллера мы будем ожидать (await) результат и преобразуем его в OkObjectResult, который будет содержать ответ API для оценки:

    var result = await sut.Get("Chicago") as OkObjectResult;

    В завершении мы убедимся, что успешный ответ от OpenWeatherService приводит к “успешному” ответу от контроллера и получению ожидаемого объекта List<WeatherForecast>:

    Assert.IsType<List<WeatherForecast>>(result.Value); Assert.Equal(200, result.StatusCode);

    В целом класс WeatherForecastController_Tests должен выглядеть так:

    using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; using WeatherWalkingSkeleton.Controllers; using WeatherWalkingSkeleton.Models; using WeatherWalkingSkeleton.Services; using WeatherWalkingSkeleton.Tests.Infrastructure; using Xunit; namespace WeatherWalkingSkeleton.Tests.Controllers_Tests { public class WeatherForecastController_Tests { [Fact] public async Task Returns_OkResult_With_WeatherForecast() { var opts = OptionsBuilder.OpenWeatherConfig(); var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.OkResponse); var service = new OpenWeatherService(opts, clientFactory); var sut = new WeatherForecastController(new NullLogger<WeatherForecastController>(), service); var result = await sut.Get("Chicago") as OkObjectResult; Assert.IsType<List<WeatherForecast>>(result.Value); Assert.Equal(200, result.StatusCode); } } }

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

    Заключение

    На данный момент этого достаточно. В этом уроке мы установили библиотеку тестирования для проекта ASP.NET Core WebApi. Кроме того, мы создали начальную инфраструктуру для контроля зависимостей, которые мы не тестируем, а также для создания макета стороннего API, чтобы иметь возможность контролировать его возможные ответы. Мы использовали его для оценки успешных ответов от нашего сервиса, а затем для оценки таких же ответов в контроллере, использующем сервис.

    В следующем уроке мы начнём процесс TDD (разработки через тестирование) с целью добавить обработку исключений в контроллер и методы сервиса. 


    Перевод статьи Jeremy Wells: Testing an ASP.NET Core Service With xUnit


    Поделиться статьей:


    Вернуться к статьям

    Комментарии

      Ничего не найдено.