如何使用 ASP.NET 核心对外部 API 进行集成测试
How to do integration testing on an external API with ASP.NET Core
我正在尝试对外部 API 进行一些集成测试。我在网上找到的大多数指南都是关于测试 ASP.NET 网络 api,但是关于外部 API 的指南并不多。我想在此 API 上测试 GET 请求,并通过检查状态代码是否正常来确认它是否通过。但是这个测试没有通过,我想知道我是否正确地做了这个。目前它给我一个状态代码 404(未找到)。
我正在使用 xUnit
和 Microsoft.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);
}
}
您可以根据您的测试用例自定义CustomWebApplicationFactory
和FakeHttpMessageHandler
,但我希望思路清晰
我正在尝试对外部 API 进行一些集成测试。我在网上找到的大多数指南都是关于测试 ASP.NET 网络 api,但是关于外部 API 的指南并不多。我想在此 API 上测试 GET 请求,并通过检查状态代码是否正常来确认它是否通过。但是这个测试没有通过,我想知道我是否正确地做了这个。目前它给我一个状态代码 404(未找到)。
我正在使用 xUnit
和 Microsoft.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);
}
}
您可以根据您的测试用例自定义CustomWebApplicationFactory
和FakeHttpMessageHandler
,但我希望思路清晰