1

I'm trying to do some integration tests on an external API. Most of the guides I find online are about testing the ASP.NET web api, but there's not much to find about external API's. I want to test a GET request on this API and confirm if it passes by checking if the status code is OK. However this test is not passing and im wondering if i'm doing this correctly. Currently it's giving me a status code 404(Not found).

I'm using xUnit together with Microsoft.AspNetCore.TestHost How would you suggest me to test external API's?

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);
}

Edit

The API call which im trying to test looks as follows, and is working properly

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();
    }
}
Bram
  • 669
  • 3
  • 12
  • 25
  • I think, you have to pretend to be a browser. Add UserAgent header. – Alexander Petrov Jan 24 '21 at 05:01
  • `"https://prtg.nl/api/content=Group,Device,Status"` - are you sure this is correct url? Can you show the controller method which you are testing? – Fabio Jan 24 '21 at 06:31
  • You are setting credentials but not using them anywhere. I suggest you try using [Postman](https://www.postman.com) first and once you have a valid response write your test. – Gixabel Jan 24 '21 at 08:34
  • I’ve tested the API call in postman and it’s working properly, I left the credentials out in my question to make it more compact – Bram Jan 24 '21 at 11:27
  • The base address of test server is localhost. `TestServer` is ment for in-memory integration tests. You are trying to access an external URL by calling the test server. You will get 404 by design – Nkosi Jan 24 '21 at 13:18
  • 1
    The question in its current state is incomplete and therefore unclear. What are you **actually** trying to do? This might be an [XY problem](https://meta.stackexchange.com/questions/66377/what-is-the-xy-problem). – Nkosi Jan 24 '21 at 13:19
  • is `https://prtg.nl/api/content` local to your API or is that the actual external link you are trying to access? – Nkosi Jan 24 '21 at 13:20
  • I'm trying to execute an GET request on `https://prtg.nl/api/content`. As can be seen in my Edit above i'm pulling devices from this API and storing it in my database. In my test i'm trying to execute a GET request on this API and assert if the status code it will return is 200. – Bram Jan 24 '21 at 13:25
  • @Bram can you include the controller definition and associated attributes. – Nkosi Jan 24 '21 at 13:28

3 Answers3

4

I would like to propose an alternative solution which involves changing the design of the code to be tested.

The currently shown test-case is coupled to the external API and tests its ability to respond 200 OK rather than your code (i.e., your code isn't referenced at all). This also means that if a connection can't be established to the server (e.g., could be an isolated build agent in a CI/CD pipeline or just a flaky café WIFI) the test fails for another reason than what is asserted.

I would propose to extract the HttpClient, and its configuration that is specific to the API, into an abstraction as you have done with the IDevicesRepository (although it's not used in the example). This allows you to substitute the response from the API and only test your code. The substitutions could explore edge-cases such as the connection down, empty response, malformed response, external server error etc. That way you can exercise more failure-paths in your code and keep the test decoupled from the external API.

The actual substitution of the abstraction would be done in the "arrange" phase of the test. You can use the Moq NuGet package for this.

Update

To provide an example of using Moq to simulate an empty API response consider a hypothetical abstraction such as:

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

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

Keep in mind the example abstraction isn't asynchronous, which could be considered best practices as you are invoking I/O (i.e., the network). I skipped it to keep it simple. See Moq documentation on how to handle async methods.

To mock the response the body of the test case could be:

[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...
}

Do check the Moq documentation on their repository (linked above) for more examples.

AnotherGuy
  • 605
  • 11
  • 20
  • Hey thanks for your suggestion, I really appreciate it. Your solution sounds like a better approach to test this API. Could you maybe provide me an example on how to test for an empty response using Moq? – Bram Jan 24 '21 at 14:34
  • 1
    @Bram I added an example. It's very basic, so do read the documentation to Moq to better understand its features. – AnotherGuy Jan 24 '21 at 14:50
  • You are amazing this helps me out so much! Thanks :) I will dig in to this and check out Moq documentation for more details – Bram Jan 24 '21 at 14:52
  • @Bram I just realised I didn't make use of the `TestHost` in the example test case. The Moq substitution (extracted using `apiMock.Object`) can be used as a replacement when you configure the services for the test host. How to do this is out of the scope of this question, but there is plenty of documentation on that. Doing so would also remove the need to create new instances of the `DbContext` and `IDevicesRepository?` from the test case. – AnotherGuy Jan 24 '21 at 14:58
3

The base address of test server is localhost. TestServer is meant for in-memory integration tests. The client created via TestServer.CreateClient() will create an instance of HttpClient that uses an internal message handler to manage requests specific you your API.

If you are trying to access an external URL by calling the test server. You will get 404 by design.

If https://prtg.nl/api/content is not local to your API and is the actual external link you want to access then use an independent 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);
}

//...

If this is meant to be an end to end via your api then you need to call the local API end point which is dependent on the target controller and action

Nkosi
  • 235,767
  • 35
  • 427
  • 472
  • Amazing, using HttpClient fixed the issue for me. Thanks alot! Let's say I would want to take this test one step further. By testing if the amount of devices I get from this API call are equal to the amount of devices stored in my (In memory) database. How would I go on about this? – Bram Jan 24 '21 at 13:47
  • 1
    @Bram You get the content of the API response and assert it against what you expect to get back – Nkosi Jan 24 '21 at 18:02
2

The example in accepted solution is not an integration test, it's unit test. While it's usable in simple scenarios, I wouldn't recommend you to test controllers directly. On integration test level, controller is an implementation detail of your application. Testing implementation details is considered a bad practice. It makes your tests more flaky and less maintainable.

Instead, you should test your API directly using WebApplicationFactory from Microsoft.AspNetCore.Mvc.Testing package.

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

Here is how I would do it

Implementation

Add typed client wrapper around 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);
    }
}

Register your typed client in 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();

Use typed client in your controller

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);
}

Tests

Add fake HttpMessageHandler for mocking HTTP responses

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;
    }
}

Add custom 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);
            });
        });
    }
}

Test will look like this:

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

You can customise CustomWebApplicationFactory and FakeHttpMessageHandler according to your test cases, but I hope the idea is clear

pkirilin
  • 332
  • 2
  • 8