Pingpong
Pingpong

Reputation: 8009

Unit Testing Core API Controller Using Custom HttpClient and Polly policy within ConfigureServices

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

Answers (1)

Nkosi
Nkosi

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

Related Questions