进行外部 API 调用的测试控制器操作

Test controller action that makes an external API call

我有一个带有动作功能的 API 控制器。此函数对另一个 API 进行外部调用以获取一些数据。这个外部调用是通过简单地创建一个带有 URL 的客户端来完成的。我想使用 WebApplicationFactory 创建一个测试来测试这个动作函数。 我想知道如何配置这个外部调用。说如果服务器调用这个 URL return 这个响应。

可能应该在覆盖 ConfigureWebHost 的某个地方告诉服务器,如果您调用此 URL(外部 API url)return 此响应。

这是我要测试的控制器动作。

namespace MyAppAPI.Controllers
{
    public class MyController : ControllerBase
    {
        [HttpPost("MyAction")]
        public async Task MyAction([FromBody] int inputParam)
        {
            var externalApiURL = "http://www.external.com?param=inputParam";
            var client = new HttpClient();
            var externalResponse = await client.GetAsync(externalApiURL);
            //more work with the externalResponse
        }
    }
}

这是测试class我想用

public class MyAppAPITests : IClassFixture<WebApplicationFactory<MyAppAPI.Startup>>
{
     private readonly WebApplicationFactory<MyAppAPI.Startup> _factory;

     public MyAppAPITests(WebApplicationFactory<MyAppAPI.Startup> factory)
     {
          _factory = factory;
     }

     [Fact]
     public async Task Test_MyActionReturnsExpectedResponse()
     {
          //Arrange Code

          //Act
          //Here I would like to have something like this or a similar fashion
          _factory.ConfigureReponseForURL("http://www.external.com?param=inputParam",
                   response => {
                         response.Response = "ExpectedResponse";
                   });

          //Assert Code
     }
}

Test_MyActionReturnsExpectedResponse中的代码在任何地方都不存在,这正是我想通过继承WebApplicationFactory或配置它来拥有的代码。我想知道如何实现。即在 API 控制器进行外部调用时配置响应。 谢谢你的帮助。

我认为最好的方法是使用接口和 MOCK。继承HttpClient实现接口,测试时mock这个接口:

    public interface IHttpClientMockable
    {
        Task<string> GetStringAsync(string requestUri);
        Task<string> GetStringAsync(Uri requestUri);
        Task<byte[]> GetByteArrayAsync(string requestUri);
        Task<byte[]> GetByteArrayAsync(Uri requestUri);
        Task<Stream> GetStreamAsync(string requestUri);
        Task<Stream> GetStreamAsync(Uri requestUri);
        Task<HttpResponseMessage> GetAsync(string requestUri);
        Task<HttpResponseMessage> GetAsync(Uri requestUri);
        Task<HttpResponseMessage> GetAsync(string requestUri, HttpCompletionOption completionOption);
        Task<HttpResponseMessage> GetAsync(Uri requestUri, HttpCompletionOption completionOption);
        Task<HttpResponseMessage> GetAsync(string requestUri, CancellationToken cancellationToken);
        Task<HttpResponseMessage> GetAsync(Uri requestUri, CancellationToken cancellationToken);
        Task<HttpResponseMessage> GetAsync(string requestUri, HttpCompletionOption completionOption, CancellationToken cancellationToken);
        Task<HttpResponseMessage> GetAsync(Uri requestUri, HttpCompletionOption completionOption, CancellationToken cancellationToken);
        Task<HttpResponseMessage> PostAsync(string requestUri, HttpContent content);
        Task<HttpResponseMessage> PostAsync(Uri requestUri, HttpContent content);
        Task<HttpResponseMessage> PostAsync(string requestUri, HttpContent content, CancellationToken cancellationToken);
        Task<HttpResponseMessage> PostAsync(Uri requestUri, HttpContent content, CancellationToken cancellationToken);
        Task<HttpResponseMessage> PutAsync(string requestUri, HttpContent content);
        Task<HttpResponseMessage> PutAsync(Uri requestUri, HttpContent content);
        Task<HttpResponseMessage> PutAsync(string requestUri, HttpContent content, CancellationToken cancellationToken);
        Task<HttpResponseMessage> PutAsync(Uri requestUri, HttpContent content, CancellationToken cancellationToken);
        Task<HttpResponseMessage> DeleteAsync(string requestUri);
        Task<HttpResponseMessage> DeleteAsync(Uri requestUri);
        Task<HttpResponseMessage> DeleteAsync(string requestUri, CancellationToken cancellationToken);
        Task<HttpResponseMessage> DeleteAsync(Uri requestUri, CancellationToken cancellationToken);
        Task<HttpResponseMessage> SendAsync(HttpRequestMessage request);
        Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
        Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption);
        Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken);
        void CancelPendingRequests();
        HttpRequestHeaders DefaultRequestHeaders { get; }
        Uri BaseAddress { get; set; }
        TimeSpan Timeout { get; set; }
        long MaxResponseContentBufferSize { get; set; }
        void Dispose();
    }

    public class HttpClientMockable: HttpClient, IHttpClientMockable
    {

    }

问题是您有一个隐藏的依赖项,即 HttpClient。因为你在你的行动中更新了它,所以不可能嘲笑。相反,您应该将此依赖项注入您的控制器。由于 IHttpClientFactory,ASP.NET Core 2.1+ 使 HttpClient 成为可能。但是,开箱即用,您不能将 HttpClient 直接注入控制器,因为控制器未在服务集合中注册。虽然您可以更改它,但推荐的方法是改为创建 "service" class。无论如何,这实际上更好,因为它完全从您的控制器中抽象出与此 API 交互的知识。总之,你应该做这样的事情:

public class ExternalApiService
{
    private readonly HttpClient _httpClient;

    public ExternalApiService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public Task<ExternalReponseType> GetExternalResponseAsync(int inputParam) =>
        _httpClient.GetAsync($"/endpoint?param={inputParam}");
}

然后,在ConfigureServices中注册:

services.AddHttpClient<ExternalApiService>(c =>
{
    c.BaseAddress = new Uri("http://www.external.com");
});

最后,将其注入您的控制器:

public class MyController : ControllerBase
{
    private readonly ExternalApiService _externalApi;

    public MyController(ExternalApiService externalApi)
    {
        _externalApi = externalApi;
    }

    [HttpPost("MyAction")]
    public async Task MyAction([FromBody] int inputParam)
    {
        var externalResponse = await _externalApi.GetExternalResponseAsync(inputParam);
        //more work with the externalResponse
    }
}

现在,使用此 API 的逻辑已从您的控制器中抽象出来,并且您有一个可以轻松模拟的依赖项。由于您想要进行集成测试,因此您需要在测试时加入不同的服务实现。为此,我实际上会做一些进一步的抽象。首先,为 ExternalApiService 创建一个接口并让服务实现它。然后,在您的测试项目中,您可以创建一个完全绕过 HttpClient 且仅 returns 预制响应的替代实现。然后,虽然不是绝对必要,但我会创建一个 IServiceCollection 扩展来抽象 AddHttpClient 调用,允许您重用此逻辑而无需重复自己:

public static class IServiceCollectionExtensions
{
    public static IServiceCollection AddExternalApiService<TImplementation>(this IServiceCollection services, string baseAddress)
        where TImplementation : class, IExternalApiService
    {
        services.AddHttpClient<IExternalApiService, TImplementation>(c =>
        {
            c.BaseAddress = new Uri(baseAddress)
        });
        return services;
    }
}

你会像这样使用:

services.AddExternalApiService<ExternalApiService>("http://www.external.com");

基地址可以(并且可能应该)通过配置提供,用于 abstraction/testability 的额外层。最后,你应该使用 TestStartupWebApplicationFactory。它使切换服务和其他实现变得容易得多,而无需在 Startup 中重写所有 ConfigureServices 逻辑,这当然会为您的测试添加变量:例如它不起作用是因为我忘记了像在我的真实 Startup 中一样注册的东西吗?

只需将一些虚拟方法添加到您的 Startup class,然后使用这些方法来添加您的数据库,在这里,添加您的服务:

public class Startup
{
    ...

    public void ConfigureServices(IServiceCollection services)
    {
        ...

        AddExternalApiService(services);
    }

    protected virtual void AddExternalApiService(IServiceCollection services)
    {
        services.AddExternalApiService<ExternalApiService>("http://www.external.com");
    }
}

然后,在您的测试项目中,您可以从 Startup 派生并覆盖此方法和类似方法:

public class TestStartup : MyAppAPI.Startup
{
    protected override void AddExternalApiService(IServiceCollection services)
    {
        // sub in your test `IExternalApiService` implementation
        services.AddExternalApiService<TestExternalApiService>("http://www.external.com");
    }
}

最后,在获取测试客户端时:

var client = _factory.WithWebHostBuilder(b => b.UseStartup<TestStartup>()).CreateClient();

实际的 WebApplicationFactory 仍然使用 MyAppAPI.Startup,因为该通用类型参数对应于应用程序入口点,而不是实际使用的 Startup class。