← Назад ко всем статьям

Mocking HttpClient в C#

Опубликовано

Когда мы пишем unit tests для services, которые делают external API calls через HttpClient, в нашем упрощенном примере ArticleService нужно заменить реальные API calls mocks.

public class ArticleService(IHttpClientFactory httpClientFactory)
{
    private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;

    public async Task<Article?> GetLastArticle()
    {
        var client = _httpClientFactory.CreateClient("ArticleApi");

        using var response = await client.GetAsync("lastarticle");

        if (!response.IsSuccessStatusCode)
        {
            // Optionally log or handle the error here
            return null;
        }

        var responseJson = await response.Content.ReadAsStringAsync();
        var article = JsonSerializer.Deserialize<Article>(responseJson);

        return article;
    }
}

Просто mock interface IHttpClientFactory, например через NSubstitute, недостаточно. Нужно создать class MockHttpMessageHandler, который наследуется от HttpMessageHandler.

image

internal class MockHttpMessageHandler(HttpResponseMessage responseMessage) : HttpMessageHandler
{
    private readonly HttpResponseMessage _responseMessage = responseMessage;

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        return Task.FromResult(_responseMessage);
    }
}

MockHttpMessageHandler принимает HttpResponseMessage в constructor, то есть message, который мы ожидаем получить от mock, и просто возвращает его в overridden method SendAsync.

image

Source: HTTP Message Handlers in ASP.NET Web API

Testing successful response

public class ApiServiceTests
{
    [Fact]
    public async Task GetLastArticle_ReturnsArticle()
    {
        // Arrange
        var expectedArticle = new Article { Id = 1, Title = "Test Title", Body = "Test Body" };
        var jsonResponse = JsonSerializer.Serialize(expectedArticle);
        var httpResponse = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(jsonResponse)
        };
        var mockHttpMessageHandler = new MockHttpMessageHandler(httpResponse);
        var httpClient = new HttpClient(mockHttpMessageHandler)
        {
            BaseAddress = new Uri("https://example.com/")
        };

        var mockHttpClientFactory = Substitute.For<IHttpClientFactory>();
        mockHttpClientFactory.CreateClient("ArticleApi").Returns(httpClient);

        var articleService = new ArticleService(mockHttpClientFactory);

        // Act
        var result = await articleService.GetLastArticle();

        // Assert
        Assert.NotNull(result);
        Assert.Equal(expectedArticle.Id, result.Id);
        Assert.Equal(expectedArticle.Title, result.Title);
    }
}

При создании HttpClient мы передаем instance class MockHttpMessageHandler в constructor и делаем этот HttpClient return value method CreateClient у instance mockHttpClientFactory.

Делаем mock более гибким

Также можно сделать mock более flexible через delegate Func, который будет вызываться в method SendAsync.

public class MockHttpMessageHandler(
    Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> sendAsyncFunc)
    : HttpMessageHandler
{
    private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>>
        _sendAsyncFunc = sendAsyncFunc;

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        return await _sendAsyncFunc(request, cancellationToken);
    }
}

В этом случае можно тестировать exceptions, которые возникают при HTTP requests.

[Fact]
public async Task GetLastArticle_ThrowsHttpRequestException()
{
    // Arrange
    var mockHttpMessageHandler = new MockHttpMessageHandler(
        (r, ct) => throw new HttpRequestException());
    var httpClient = new HttpClient(mockHttpMessageHandler)
    {
        BaseAddress = new Uri("https://example.com/")
    };

    var mockHttpClientFactory = Substitute.For<IHttpClientFactory>();
    mockHttpClientFactory.CreateClient("ArticleApi").Returns(httpClient);

    var articleService = new ArticleService(mockHttpClientFactory);

    // Act & Assert
    await Assert.ThrowsAsync<HttpRequestException>(() => articleService.GetLastArticle());
}

В итоге у нас есть довольно простой способ mock-ать HttpClient без external dependencies, при этом гибко настраивая expected HTTP responses.