Note: see the link below for the English version of this article.
https://duongnt.com/mock-ihttpclientfactory
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ả HttpClient
và HttpMessageInvoker
.
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 HttpMessageHandler
là abstract
. Đ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.