Testing an API
By the end of this lesson
Exercise a running application over HTTP, including failure paths.
An API test starts your application, sends a real HTTP request to it, and asserts on the response. Status code, headers, body.
That is worth doing because a lot of an API is not your code. Routing decides which method runs. Model binding turns JSON into objects. Validation rejects bad input. Authorisation decides whether the request is allowed. Serialisation turns the result back into JSON. A unit test on the controller method skips every one of those, so it can pass while the endpoint returns 404, or 401, or a body with the wrong property names.
In ASP.NET Core the tooling for this is unusually direct: the application is hosted in the same process as the test, and you get an HttpClient wired to it. No port, no deployment, no separate build step.
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class EmployeesEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public EmployeesEndpointTests(WebApplicationFactory<Program> factory) =>
_client = factory.CreateClient();
[Fact]
public async Task GetEmployee_WhenTheEmployeeExists_ReturnsTheEmployee()
{
HttpResponseMessage response = await _client.GetAsync("/api/employees/1042");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
EmployeeResponse? employee = await response.Content.ReadFromJsonAsync<EmployeeResponse>();
Assert.NotNull(employee);
Assert.Equal(1042, employee!.EmployeeId);
Assert.Equal("Sales Representative", employee.JobTitle);
}
}- WebApplicationFactory<Program> starts the application using its real startup code — the same registrations, middleware and endpoint mapping that run in production.
- Program is the top-level statements file. Because that generated class is internal, the test project cannot name it until you add public partial class Program { } at the end of Program.cs. That one line is the most common thing to miss on a first attempt.
- CreateClient returns an HttpClient that talks to the in-process application. There is no network and no port, so nothing to configure and nothing to clash with another test run.
- ReadFromJsonAsync deserialises using the same options the application configured. If a property name or casing does not round-trip, this is where you find out — a unit test on the controller returns a typed object and never serialises anything.
- Assert on the status code first and separately. If the endpoint returned 500, an assertion about the body fails with a deserialisation error that tells you nothing about the cause.
Failure paths break unnoticed, which is the reason to test them first. The happy path has a safety net: somebody uses the feature, sees the wrong thing, and reports it.
Failure paths have no such net. If an endpoint that should return 404 for an unknown employee starts returning 200 with an empty body, or an endpoint that should require a token starts allowing anonymous requests, nobody notices by using the application normally. The request that should have been refused just succeeds quietly.
These are also the paths most easily broken by a change somewhere else — a reordered middleware registration, a removed attribute, a filter that no longer runs. That combination, high risk and low visibility, is why an API suite should cover them at least as carefully as the success case.
The responses worth pinning down for most endpoints:
- 401 when no credentials are supplied to an endpoint that requires them. This is the one that turns into a security incident rather than a bug report.
- 403 when the caller is authenticated but not permitted. Distinct from 401, and frequently confused with it.
- 404 for an identifier that does not exist, rather than 200 with a null body or 500 from a null reference.
- 400 for invalid input, with a body that names the field that was wrong.
- 409 or an equivalent for a conflict, such as creating something that already exists.
- The behaviour for malformed JSON, which reaches model binding rather than your code.
[Fact]
public async Task GetEmployee_WhenNoTokenIsSupplied_ReturnsUnauthorised()
{
// A client with no Authorization header at all
HttpResponseMessage response = await _client.GetAsync("/api/employees/1042");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GetEmployee_WhenTheEmployeeDoesNotExist_ReturnsNotFound()
{
HttpResponseMessage response = await _authorisedClient.GetAsync("/api/employees/999999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task CreateEmployee_WithABlankLastName_ReturnsBadRequestNamingTheField()
{
var request = new { firstName = "Priya", lastName = "", jobTitle = "Sales Representative" };
HttpResponseMessage response = await _authorisedClient.PostAsJsonAsync("/api/employees", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.True(problem!.Errors.ContainsKey("lastName"));
}- The first test is the cheapest security check in the suite. It asserts that the endpoint is not reachable without credentials, which is the kind of protection that disappears when somebody moves an [Authorize] attribute or reorders middleware.
- 404 rather than 200 matters to callers. A client that receives 200 with an empty body has to invent a rule for interpreting it, and the usual rule is a null reference somewhere further on.
- Asserting that the validation response names lastName is the difference between testing that it failed and testing that the caller can act on the failure. ValidationProblemDetails is the standard shape ASP.NET Core returns for model validation, so the assertion is about a contract rather than about wording.
- An anonymous object as the request body is deliberate. It lets the test send a blank last name that a strongly typed request record might not allow you to construct, and it keeps the test honest about sending raw JSON.
- The authorised client is a separate HttpClient with a token attached. Producing one usually means registering a test authentication handler on the factory so the test does not depend on a real identity provider being reachable.
Summary
- An API test exercises routing, binding, validation, authorisation and serialisation — none of which a unit test on a controller method touches
- WebApplicationFactory hosts the real application in the test process, with no port or deployment involved
- Add public partial class Program { } to the API project so the test can name the startup class
- Cover 401, 403, 404 and 400 deliberately: failure paths break as side effects and nobody notices by using the application
- Keep case-by-case logic in unit tests and use API tests for the contract a caller depends on
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Cover a create endpoint properly
POST /api/employees creates an employee. It requires a token, validates that the first name, last name and job title are present, and rejects a hire date in the future.
List the tests you would write, then write the two that a reviewer is most likely to find missing.
Show solution
A reasonable set: 201 with a Location header on success, 401 with no token, 403 for a caller without the permission, 400 for each invalid field, 400 for a future hire date, and the response body shape on success.
The two most often missing are 401 and the Location header. The unauthorised case gets skipped because authentication is assumed to be handled elsewhere, and the header gets skipped because nobody looks at it by hand — which is precisely why a client that depends on it will break without warning.
Asserting on the Location header is asserting on the contract. A caller that follows it to fetch the new employee has a working integration only if that header is correct, and no other test in the suite looks at it.
Note what the success test does not do: it does not assert on the generated identifier's value. That is assigned by the database and is not part of the contract, so pinning it down would make the test fail for a reason nobody cares about.
[Fact]
public async Task CreateEmployee_WithValidDetails_ReturnsCreatedWithALocationHeader()
{
var request = new
{
firstName = "Priya",
lastName = "Anand",
jobTitle = "Sales Representative",
hireDate = "2024-06-03",
};
HttpResponseMessage response = await _authorisedClient.PostAsJsonAsync("/api/employees", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
// The header must actually lead somewhere
HttpResponseMessage followUp = await _authorisedClient.GetAsync(response.Headers.Location);
Assert.Equal(HttpStatusCode.OK, followUp.StatusCode);
}
[Fact]
public async Task CreateEmployee_WithNoToken_ReturnsUnauthorisedAndCreatesNothing()
{
var request = new { firstName = "Priya", lastName = "Anand", jobTitle = "Sales Representative" };
HttpResponseMessage response = await _client.PostAsJsonAsync("/api/employees", request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}Think about it
Why the 401 test is the one to keep
You have to cut your API suite from forty tests to ten because the pipeline is too slow. A colleague suggests dropping all the failure-path tests, since the happy paths are what users exercise.
Make the case for keeping the unauthorised and not-found tests ahead of several happy-path ones.
Show solution
Start with how each kind of failure gets discovered. A broken happy path is reported within hours, because someone was using it. A missing authorisation check is discovered by whoever finds it first, which may not be you, and the cost is not proportional to the size of the mistake.
Next, look at how each breaks. Happy paths break when the feature changes, and whoever changes it is thinking about it. Failure paths break as side effects — middleware reordered, an attribute dropped in a merge, a filter that stopped being registered. Nobody is thinking about them at the moment they break.
There is a fair counter-argument, and it is worth conceding: if a happy path is the one thing that earns the organisation money, it belongs in the ten. The point is not that failure paths always win, but that they cannot be ranked last just because users do not exercise them.
Better still, question the premise. A slow API suite is usually slow because it covers logic that belongs in unit tests. Moving those down is how you get from forty tests to ten without losing coverage of anything.
Saved in this browser only.