Testing an API
By the end of this lesson
Test endpoints end to end, including authorisation and failure paths.
What callers depend on is not your handler method. It is the status code, the body shape, the headers and the behaviour of the whole pipeline — model binding, validation, authentication, authorisation, serialisation and error handling, in that order.
A unit test on a handler skips all of it. It can pass while the endpoint returns 415 because the content type was wrong, or 200 with a body in the wrong case, or 500 because the error handler was not registered. So the tests that protect an API send real HTTP requests through the real pipeline and assert on what comes back.
ASP.NET Core makes that inexpensive. WebApplicationFactory starts your application in memory, with your real routing, middleware and dependency injection, and hands you an HttpClient wired to it. No port, no deployment, and fast enough to run on every build.
Three kinds of test, doing different jobs. The middle one is the subject of this lesson:
- Unit tests
- Business rules in isolation: a salary band calculation, a date rule, a discount limit. Fast, numerous, and blind to anything about HTTP.
- Endpoint tests
- A real request through the real pipeline, asserting on status, body and headers. These are the ones that prove the contract, including its failure paths.
- Contract checks
- Comparing the API against its published description, or against what a specific consumer expects. Useful once other teams depend on you and you need to know before they do that something moved.
public class EmployeeEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _app;
public EmployeeEndpointTests(WebApplicationFactory<Program> app) => _app = app;
[Fact]
public async Task Create_returns_201_with_a_location_header()
{
var client = _app.CreateClient();
client.DefaultRequestHeaders.Authorization = TestTokens.ForHrUser();
var response = await client.PostAsJsonAsync("/api/employees", new
{
fullName = "Asha Menon",
email = "asha.menon@example.com",
departmentId = 3,
startDate = "2024-04-17"
});
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
}
[Fact]
public async Task Create_without_a_token_returns_401()
{
var response = await _app.CreateClient()
.PostAsJsonAsync("/api/employees", new { fullName = "Asha Menon" });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Reading_another_employee_returns_403()
{
var client = _app.CreateClient();
client.DefaultRequestHeaders.Authorization = TestTokens.ForEmployee(19);
var response = await client.GetAsync("/api/employees/42");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}- WebApplicationFactory<Program> needs Program to be reachable from the test project. In a top-level-statements application, adding InternalsVisibleTo for the test assembly, or a public partial class Program declaration, is the usual way.
- CreateClient gives an HttpClient that calls the application in memory. Every request goes through the real middleware, so anything the pipeline does to a request or response is exercised.
- The first test asserts the status and the Location header. Asserting only the status would miss a 201 that forgot to say where the new employee is.
- The second test sends no Authorization header. It is one of the most valuable tests in the file, because it is the one that fails if authentication is ever removed from that endpoint.
- The third is the ownership refusal from the previous lesson, expressed as a test. Employee 19 asking for employee 42 must be refused, and this assertion is what keeps that check from being dropped in a later refactor.
- TestTokens is a small helper producing tokens your test configuration accepts. Do not disable authentication in tests to make them easier — that removes the only automated check that it works.
The cases worth a test on every endpoint that can produce them. Each corresponds to something a caller has to handle:
- The success, asserting status, body shape and any header such as Location
- 400 for an invalid body, asserting that the field errors name the right fields
- 401 with no credentials
- 403 for a caller who is authenticated but not permitted, including the record-level refusal
- 404 for an id that does not exist
- 409 for a duplicate or a conflicting state change
- The error body shape itself, so a change to the shared format is caught in one place
- Paging boundaries: the first page, an empty page, and a page size above the maximum
The failure paths deserve the emphasis, because they are the ones that break silently. Your front end exercises the happy path continuously — every developer, every manual check, every demo. If a 200 response loses a field, someone notices within a day.
Nobody exercises the 403. It fires for a request a legitimate user does not make, so if it becomes a 500, or a 200, nothing visible changes. The API keeps working for everyone who is allowed, which is exactly the condition under which a gap can persist for months.
Validation behaves the same way. A form that no longer sends an empty field stops producing 400s, so a regression in the validation response goes unnoticed until a different consumer relies on it. The tests that assert refusals are the tests that hold these paths in place, and they cost very little to write once the factory is set up.
Summary
- Callers depend on the whole pipeline, and WebApplicationFactory runs it in memory with an HttpClient attached
- Assert on status, body shape and headers, not on status alone
- Cover 400, 401, 403, 404 and 409 wherever they can occur, including record-level refusals
- Failure paths break silently because normal use never exercises them
- Keep authentication enabled in tests, and avoid asserting on error wording
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Write the refusals first
Take one endpoint you have that requires authentication and involves a record belonging to someone.
Write three tests before any happy-path test: no token returns 401, a token belonging to the wrong person returns 403, and an id that does not exist returns 404.
Then remove the authorisation check temporarily and confirm the 403 test fails.
Show solution
The confirmation step is the point of the exercise. A test that passes whether or not the protection exists provides no safety, and the only way to know is to break the code deliberately and watch the test fail.
The 404 test frequently surfaces something unexpected: a 500 from a null reference, or a 200 with an empty body. Both are contract problems the happy path could never reveal.
Writing the refusals first also tends to improve the handler. It is much easier to see whether the ownership rule is expressed in one readable place when you are writing a test that has to provoke it.
Think about it
Why a wrong status code survives
A refactor changes a 400 response to a 500 for an invalid request body. Nobody notices for three months.
Explain how that is possible when the endpoint is used every day, and what would have caught it.
Show solution
The endpoint is used every day with valid bodies. The front end validates its own form, so it stops sending the invalid request that would trigger the path, and the failure code is never exercised by normal use.
Monitoring does not help as much as it should, because a low volume of 500s on a busy endpoint looks like background noise rather than a category of failure.
An endpoint test posting a deliberately invalid body would have failed on the commit that caused it. That is the argument for treating failure responses as part of the contract and testing them like any other behaviour — not because they are more likely to break, but because nothing else will tell you when they do.
Saved in this browser only.