Эта статья относится к серии, в которой мы создаём “ходячий скелет” (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
$ dotnet user-secrets set "OpenWeather:ApiKey" "<YOUR-API-KEY>" --project ./Api/WeatherWalkingSkeleton.csproj
$ dotnet restore
$ dotnet run --project Api/WeatherWalkingSkeleton.csproj
…и выполнить запрос в другом терминале, чтобы убедиться в получении результата:
$ curl -k https://localhost:5001/weatherforecast?location=detroitЕсли в ответ вы получите нечто похожее на массив прогнозов погоды, то всё в порядке.
На этой стадии проекта мы добавим тесты для двух из созданных ранее классов: OpenWeatherService
и WeatherForecastController
. Это нужно, чтобы установить шаблон тестов, описывающих код, а также для нашей уверенности в том, что по мере роста приложения его основная функциональность не будет нарушена.
Я прочёл множество мнений относительно тестирования ПО, высказанных гораздо более опытными инженерами, чем я сам. Эти мнения разнятся и охватывают диапазон от строгого следования разработке через тестирование до более прагматичных стратегий. Я же в качестве стратегии выбрал проверку того, как OpenWeatherService
реагирует на разные возможные ответы от сторонних API. Поскольку мы не будем вызывать фактический OpenWeatherMap API, то настроим замещающий класс, в котором сможем симулировать ответы. Написав тесты для этого сервиса, мы настроим его с WeatherForecastController
, чтобы проверить правильность возвращаемых им от API данных. Затем мы используем небольшой набор описывающих классы тестов, которые сможем запускать и в будущем, чтобы нововведения не помешали работе основного функционала.
Этапы будут следующими:
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
для всех тестовых окружений и утилит, необходимых для поддержки тестовых классов:
В завершении псевдо-пример теста можно удалить:
$ rm ./Tests/UnitTest1.csСамым изощрённым классом для тестирования будет OpenWeatherService
, поскольку он создаёт объект HttpClient
для совершения вызовов к стороннему API. Нам нужно протестировать, как он обрабатывает разные виды запросов от этого API, при этом не совершая эти запросы фактически. Нам потребуется симулировать варианты запросов, которые API может вернуть. Кроме того, наш класс сервиса использует объект IOptions
, чтобы извлекать и использовать секретный ключ API. Нам же не нужно, чтобы какие-либо ключи API фигурировали в коде. На самом деле нет разницы в том, есть ли у нас реальный ключ API или нет, поэтому нужно создать сервис для тестирования с альтернативным объектом IOptions
.
Вот стратегия для описания и тестирования OpenWeatherService
:
IOptions<OpenWeather>
для внедрения в сервис.HttpClient
, способного возвращать симулированные запросы.В директории 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
.
Этот компонент будет более весом. Наш сервис инстанцируется с IHttpClientFactory
, и мы вызываем метод CreateClient
из объекта для получения HttpClient
. Эта стратегия является обходным путём, поскольку мы не можем создать макет HttpClient
напрямую.
Для облегчения создания макетов объектов мы добавим популярный пакет 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:
В handler
есть метод SendAsync
, который вызывается для отправки запроса, поэтому мы используем Moq, чтобы установить нужный нам ответ:
handler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode,
Content = content
});
С помощью фиктивного обработчика сообщений мы создадим реальный объект HttpClient
:
И после создадим макет IHttpClientFactory
, возвращающий HttpClient
.
var client = new HttpClient(handler.Object);
var clientFactory = new Mock<IHttpClientFactory>();
clientFactory.Setup(_ => _.CreateClient(It.IsAny<string>()))
.Returns(client);
return clientFactory.Object;
В качестве заключительного элемента инфраструктуры нам понадобится статический класс, способный возвращать заготовленные ответы, похожие на те, что мы могли бы получать от API OpenWeatherMap. Из тестирования API в Postman видно, что успешный ответ возвращает массив объектов, напоминающих класс проекта WeatherForecast
, со статусом HTTP 200.
Мы также можем прогнозировать и некоторые другие сценарии:
Добавьте файл 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
:
Класс, содержащий все инструкции 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 для оценки:
В завершении мы убедимся, что успешный ответ от 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
Комментарии