← Open in the full interactive course (progress tracking, search & more)

Your business logic can be perfectly correct and your API can still be broken — a renamed JSON field, a missing header, or the wrong status code is invisible to a unit test and very visible to every client calling you over the wire.

Lessons 309 and 311 already gave you the mechanics for this: a real WebApplicationFactory-hosted pipeline, and — when you need it — a real, disposable database behind it via Testcontainers. What this lesson adds isn't new plumbing; it's a sharper focus on what specifically to verify once a request actually reaches your API. A controller action can compute exactly the right answer and still ship a broken response — the wrong status code, a response shape that doesn't match what the mobile team expects, a missing Content-Type header, or an authorization check that silently returns 401 instead of 403 (or vice versa).

That's the specific, narrower discipline this lesson is about: API testing — treating the HTTP contract itself as something worth verifying directly, not just the logic behind it.

In this lesson, you'll learn what to actually assert in an API test — status codes, response shape, headers — how to test authentication and authorization behavior precisely, how automated API tests (via WebApplicationFactory) relate to manual tools like Postman and Insomnia, and a brief, honestly-scoped look at what "contract testing" means.

What Is It?

The Simple Explanation

API testing means sending a real HTTP request to your API and checking that everything about the response — not just whether it "worked," but its exact status code, its response body's shape, and its headers — matches what every client depending on that API actually expects.

The Technical Definition

API testing is a form of integration testing (309) specifically focused on an HTTP API's external contract: the status code returned for a given scenario, the exact shape and types of the response body, the presence and correctness of relevant response headers, and the API's behavior around authentication and authorization. It can be done through automated, code-based tests — most commonly using WebApplicationFactory's HttpClient, exactly as in lesson 309 — or through manual, exploratory tools built for the purpose.

Automated API tests

Manual/exploratory tools (Postman, Insomnia)

These two aren't competitors — they're used at different moments. A developer might explore and debug an endpoint by hand in Postman while building it, and then encode the behaviors that actually matter as automated tests so they keep being true after the developer moves on to something else.

Why Does It Exist?

The Problem — Correct Logic Doesn't Guarantee a Correct Contract

A unit test (308) can prove a discount calculation is flawless. An integration test (309) can prove the controller action successfully calls it. Neither, on its own, forces anyone to check that the JSON coming back actually has a totalPrice field instead of a renamed total_price, that a "not found" scenario returns 404 rather than 200 with a null body, or that an anonymous request to a protected endpoint returns 401 rather than a misleading 500. Every client of your API — a mobile app, a partner integration, a frontend team — depends on that exact contract holding, and none of those clients read your C# source code to find out whether it does.

The Solution — Assert the Contract Directly, Not Just the Logic Behind It

API testing makes the HTTP contract itself a first-class thing under test — status code, shape, headers, auth behavior — using the exact same real pipeline lesson 309 already gave you access to. It's a deliberate shift in what you're asserting on, not a new mechanism.

Big Picture — Four Things an API Test Should Check

THE FULL CONTRACT, NOT JUST "DID IT WORK"
Status code
Exactly which one, for exactly this scenario — not just "success vs. failure"
Response shape
Field names, types, and structure the body actually returns
Headers
Content-Type, Location on a 201, caching headers — whatever the contract promises
Auth behavior
401 vs. 403, and access correctly denied or granted by role/policy

How It Works — Testing Authentication vs. Authorization Precisely

Lessons 260 and 261 already drew this line precisely, and it's worth restating exactly because getting it backwards is such a common mistake: 401 Unauthorized means the request has no valid identity at all — authentication itself failed or was never attempted. 403 Forbidden means the identity is genuinely valid, but that specific identity isn't permitted to do this specific thing — authentication succeeded, authorization failed. A thorough API test suite checks both, deliberately, as separate scenarios:

ScenarioExpected statusWhy
No auth token sent at all401No identity was established — authentication never succeeded
Valid token, wrong role/policy for this action403Identity is valid; this specific action isn't permitted for it
Valid token, correct role200 (or the success code for the action)Both authentication and authorization succeeded

Simple Example — Status Code, Shape, and Headers Together

public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client; public ProductsApiTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient(); [Fact] public async Task PostProduct_ValidProduct_Returns201WithLocationAndCorrectShape() { // Act var response = await _client.PostAsJsonAsync("/api/products", new { Name = "Widget", Price = 9.99m }); // Assert — status code Assert.Equal(HttpStatusCode.Created, response.StatusCode); // Assert — headers: a 201 must carry a Location pointing at the new resource Assert.NotNull(response.Headers.Location); Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType?.ToString()); // Assert — response shape var created = await response.Content.ReadFromJsonAsync<ProductDto>(); Assert.NotNull(created); Assert.True(created!.Id > 0); Assert.Equal("Widget", created.Name); } }

Meaning: This one test checks four independent parts of the contract at once — the status code is exactly 201 (not just "a success code"), the Location header exists (required by the HTTP spec for a 201 response), the content type is correct, and the deserialized body has the exact shape a client would rely on. Any one of these could break while the underlying business logic stays perfectly correct — which is exactly why each gets its own explicit assertion.

Real-World Example — Testing 401 vs. 403 Precisely

[Fact] public async Task DeleteOrder_NoAuthToken_Returns401() { // No Authorization header attached at all var response = await _client.DeleteAsync("/api/orders/1"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task DeleteOrder_AuthenticatedAsCustomer_NotAdmin_Returns403() { // A valid token for a real, authenticated identity — // just one that lacks the Admin role this endpoint requires _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", CustomerJwt); var response = await _client.DeleteAsync("/api/orders/1"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task DeleteOrder_AuthenticatedAsAdmin_Returns204() { _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AdminJwt); var response = await _client.DeleteAsync("/api/orders/1"); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); }

Three tests, three distinct identities, three distinct — and correct — status codes. Skipping the middle case is exactly the kind of gap that lets a genuine authorization bug (an endpoint that should require Admin but doesn't) reach production without a single test ever noticing.

A Brief, Honest Note on Contract Testing

A related, broader idea worth knowing exists: contract testing asks whether an API's actual shape genuinely matches what its consumers expect — sometimes formalized with a shared, machine-checkable contract (an OpenAPI spec, or a dedicated contract-testing tool) that both the API and its consumers can verify against independently, catching a breaking change before either side deploys. That's a real, valuable practice, especially with multiple independent teams consuming the same API — but it's a distinct discipline from the request/response assertions this lesson covers, and worth treating as its own topic rather than folding in here.

Analogy

A Customs Inspector Checking the Manifest, Not Just the Cargo

A customs inspector doesn't just check that a shipping container has something inside it — they check that the manifest's exact declared contents, weight, and paperwork match what's actually there, down to the specific form that has to be stamped and attached. Cargo that's technically fine but doesn't match its paperwork still gets held at the border. API testing works the same way: the business logic "having the right cargo inside" isn't enough on its own — the status code, the response shape, and the headers are the paperwork every consumer is relying on matching exactly what was promised.

Under the Hood — Faking Authentication for a Test

The examples above assume a real JWT for each identity — genuinely useful when you want to exercise the real token-validation pipeline (262). For tests that only care about the authorization decision itself, ASP.NET Core lets you register a lightweight test authentication handler through WithWebHostBuilder, so a request can simply declare which identity and roles it should be treated as, without generating a real signed token for every test:

HOW A TEST AUTH HANDLER SLOTS INTO THE REAL PIPELINE
1. THE TEST FACTORY REGISTERS A CUSTOM AuthenticationHandler
2. EVERYTHING DOWNSTREAM STAYS REAL

Common Confusion

1. "If my unit tests pass, my API is tested" — no, they test different layers

Unit tests (308) prove logic; integration tests (309) prove wiring; API tests, as this lesson defines them, prove the specific external HTTP contract. A suite can be green across the first two layers while a client-facing field name or status code is silently wrong — that gap is exactly what this lesson closes.

2. "401 and 403 are basically interchangeable failure codes" — they are not

This was already stated precisely in lesson 261, and it's worth repeating here because it's exactly the kind of detail API testing exists to catch: 401 means no valid identity; 403 means a valid identity lacking permission. Returning the wrong one of these isn't cosmetic — clients often branch their behavior on which one they received (401 typically triggers a re-login flow; 403 typically shows a permissions error instead).

Common Mistakes

Mistake 1 — Only asserting the status code, never the response shape

Assert.Equal(HttpStatusCode.OK, response.StatusCode) and nothing else — a response body that's silently missing a field a client depends on sails right through this test undetected.

Deserialize the body and assert on the fields consumers actually rely on — not necessarily every field, but the ones that matter to the contract.

Mistake 2 — Asserting on the entire raw JSON string, including irrelevant fields

Comparing the full response body against one giant, hand-written JSON literal — the test now breaks the moment an unrelated, harmless field (a timestamp, a new optional field) changes, even when nothing that actually matters to consumers did.

Deserialize into a typed DTO and assert on the specific fields the test cares about — exactly what the "Simple Example" above does — so the test stays resilient to unrelated additions.

Mistake 3 — Never testing the 401/403 boundary at all

Only testing the "happy path" of a protected endpoint with valid, sufficient credentials — leaving the actual security boundary completely unverified.

Test all three states explicitly, as the real-world example above did: no identity (401), valid identity without permission (403), and valid identity with permission (success) — this is precisely where authorization bugs hide.

When Should I Use It?

Rule of thumb: If a client outside your own codebase would notice the difference — a renamed field, a different status code, a missing header, the wrong 401/403 — it belongs in an API test.

Mental Model

API testing = verify the whole HTTP contract — status code, shape, headers, auth — not just "did it work."
401 = no valid identity. 403 = valid identity, insufficient permission. Test both, plus the success case.
Automated tests gate the build; Postman/Insomnia support exploration and debugging — complementary, not competing.

Assert on the fields consumers actually rely on — never the whole raw response as one brittle string.

Key Takeaway


Check Your Understanding

You've learned what to actually assert in an API test, how to test the 401/403 boundary correctly, and where automated and manual tools each fit. Let's confirm it clicked.

1. A request is sent to a protected endpoint with no Authorization header at all. What status code should the API correctly return?

Show answer

Correct: B

Why B is correct: No identity was presented at all, so authentication itself never succeeded — that's precisely what 401 means, as lessons 260/261 established and this lesson restates.

Why A is incorrect: 403 requires a valid, established identity that's simply not permitted — there's no identity here to evaluate permissions for in the first place.

Why C is incorrect: A missing credential is an entirely expected, routine scenario with a well-defined status code — not a server error.

Why D is incorrect: Silently returning 200 would hide the access failure from the client entirely, which is both incorrect and a poor API contract.

Reinforcement: No identity at all → 401. This is the boundary API tests should verify explicitly, not assume.

2. An API test asserts only Assert.Equal(HttpStatusCode.OK, response.StatusCode) for a GET endpoint, with no assertion on the response body. What risk does this leave uncovered, according to this lesson?

Show answer

Correct: B

Why B is correct: This is exactly Mistake 1 from the lesson — a status-code-only assertion says nothing about whether the response body's shape still matches what consumers expect.

Why A is incorrect: The lesson explicitly treats shape and headers as equally important parts of the contract, not just the status code.

Why C is incorrect: This is a runtime testing gap, not a compilation issue — the test compiles and runs fine, it just doesn't verify enough.

Why D is incorrect: Without an explicit assertion on the body, an empty or malformed body would pass this test silently — nothing here would catch it.

Reinforcement: Status code alone is not the contract — shape and headers matter just as much, and deserve their own assertions.

3. A test compares an entire API response against one large, hand-written JSON string, including every field. A later, unrelated change adds a new optional timestamp field to the response, and the test breaks. What does this lesson identify as the problem?

Show answer

Correct: B

Why B is correct: This is exactly Mistake 2 — whole-string comparisons are brittle against harmless, unrelated changes; targeted assertions on a deserialized DTO stay resilient while still catching genuine regressions.

Why A is incorrect: A harmless, backward-compatible addition shouldn't break a well-written test — only changes that actually matter to consumers should.

Why C is incorrect: APIs evolve, and adding new optional fields is a common, often harmless change — the lesson's point is about how the test should be written, not about freezing the API forever.

Why D is incorrect: This is the opposite lesson from Mistake 1 — status-code-only tests are too loose; whole-string comparisons are too strict. Targeted DTO assertions are the balance this lesson recommends.

Reinforcement: Assert on the specific fields that matter to consumers — not the whole raw payload, not just the status code.

4. What is the most accurate, honestly-scoped relationship between automated API tests and manual tools like Postman or Insomnia?

Show answer

Correct: B

Why B is correct: This is exactly the relationship the lesson describes — manual tools are excellent for exploration and debugging in the moment; automated tests are what actually keeps the contract verified, repeatably, over time.

Why A is incorrect: Manual testing doesn't run automatically on every change and leaves no repeatable regression record — exactly the gap automated tests exist to close.

Why C is incorrect: No such required conversion step exists — automated tests are written directly in code, independent of any manual tool's collections.

Why D is incorrect: They solve different problems at different points in the workflow — one is for a human exploring interactively, the other for a machine verifying automatically and repeatedly.

Reinforcement: Explore and debug by hand; encode what matters as automated tests so it stays true after you've moved on.

You now know how to verify an API's full contract, not just its logic — status codes, shape, headers, and the precise 401/403 boundary. With correctness covered end to end, the next question is different entirely: is the system actually fast enough — performance testing.


dotnetmadeeasy.com — Learn C# and .NET, the right way.