Reputation: 8009
I have problems performing unit testing when using Polly
and HttpClient
.
Specifically, Polly and HttpClient are used for ASP.NET Core Web API Controller following the links below:
https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-requests
https://github.com/App-vNext/Polly/wiki/Polly-and-HttpClientFactory
The problems (1 and 2) are specified at the bottom. I wonder if this is the correct way to using Polly
and HttpClient
.
ConfigureServices
Configure Polly policy and sepecify custom HttpClient, CustomHttpClient
public void ConfigureServices(IServiceCollection services)
{
services.AddHttpClient();
services.AddHttpClient<HttpClientService>()
.AddPolicyHandler((service, request) =>
HttpPolicyExtensions.HandleTransientHttpError()
.WaitAndRetryAsync(3,
retryCount => TimeSpan.FromSeconds(Math.Pow(2, retryCount)))
);
}
CarController
CarController depends on HttpClientService
, which is injected by the framework automatically without explicit registration.
Please note that HttpClientService
causes issues with unit test as Moq
cannot mock a non virtual method, which is mentioned later on.
[ApiVersion("1")]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
public class CarController : ControllerBase
{
private readonly ILog _logger;
private readonly HttpClientService _httpClientService;
private readonly IOptions<Config> _config;
public CarController(ILog logger, HttpClientService httpClientService, IOptions<Config> config)
{
_logger = logger;
_httpClientService = httpClientService;
_config = config;
}
[HttpPost]
public async Task<ActionResult> Post()
{
using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8))
{
string body = reader.ReadToEnd();
var statusCode = await _httpClientService.PostAsync(
"url",
new Dictionary<string, string>
{
{"headerID", "Id"}
},
body);
return StatusCode((int)statusCode);
}
}
}
HttpClientService
Simiar to CarController
, HttpClientService
has an issue with Unit test, because HttpClient
's PostAsync cannot be mocked. HttpClient
is injected by the framework automatically without explicit registration.
public class HttpClientService
{
private readonly HttpClient _client;
public HttpClientService(HttpClient client)
{
_client = client;
}
public async Task<HttpStatusCode> PostAsync(string url, Dictionary<string, string> headers, string body)
{
using (var content = new StringContent(body, Encoding.UTF8, "application/json"))
{
foreach (var keyValue in headers)
{
content.Headers.Add(keyValue.Key, keyValue.Value);
}
var response = await _client.PostAsync(url, content);
response.EnsureSuccessStatusCode();
return response.StatusCode;
}
}
Problem 1
Unit Test: Moq
cannot mock HttpClientService
's PostAsync
method. I CAN change it to virtual, but I wonder if it is the best option.
public class CarControllerTests
{
private readonly Mock<ILog> _logMock;
private readonly Mock<HttpClient> _httpClientMock;
private readonly Mock<HttpClientService> _httpClientServiceMock;
private readonly Mock<IOptions<Config>> _optionMock;
private readonly CarController _sut;
public CarControllerTests() //runs for each test method
{
_logMock = new Mock<ILog>();
_httpClientMock = new Mock<HttpClient>();
_httpClientServiceMock = new Mock<HttpClientService>(_httpClientMock.Object);
_optionMock = new Mock<IOptions<Config>>();
_sut = new CarController(_logMock.Object, _httpClientServiceMock.Object, _optionMock.Object);
}
[Fact]
public void Post_Returns200()
{
//System.NotSupportedException : Invalid setup on a non-virtual (overridable in VB) member
_httpClientServiceMock.Setup(hc => hc.PostAsync(It.IsAny<string>(),
It.IsAny<Dictionary<string, string>>(),
It.IsAny<string>()))
.Returns(Task.FromResult(HttpStatusCode.OK));
}
}
}
Problem 2
Unit test: similar to HttpClientService
, Moq
cannot mock HttpClient
's PostAsync
method.
public class HttpClientServiceTests
{
[Fact]
public void Post_Returns200()
{
var httpClientMock = new Mock<HttpClient>();
//System.NotSupportedException : Invalid setup on a non-virtual (overridable in VB) member
httpClientMock.Setup(hc => hc.PostAsync("", It.IsAny<HttpContent>()))
.Returns(Task.FromResult(new HttpResponseMessage()));
}
}
ASP.NET Core API 2.2
Update
Corrected a typo of CustomHttpClient to HttpClientService
Update 2
Solution to problem 2
Upvotes: 1
Views: 1403
Reputation: 247423
Assuming CustomHttpClient
is a typo an the HttpClientService
is the actual dependency, the controller is tightly coupled to implementation concerns which, as you have already experiences, are difficult to test in isolation (unit test).
Encapsulate those concretions behind abstractions
public interface IHttpClientService {
Task<HttpStatusCode> PostAsync(string url, Dictionary<string, string> headers, string body);
}
public class HttpClientService : IHttpClientService {
//...omitted for brevity
}
that can be replaced when testing.
Refactor the controller to depend on the abstraction and not the concrete implementation
public class CarController : ControllerBase {
private readonly ILog _logger;
private readonly IHttpClientService httpClientService; //<-- TAKE NOTE
private readonly IOptions<Config> _config;
public CarController(ILog logger, IHttpClientService httpClientService, IOptions<Config> config) {
_logger = logger;
this.httpClientService = httpClientService;
_config = config;
}
//...omitted for brevity
}
Update the service configuration to use the overload that allows the registration of the abstraction along with its implementation
public void ConfigureServices(IServiceCollection services) {
services.AddHttpClient();
services
.AddHttpClient<IHttpClientService, HttpClientService>() //<-- TAKE NOTE
.AddPolicyHandler((service, request) =>
HttpPolicyExtensions.HandleTransientHttpError()
.WaitAndRetryAsync(3,
retryCount => TimeSpan.FromSeconds(Math.Pow(2, retryCount)))
);
}
That takes care of refatcoring the code to make it more test friendly.
The following shows how the controller can now be unit tested in isolation
public class CarControllerTests {
private readonly Mock<ILog> _logMock;
private readonly Mock<IHttpClientService> _httpClientServiceMock;
private readonly Mock<IOptions<Config>> _optionMock;
private readonly CarController _sut;
public CarControllerTests() //runs for each test method
{
_logMock = new Mock<ILog>();
_httpClientServiceMock = new Mock<IHttpClientService>();
_optionMock = new Mock<IOptions<Config>>();
_sut = new CarController(_logMock.Object, _httpClientServiceMock.Object, _optionMock.Object);
}
[Fact]
public async Task Post_Returns200() {
//Arrange
_httpClientServiceMock
.Setup(_ => _.PostAsync(
It.IsAny<string>(),
It.IsAny<Dictionary<string, string>>(),
It.IsAny<string>())
)
.ReturnsAsync(HttpStatusCode.OK);
//Act
//...omitted for brevity
//...
}
}
Note how there was no longer a need for the HttpClient
for the test to be exercised to completion.
There are other dependencies that the controller will need to make sure are arranged for the test to flow but that is currently ouside of the scope of the question.
Upvotes: 2