Note: see the link below for the English version of this article.

https://duongnt.com/mock-ihttpclientfactory

Mock IHttpClientFactory for unit testing

Interface IHttpClientFactory được giới thiệu trong bản .NET Core 2.1. Nhờ nó, chúng ta có thể dễ dàng tạo và cấu hình những đối tượng thuộc lớp HttpClient.

Tuy nhiên, việc tạo mock cho IHttpClientFactory để chạy unit test là không đơn giản. Trong bài hôm nay, chúng ta sẽ tìm hiểu 2 giải pháp cho bài toán này.

  • Dùng FakeItEasy để tạo mock.
  • Tự viết class mock.

Các bạn có thể download toàn bộ code trong bài từ link dưới đây.

https://github.com/duongntbk/MockIHttpClientFactoryDemo

Project mẫu

Trong một bài trước đây, tôi đã giới thiệu một package Python với tên gọi passpwnedcheck để kiểm tra mật khẩu đã bị lộ hay chưa. Hôm nay, chúng ta sẽ viết lại một phần package đó bằng C#.

Class để tương tác với API haveibeenpwned

Ta dùng class PassPwnedCheckClient này để gửi phần đầu của hash tới API haveibeenpwned. Kết quả trả về sẽ được lưu vào trong một dictionary. Hàm khởi tạo của class nhận vào một IHttpClientFactory và dùng factory này để tạo đối tượng thuộc lớp HttpClient.

public PassPwnedCheckClient(IHttpClientFactory httpClientFactory) =>
    _httpClient = httpClientFactory.CreateClient(Constants.HttpClientName);

Sau đó ta dùng HttpClient để tương tác với API haveibeenpwned.

var request = new HttpRequestMessage
{
    Method = HttpMethod.Get,
    RequestUri = fullUri,
};

var response = await _httpClient.SendAsync(request);
var rawHashes = await response.Content.ReadAsStringAsync();

return rawHashes.Split("\r\n").Select(r => r.Split(":")).ToDictionary(r => r[0], r => int.Parse(r[1]));

Khó khăn khi test class

Dĩ nhiên ta không muốn kết nối tới API haveibeenpwned mỗi khi chạy unit test. Thay vào đó, ta sẽ truyền mock của IHttpClientFactory vào hàm khởi tạo của PassPwnedCheckClient. Có người sẽ nghĩ rằng đối tượng mock đó sẽ trả về một mock của HttpClient khi ta gọi hàm CreateClient. Và ta có thể cấu hình mock đó để trả về bất kỳ dữ liệu test nào mình muốn. Thoạt nghe điều này có vẻ đơn giản.

var httpClient = A.Fake<HttpClient>();
A.CallTo(() => httpClient.GetAsync("<expected request uri>")).Returns(<expected result>);
var httpClientFactory = A.Fake<IHttpClientFactory>
A.CallTo(() => httpClientFactory.CreateClient(Constants.HttpClientName)).Returns(httpClient);

Nhưng thật không may là đoạn code trên sẽ không chạy. Nguyên nhân là do hàm GetAsync trong class HttpClient là non-virtual. Mà những framework tạo mock thường dùng thì mock cho hàm virtual.

Cách mock IHttpClientFactory

Class HttpClient gửi request như thế nào?

Chúng ta sẽ tìm hiểu logic của hàm GetAsync trong class HttpClient. Tất cả overload của hàm đó đều gọi tới đoạn code này. Đoạn code đó lại gọi tới hàm cùng tên trong class cha.

response = await base.SendAsync(request, cts.Token).ConfigureAwait(false);
ThrowForNullResponse(response);

Tiếp theo, ta sẽ đọc hàm trong class cha, tức là class HttpMessageInvoker.

return await handler.SendAsync(request, cancellationToken).ConfigureAwait(false);

Ở đây, handler là một đối tượng với kiểu HttpMessageHandler. Đối tượng này được truyền vào thông qua hàm khởi tạo của cả HttpClientHttpMessageInvoker.

public HttpClient(HttpMessageHandler handler, bool disposeHandler) : base(handler, disposeHandler)
{
    _timeout = s_defaultTimeout;
    _maxResponseContentBufferSize = HttpContent.MaxBufferSize;
    _pendingRequestsCts = new CancellationTokenSource();
}

Biết được những điều trên, chúng ta đã có cách để khiến HttpClient trả về dữ liệu bất kỳ. Ta chỉ cần mock kiểu HttpMessageHandler, truyền nó vào hàm khởi tạo của HttpClient, và cấu hình cho nó trả về dữ liệu ta muốn. Hôm nay, chúng ta sẽ tạo đối tượng mock bằng hai cách sau.

  • Dùng framework FakeItEasy.
  • Tự viết một handler.

Tạo mock bằng FakeItEasy

Trong C# .NET, FakeItEasy là framework tạo mock ưa thích của tôi. Việc tạo mock cho kiểu HttpMessageHandler bằng FakeItEasy là rất đơn giản.

var handler = A.Fake<HttpMessageHandler>();

Bước tiếp theo là cấu hình giá trị trả về của hàm SendAsync. May thay đây lại là một hàm abstract nên ta mock được hàm này. Nhưng vì nó là hàm protected internal chứ không phải là public nên code sẽ hơi phức tạp một chút.

var response = new HttpResponseMessage
{
    StatusCode = HttpStatusCode.OK,
    Content = new StringContent($"<test content>")
};
A.CallTo(handler).Where(x => x.Method.Name == "SendAsync")
    .WithReturnType<Task<HttpResponseMessage>>()
    .Returns(Task.FromResult(response));

Với các bước trên, tất cả mọi lần gọi tới một hàm SendAsync với kiểu trả về là Task<HttpResponseMessage> đều sẽ nhận được kết quả là dữ liệu test của ta.

Nhưng như đã nhắc tới trong một bài trước, ta nên cấu hình danh sách tham số càng chính xác càng tốt. Ta nên kiểm tra xem Uri được request có trùng với giá trị ta mong đợi hay không. Điều này là đơn giản đối với FakeItEasy.

A.CallTo(handler).Where(x => x.Method.Name == "SendAsync" &&
    (x.Arguments[0] as HttpRequestMessage).RequestUri ==
        new Uri($"<expected request uri>"))
.WithReturnType<Task<HttpResponseMessage>>()
.Returns(Task.FromResult(response));

Các bạn có thể tham khảo test case hoàn chỉnh tại đây.

Tự mình viết mock handler

Như đã nói ở trên, hàm SendAsync trong HttpMessageHandlerabstract. Điều đó nghĩa là ta có thể kế thừa từ HttpMessageHandler và thay đổi logic của SendAsync. Đây là một class như vậy. Ta điểm qua một số điểm đáng chú ý như sau.

private readonly Uri _requestUri;
private readonly string _result;

public MockMessageHandler(Uri requestUri, string result)
{
    _requestUri = requestUri;
    _result = result;
}

Lưu lại URI ta dùng trong test và giá trị trả về tương ứng.

HttpResponseMessage response;
if (request.RequestUri == _requestUri)
{
    response = new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.OK,
        Content = new StringContent(_result)
    };
}
else
{
    response = new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.NotFound
    };
}

return Task.FromResult(response);

Nếu request gửi tới đúng giá trị _requestUri thì ta trả về _result ở bước trước. Còn nếu không ta trả về lỗi 404 - NotFound.

Bước cuối cùng là dùng handler này trong unit test. Các bạn có thể tham khảo code hoàn chỉnh tại đây.

var httpClient = new HttpClient(handler);
var httpClientFactory = A.Fake<IHttpClientFactory>();
A.CallTo(() => httpClientFactory.CreateClient(Constants.HttpClientName)).Returns(httpClient);

Kết thúc

Mặc dù không đơn giản nhưng chúng ta vẫn tạo được mock cho IHttpClientFactory bằng cách tạo mock HttpMessageHandler. Nếu request gửi bằng HttpClient là đơn giản thì ta có thể dùng FakeItEasy để nhanh chóng tạo mock. Nhưng nếu ta muốn có nhiều quyền kiểm soát hơn thì cách tạo lớp kế thừa từ HttpMessageHandler cũng không phải là ý kiến tồi.

A software developer from Vietnam and is currently living in Japan.

Leave a Reply