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

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

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

Чтобы узнать больше о написании тестируемого кода, обязательно прочитайте мой пост Узнайте, как писать тестируемый код на C# всего за 15 минут.

Абстракция над объявлением HttpClient

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

Если вы будете работать с .NET Core 2.1 или более новой версией .NET Core, создавать пользовательскую абстракцию не требуется. IHttpClientFactory идеально подходит:

Создание собственной фабрики HttpClient

Если вы не используете .NET Core или используете более старую версию, чем 2.1, вам необходимо создать собственную абстракцию. Давайте сохраним это в соответствии с IHttpClientFactory:

Для реализации мы хотим создать статический экземпляр HttpClient (если вы хотите знать, почему он должен быть статическим, обязательно прочитайте Вы неправильно используете HttpClient). Затем этот экземпляр будет возвращен методом CreateClient.

internal class CustomHttpClientFactory : ICustomHttpClientFactory
{
    private static readonly HttpClient _httpClient = new HttpClient();
    public HttpClient CreateClient()
    {
        return _httpClient;
    }
}

Мы можем использовать фиктивный фреймворк по выбору, чтобы создать макет для I(Custom)HttpClientFactory. Затем мы можем предоставить HttpClient из модульного теста для настройки метода CreateClient макета. Поступая таким образом, мы получаем контроль над возвращаемым HttpClient.

Получение контроля над ответом от HttpClient

Пока все хорошо, но как нам получить контроль над HttpResponseMessage, возвращаемым различными методами REST HttpClient? Чтобы выяснить это, нам нужно знать о внутренностях HttpClient.

Как вы можете видеть на приведенной выше диаграмме классов, HttpClient содержит два конструктора: один конструктор без параметров и один конструктор, принимающий входной параметр типа HttpMessageHandler.

Если вызывается конструктор без параметров, внутри создается экземпляр HttpClientHandler. Затем он передается другому конструктору, что возможно, поскольку HttpClientHandler расширяет HttpMessageHandler. В конце концов обработчик передается HttpMessageInvoker, и именно здесь обработчик выполняется и возвращается HttpResponseMessage.

HttpClientHandler выполняет фактические веб-запросы. Поскольку мы не хотим, чтобы веб-запросы выполнялись для наших модульных тестов, мы знаем, что конструктор без параметров не подходит для получения контроля над возвращаемым HttpResponseMessage.

Но что, если мы создадим пользовательскую реализацию HttpMessageHandler и предоставим ее HttpClient? Класс HttpMessageHandler выглядит следующим образом:

Это то, с чем мы можем работать! Нам просто нужно переопределить метод SendAsync и заставить его возвращать HttpResponseMessage. Ответное сообщение может быть предоставлено через конструктор, а затем возвращено SendAsync.

Реализация MockHttpMessageHandler

Теперь, когда у нас есть четкое представление о том, чего мы хотим достичь и как мы хотим это сделать, давайте начнем писать код. Начнем с MockHttpMessageHandler, который мы только что смоделировали:

public class MockHttpMessageHandler : HttpMessageHandler
{
    private readonly HttpResponseMessage _httpResponseMessage;     
    public MockHttpMessageHandler(HttpResponseMessage httpResponseMessage)
    {
        _httpResponseMessage = httpResponseMessage;
    }
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        return Task.FromResult(_httpResponseMessage);
    }
}

Создание модульного теста

Теперь все готово для тестирования класса, использующего HttpClient. Допустим, мы хотим протестировать следующий класс:

public class PseudoCodeClient
{
    private readonly ICustomHttpClientFactory _httpClientFactory;     
    public PseudoCodeClient(ICustomHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }
    
    public async Task<bool> ReturnsOkResponseAsync()
    {
        var client = _httpClientFactory.CreateClient();
        var response = await client.GetAsync("http://www.api.com");        
        return response.IsSuccessStatusCode;
    }
}

Вы можете видеть, что он выполняет запрос GET к API, используя клиент, созданный фабрикой. Наконец, он возвращает, имеет ли ответ код состояния успеха. Довольно просто, но, тем не менее, подходит для проверки того, можем ли мы получить контроль над ответом от HttpClient. Давайте начнем с создания модульного теста, который проверяет, возвращается ли true, если ответ имеет код состояния успеха:

[Fact]
public async Task ReturnsOkResponse_ClientReturnsOkResponse_ReturnsTrue()
{
    // Arrange
    var factory = new Mock<ICustomHttpClientFactory>();
    var messageHandler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK));
    var httpClient = new HttpClient(messageHandler);
    factory
        .Setup(f => f.CreateClient())
        .Returns(httpClient);
    var client = new PseudoCodeClient(factory.Object);
    // Act
    var result = await client.ReturnsOkResponseAsync();
    // Assert
    Assert.True(result);
}

Если мы разберем часть теста, посвященную аранжировке, то увидим, что начинаем с создания макета для ICustomHttpClientFactory. Я использую Moq для создания макетов, но есть различные альтернативы, которые вы также можете использовать. Затем мы создаем экземпляр нашего MockHttpMessageHandler и передаем его конструктору HttpClient. Далее следует настройка метода CreateClient, чтобы он возвращал HttpClient. Наконец, мы создаем экземпляр PseudoCodeClient, внедряя макет в его конструктор.

Сценарий, в котором мы проверяем, возвращается ли false, если HttpClient возвращает неуспешный код состояния, очень похож:

[Fact]
public async Task ReturnsOkResponse_ClientReturnsBadRequestResponse_ReturnsFalsee()
{
    // Arrange
    var factory = new Mock<ICustomHttpClientFactory>();
    var messageHandler = new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.BadRequest));
    var httpClient = new HttpClient(messageHandler);
    factory
        .Setup(f => f.CreateClient())
        .Returns(httpClient);
    var client = new PseudoCodeClient(factory.Object);
   
    // Act
    var result = await client.ReturnsOkResponseAsync();
    // Assert
    Assert.False(result);
}

Улучшение удобства для пользователя

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

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

Не лучше ли создать фабрику, которая поможет нам с созданием HttpClients?

В PseudoCodeClient нас интересует только код состояния ответа. Что, если мы добавим в фабрику метод, который принимает код состояния в качестве входных данных, и позволим фабрике создать внутри себя HttpClient?

Теперь мы предоставляем пользователю возможность предоставлять HttpResponseMessage, а также просто предоставлять HttpStatusCode. Код выглядит следующим образом:

public static class MockHttpClientFactory
{
    public static HttpClient Build(HttpResponseMessage responseMessage)
    {
        return new HttpClient(new MockHttpMessageHandler(responseMessage));
    }
    
    public static HttpClient Build(HttpStatusCode statusCode)
    {
        return Build(new HttpResponseMessage(statusCode));
    }
}

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

[Fact]
public async Task ReturnsOkResponse_ClientReturnsOkResponse_ReturnsTrue()
{
    // Arrange
    var httpClient = MockHttpClientFactory.Create(HttpStatusCode.OK);
    factory
        .Setup(f => f.CreateClient())
        .Returns(httpClient);
    var client = new PseudoCodeClient(factory.Object);
    
    // Act
    var result = await client.ReturnsOkResponseAsync();
    // Assert
    Assert.True(result);
}

Это больше походит на это! Компактный, легкий и, следовательно, удобный для пользователя. Давайте добавим еще пару перегрузок, чтобы мы также могли легко предоставить строку ответа и объект ответа:

public static HttpClient Build(HttpStatusCode statusCode, string responseString)
{
    var responseMessage = new HttpResponseMessage
    {
        Content = new StringContent(responseString),
        StatusCode = statusCode
    };
    return Build(responseMessage);
}
public static HttpClient Build<T>(HttpStatusCode statusCode, T responseObject)
{
    try
    {
        var responseString = JsonConvert.SerializeObject(responseObject);
        return Build(statusCode, responseString);
    }
    catch (JsonSerializationException)
    {
        throw new ArgumentException("The provided response object could not be serialized.");
    }
}

Подведение итогов

И вот оно: удобная библиотека для тестирования классов, использующих HttpClient.

Чтобы действительно сделать его пригодным для использования, нам нужно создать для него пакет NuGet. Таким образом, мы можем легко включить его в различные проекты модульного тестирования. В следующем посте я покажу вам, как создать пакет NuGet и как опубликовать его на nuget.org.