如何对静态只读的 HttpClient 进行单元测试?

huangapple go评论70阅读模式
英文:

How to unit test static readonly HttpClient?

问题

I'd like to unit test the following class.

How can I mock the HttpClient when it's used as a static readonly field in a static class?

This question doesn't help as the OP is using an instance field for the HttpClient.

Here's my class:

using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

namespace Integration.IdentityProvider
{
    public static class IdentityProviderApiCaller
    {
        private static readonly HttpClient HttpClient;

        static IdentityProviderApiCaller()
        {
            HttpClient = HttpClientFactory.Create();
            HttpClient.BaseAddress = new Uri("https://someApi.com");
            HttpClient.DefaultRequestHeaders.Add("Accept", "application/json");
        }

        public static async Task<IList<AddGroupResult>> AddGroups(AddGroup[] addGroupsModel)
        {
            var content = GetContent(addGroupsModel);
            var urlPath = "/groups";

            var result = await HttpClient.PutAsync(urlPath, content).ConfigureAwait(false);
            return await GetObject<IList<AddGroupResult>>(result).ConfigureAwait(false);
        }

        private static async Task<T> GetObject<T>(HttpResponseMessage result) where T : class
        {
            if (result.IsSuccessStatusCode)
            {
                return await DeserializeObject<T>(result.Content).ConfigureAwait(false);
            }
            var errorResult = await DeserializeObject<ErrorResult>(result.Content).ConfigureAwait(false);
            throw new Exception(errorResult.ExceptionMessage);
        }

        private static async Task<T> DeserializeObject<T>(HttpContent content) where T : class
        {
            var jsonContent = await content.ReadAsStringAsync().ConfigureAwait(false);
            T obj;
            try
            {
                obj = JsonConvert.DeserializeObject<T>(jsonContent);
            }
            catch (JsonSerializationException)
            {
                return await Task.FromResult<T>(null).ConfigureAwait(false);
            }
            return obj;
        }

        private static StringContent GetContent<T>(T obj)
        {
            var payload = JsonConvert.SerializeObject(obj);
            var content = new StringContent(payload, Encoding.UTF8, "application/json");
            return content;
        }
    }

    public class AddGroup
    {
        public string Name { get; set; }

        public string Description { get; set; }
    }

    public class AddGroupResult
    {
        public bool IsSuccessful { get; set; }
    }

    public class ErrorResult
    {
        public string ExceptionMessage { get; set; }
    }
}
英文:

I'd like to unit test the following class.

How can I mock the HttpClient when it's used as a static readonly field in a static class?

This question doesn't help as the OP is using an instance field for the HttpClient.

Here's my class:

using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace Integration.IdentityProvider
{
public static class IdentityProviderApiCaller
{
private static readonly HttpClient HttpClient;
static IdentityProviderApiCaller()
{
HttpClient = HttpClientFactory.Create();
HttpClient.BaseAddress = new Uri(&quot;https://someApi.com&quot;);
HttpClient.DefaultRequestHeaders.Add(&quot;Accept&quot;, &quot;application/json&quot;);
}
public static async Task&lt;IList&lt;AddGroupResult&gt;&gt; AddGroups(AddGroup[] addGroupsModel)
{
var content = GetContent(addGroupsModel);
var urlPath = &quot;/groups&quot;;
var result = await HttpClient.PutAsync(urlPath, content).ConfigureAwait(false);
return await GetObject&lt;IList&lt;AddGroupResult&gt;&gt;(result).ConfigureAwait(false);
}
private static async Task&lt;T&gt; GetObject&lt;T&gt;(HttpResponseMessage result) where T : class
{
if (result.IsSuccessStatusCode)
{
return await DeserializeObject&lt;T&gt;(result.Content).ConfigureAwait(false);
}
var errorResult = await DeserializeObject&lt;ErrorResult&gt;(result.Content).ConfigureAwait(false);
throw new Exception(errorResult.ExceptionMessage);
}
private static async Task&lt;T&gt; DeserializeObject&lt;T&gt;(HttpContent content) where T : class
{
var jsonContent = await content.ReadAsStringAsync().ConfigureAwait(false);
T obj;
try
{
obj = JsonConvert.DeserializeObject&lt;T&gt;(jsonContent);
}
catch (JsonSerializationException)
{
return await Task.FromResult&lt;T&gt;(null).ConfigureAwait(false);
}
return obj;
}
private static StringContent GetContent&lt;T&gt;(T obj)
{
var payload = JsonConvert.SerializeObject(obj);
var content = new StringContent(payload, Encoding.UTF8, &quot;application/json&quot;);
return content;
}
}
public class AddGroup
{
public string Name { get; set; }
public string Description { get; set; }
}
public class AddGroupResult
{
public bool IsSuccessful { get; set; }
}
public class ErrorResult
{
public string ExceptionMessage { get; set; }
}
}

答案1

得分: 2

以下是翻译好的内容:

  1. 从您的代码中删除所有static关键字。我理解您想要一个执行工作的东西,但这可以通过仅拥有类的一个实例来实现。这还意味着更容易测试您的类 - 为测试创建一个新实例。您可以在 aspnet core 的配置/设置阶段限制创建多少个实例(如果您正在使用它)。

  2. 将构造函数从静态更改为实例,并将客户端作为其依赖项传递。新的构造函数将如下所示:

private readonly HttpClient _client;

public IdentityProviderApiCaller(HttpClient client)
{
    if (client == null) throw new ArgumentNullException(nameof(client));
    _client = client
}

关键点在于,在构造函数中提供了IdentityProviderApiCaller的依赖项,以便进行单元测试。在单元测试中,您可以为 HTTP 客户端提供一个mock,以便为getpost设置期望,并查看方法是否被正确调用。在集成测试中,您可以传递一个真实的 HTTP 客户端实例,以便实际访问后端服务。

  1. 澄清函数的参数,考虑传递一个简单的列表/数组。如果将AddGroup类重命名为Group,则代码将更容易阅读。想象一下,您还可以拥有Delete(Group group)或列出组的 API。将组命名为AddGroup将会令人困惑。此外,您可以简化async。因此,所有这些代码应该如下所示:
public async Task<HttpResponseMessage> AddGroups(List<Group> groups)
{
    if (groups == null) throw new ArgumentNullException(nameof(groups));
    var content = GetContent(addGroupsModel);
    var urlPath = "/groups";
    return await _client.PutAsync(urlPath, content);
}
  1. 抛出一个更专注的异常,类Exception非常广泛。考虑常见的异常,如ArgumentExceptionInvalidOperationException等。您也可以创建自己的异常,但最好检查一下可用的内置异常是什么。
throw new Exception(errorResult.ExceptionMessage); // 这一行可以变成一个更专注的异常

可能有一个专门用于 aspnet core 的类,可以返回一个失败代码,它可能看起来像:

return BadRequest(errorResult.ExceptionMessage);

思路是您可以指定返回给 API 客户端的错误代码,例如 401、400。如果抛出异常,我认为状态码将是 500 内部服务器错误,这对于 API 的客户端可能不是理想的。


现在回到单元测试,在元代码中,它将如下所示:

[TestFixture]
public class IdentityProviderApiCallerTest
{
    private readonly IdentityProviderApiCaller _uut; // 被测试的单元

    [Test]
    public void AddGroups()
    {
        var mock = Mock<HttpClient>.Create(); // 语法取决于模拟库
        mock.Expect(x => x.Put(...)); // 配置调用 HTTP PUT 的期望
        _uut = new IdentityProviderApiCaller(mock) // 这是传递依赖项的方法
        var group = new Group();
        var result = _uut.AddGroups(group);
        assert.True(result.IsSuccessful)
    }
}

希望这能帮助您进行代码审查和修改。

英文:

In order to test things, I will suggest a few changes.

  1. Remove all static keyword from your code. I understand you would like to have one thing that does the job, but this can be achieved by having only one instance of the class. This also means it is easier to test your class - create a new instance for a test. You could restrict how many instances is created during the configuration/setup stage in aspnet core (if you are using that).

  2. Change the constructor from static to instance and pass the client as a dependency to it. The new ctor will look something like this:

private readonly HttpClient _client;
public IdentityProviderApiCaller(HttpClient client)
{
if (client == null) throw new ArgumentNullException(nameof(client));
_client = client
}

The key point here, is that you provide the dependency of IdentityProviderApiCaller in a ctor, so you can unit test it. In a unit test you provide a mock for the HTTP client, so you can set expectation for get or post and see if the method is being called correctly. In an integration test you can pass a real instance of HTTP client, so you can actually hit your back end services.

  1. Clarify arguments to the function, consider passing a simple list/array. If you rename the AddGroup class to a Group, then the code gets easier to read. Imagine you can also have APIs to Delete(Group group) or list groups. Having a name AddGroup for the group will be confusing. Also, you can simplify the the async. So all together the code should look something like:
public async Task&lt;HttpResponseMessage&gt; AddGroups(List&lt;Group&gt; groups)
{
if (groups == null) throw new ArgumentNullException(nameof(groups));
var content = GetContent(addGroupsModel);
var urlPath = &quot;/groups&quot;;
return await _client.PutAsync(urlPath, content);
}
  1. Throw a more focused exception, the class Exception is very broad. Consider common exceptions ArgumentException, InvalidOperationException, etc. You could create your own exception too, but maybe best check what built-in exceptions are available
throw new Exception(errorResult.ExceptionMessage); // so this line can become a more focused exception

There may be a class, specifically for aspnet core, where you can return a failure code, it may look something like:

return BadRequest(errorResult.ExceptionMessage);

The idea is that you can specify which error code is returned to the client of your API, such as 401, 400. If an exception is thrown, I think it will be status code 500 Internal Server error, which may not be ideal for the client of the API


Now, back to the unit testing, in meta-code, this is how it will look:

[TestFixture]
public class IdentityProviderApiCallerTest
{
private readonly IdentityProviderApiCaller _uut; // unit under test
[Test]
public void AddGroups()
{
var mock = Mock&lt;HttpClient&gt;.Create(); // syntax depends on the mock lib
mock.Expect(x =&gt; x.Put(...)); // configure expectations for calling HTTP PUT
_uut = new IdentityProviderApiCaller(mock) // this is how you pass the dependency
var group = new Group();
var result = _uut.AddGroups(group);
assert.True(result.IsSuccessful)
}
}

答案2

得分: 2

以下是翻译好的内容:

Oleksii的建议非常中肯:注入HttpClient以使其能够进行单元测试。但关于HttpClient还有一些需要注意的事项。

请设置您的依赖注入以使用IHttpClientFactory以获取HttpClients,以避免套接字争用和陈旧的DNS问题。

HttpClient本身只是HttpMessageHandler的轻量级封装。您不应该模拟HttpClient,而应该模拟HttpMessageHandler,并将其传递给实际的HttpClient实例。请参阅此链接

英文:

Oleksii's advice is on point: inject the HttpClient in order to make it unit testable. But there are a few more things to say on the subject of HttpClient.

Set up your dependency injection to use IHttpClientFactory to give you HttpClients, to avoid socket contention and stale DNS issues.

HttpClient itself is a thin and straightforward wrapper around HttpMessageHandler. Instead of mocking HttpClient, you'll want to mock HttpMessageHandler, and pass it into an actual HttpClient instance. See https://stackoverflow.com/a/36427274/120955

huangapple
  • 本文由 发表于 2023年6月5日 07:40:17
  • 转载请务必保留本文链接:https://go.coder-hub.com/76402843.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定