如何使用 ASP.NET 核心对外部 API 进行集成测试

How to do integration testing on an external API with ASP.NET Core

我正在尝试对外部 API 进行一些集成测试。我在网上找到的大多数指南都是关于测试 ASP.NET 网络 api,但是关于外部 API 的指南并不多。我想在此 API 上测试 GET 请求,并通过检查状态代码是否正常来确认它是否通过。但是这个测试没有通过,我想知道我是否正确地做了这个。目前它给我一个状态代码 404(未找到)。

我正在使用 xUnitMicrosoft.AspNetCore.TestHost 您建议我如何测试外部 API?

private readonly HttpClient _client;

public DevicesApiTests()
{
    var server = new TestServer(new WebHostBuilder()
        .UseEnvironment("Development")
        .UseStartup<Startup>());
    _client = server.CreateClient();
}

[Theory]
[InlineData("GET")]
public async Task GetAllDevicesFromPRTG(string method)
{
    //Arrange
    var request = new HttpRequestMessage(new HttpMethod(method), "https://prtg.nl/api/content=Group,Device,Status");

    //Act
    var response = await _client.SendAsync(request);

    // Assert
    response.EnsureSuccessStatusCode();
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

编辑

我正在尝试测试的 API 调用如下所示,并且工作正常

private readonly DbContext _dbContext;
private readonly IDevicesRepository _devicesRepository;

public DevicesAPIController(DbContext dbContext, IDevicesRepository devicesRepository)
{
    _dbContext = dbContext;
    _devicesRepository = devicesRepository;
}

[HttpPost("PostLiveDevicesToDatabase")]
public async Task<IActionResult> PostLiveDevicesToDatabase()
{
    try
    {

        using (var httpClient = new HttpClient())
        {
            httpClient.DefaultRequestHeaders.Clear();
            httpClient.DefaultRequestHeaders.Accept.Add(
                new MediaTypeWithQualityHeaderValue("application/json"));

            using (var response = await httpClient
                .GetAsync(
                    "https://prtg.nl/api/content=Group,Device,Status")
            )
            {
                string apiResponse = await response.Content.ReadAsStringAsync();
                var dataDeserialized = JsonConvert.DeserializeObject<Devices>(apiResponse);

                devicesList.AddRange(dataDeserialized.devices);

                foreach (DevicesData device in devicesList)
                {

                    _dbContext.Devices.Add(device);
                    devicesAdded.Add(device);

                    _dbContext.SaveChanges();
                }
            }
        }
    }
    catch
    {
        return BadRequest();
    }
}

测试服务器的基址是localhost。 TestServer 用于内存中集成测试。通过 TestServer.CreateClient() 创建的客户端将创建一个 HttpClient 的实例,该实例使用内部消息处理程序来管理特定于您的请求 API。

如果您尝试通过调用测试服务器访问外部 URL。你会得到 404 的设计。

如果 https://prtg.nl/api/content 不是您的 API 本地的并且是您要访问的实际外部 link 则使用独立的 HttpClient

//...

private static readonly HttpClient _client;

static DevicesApiTests() {
    _client = new HttpClient();
}

[Theory]
[InlineData("GET")]
public async Task GetAllDevicesFromPRTG(string method) {
    //Arrange
    var request = new HttpRequestMessage(new HttpMethod(method), "https://prtg.nl/api/content=Group,Device,Status");

    //Act
    var response = await _client.SendAsync(request);

    // Assert
    response.EnsureSuccessStatusCode();
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

//...

如果这意味着通过您的 api 端到端,那么您需要调用本地 API 端点,这取决于目标控制器和操作

我想提出一个替代解决方案,其中涉及更改要测试的代码的设计。

当前显示的测试用例耦合到外部 API 并测试其响应能力 200 OK 而不是您的代码(即,您的代码根本没有被引用)。这也意味着,如果无法与服务器建立连接(例如,可能是 CI/CD 管道中的独立构建代理,或者只是不稳定的咖啡馆 WIFI),则测试失败的原因与所断言的原因不同。

我建议将 HttpClient 及其特定于 API 的配置提取到一个抽象中,就像您对 IDevicesRepository 所做的那样(尽管它没有被使用在示例中)。这允许您替换来自 API 的响应并仅测试您的代码。替换可以探索边缘情况,例如连接断开、空响应、格式错误的响应、外部服务器错误等。这样您就可以在代码中使用更多的故障路径,并使测试与外部分离API。

抽象的实际替换将在测试的“安排”阶段完成。您可以为此使用 Moq NuGet 包。

更新

要提供使用 Moq 模拟空 API 响应的示例,请考虑一个假设的抽象,例如:

public interface IDeviceLoader
{
    public IEnumerable<DeviceDto> Get();
}

public class DeviceDto
{
    // Properties here...
}

请记住示例抽象不是异步的,当您调用 I/O(即网络)时,这可以被视为最佳实践。为了简单起见,我跳过了它。请参阅 Moq documentation 了解如何处理异步方法。

要模拟响应,测试用例的主体可以是:

[Fact]
public async Task CheckEndpointHandlesEmptyApiResponse()
{
    // How you get access to the database context and device repository is up to you.
    var dbContext = ...
    var deviceRepository = ...

    //Arrange
    var apiMock = new Mock<IDeviceLoader>();

    apiMock.Setup(loader => loader.Get()).Returns(Enumerable.Empty<DeviceDto>());

    var controller = new DevicesAPIController(dbContext, deviceRepository, apiMock.Object);

    //Act
    var actionResponse = controller.PostLiveDevicesToDatabase();

    // Assert
    // Check the expected HTTP result here...
}

请查看其存储库(上面链接)中的 Moq 文档以获取更多示例。

已接受解决方案中的示例不是集成测试,而是单元测试。虽然它可以在简单的场景中使用,但我不建议您直接测试控制器。在集成测试级别,控制器是应用程序的实现细节。测试实现细节被认为是一种不好的做法。它使您的测试更加脆弱且难以维护。

相反,您应该使用 Microsoft.AspNetCore.Mvc.Testing 包中的 WebApplicationFactory 直接测试您的 API。

https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests

这是我的做法

实施

HttpClient

周围添加类型化客户端包装器
public class DeviceItemDto
{
    // some fields
}

public interface IDevicesClient
{
    Task<DeviceItemDto[]?> GetDevicesAsync(CancellationToken cancellationToken);
}

public class DevicesClient : IDevicesClient
{
    private readonly HttpClient _client;

    public DevicesClient(HttpClient client)
    {
        _client = client;
    }
    
    public Task<DeviceItemDto[]?> GetDevicesAsync(CancellationToken cancellationToken)
    {
        return _client.GetFromJsonAsync<DeviceItemDto[]>("/api/content=Group,Device,Status", cancellationToken);
    }
}

在 DI 中注册您输入的客户端

public static class DependencyInjectionExtensions
{
    public static IHttpClientBuilder AddDevicesClient(this IServiceCollection services)
    {
        return services.AddHttpClient<IDevicesClient, DevicesClient>(client =>
        {
            client.BaseAddress = new Uri("https://prtg.nl");
        });
    }
}

// Use it in Startup.cs
services.AddDevicesClient();

在控制器中使用类型化客户端

private readonly IDevicesClient _devicesClient;

public DevicesController(IDevicesClient devicesClient)
{
    _devicesClient = devicesClient;
}

[HttpGet("save")]
public async Task<IActionResult> PostLiveDevicesToDatabase(CancellationToken cancellationToken)
{
    var devices = await _devicesClient.GetDevicesAsync(cancellationToken);

    // save to database code
    
    // you can return saved devices, or their ids
    return Ok(devices);
}

测试

为模拟 HTTP 响应添加伪造的 HttpMessageHandler

public class FakeHttpMessageHandler : HttpMessageHandler
{
    private HttpStatusCode _statusCode = HttpStatusCode.NotFound;
    private HttpContent? _responseContent;
    
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var response = new HttpResponseMessage(_statusCode)
        {
            Content = _responseContent
        };
        
        return Task.FromResult(response);
    }

    public FakeHttpMessageHandler WithDevicesResponse(IEnumerable<DeviceItemDto> devices)
    {
        _statusCode = HttpStatusCode.OK;
        _responseContent = new StringContent(JsonSerializer.Serialize(devices));
        return this;
    }
}

添加自定义WebApplicationFactory

internal class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Use the same method as in implementation
            services.AddDevicesClient()
                // Replaces the default handler with mocked one to avoid calling real API in tests
                .ConfigurePrimaryHttpMessageHandler(() => new FakeHttpMessageHandler());
        });
    }

    // Use this method in your tests to setup specific responses
    public WebApplicationFactory<Program> UseFakeDevicesClient(
        Func<FakeHttpMessageHandler, FakeHttpMessageHandler> configureHandler)
    {
        var handler = configureHandler.Invoke(new FakeHttpMessageHandler());
        
        return WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                services.AddDevicesClient().ConfigurePrimaryHttpMessageHandler(() => handler);
            });
        });
    }
}

测试将如下所示:

public class GetDevicesTests
{
    private readonly CustomWebApplicationFactory _factory = new();

    [Fact]
    public async void Saves_all_devices_from_external_resource()
    {
        var devicesFromExternalResource => new[]
        {
            // setup some test data
        }

        var client = _factory
            .UseFakeDevicesClient(_ => _.WithDevicesResponse(devicesFromExternalResource))
            .CreateClient();

        var response = await client.PostAsync("/devices/save", CancellationToken.None);
        var devices = await response.Content.ReadFromJsonAsync<DeviceItemDto[]>();
        
        response.StatusCode.Should().Be(200);
        devices.Should().BeEquivalentTo(devicesFromExternalResource);
    }
}

Code example

您可以根据您的测试用例自定义CustomWebApplicationFactoryFakeHttpMessageHandler,但我希望思路清晰