Когда мы пишем 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.

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.

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.