Tests for the Whole API: Safety Nets and Coverage – Web API | Lesson 96
Introduction
Since Lesson 63, the SMS has collected 168 tests, one lesson at a time. Each one checks what its lesson built. But some mistakes do not belong to any one lesson: a forgotten registration, a door left open, a renamed route.
This lesson looks at the API as a whole. You will add tests that check every controller and every endpoint at once, measure which code the tests actually run, and fill the biggest gaps.
By the end of this lesson, you will be able to:
- Describe the SMS test suite as layers: unit, integration, and API tests.
- Write tests that cover every controller and every endpoint automatically.
- Use an approved list to catch routes that change by accident.
- Measure code coverage, read it honestly, and avoid its traps.
- Use the report to decide which tests to write next.
Why this matters
Three lessons warned about mistakes that slip past normal tests. Lesson 69: a missing registration only fails
when that controller is first used. Lesson 85: an endpoint marked [AllowAnonymous] by mistake is open to
everyone.
Lesson 93: a renamed route breaks every installed app. A test for each endpoint would catch these, but only if someone remembers to write it.
The tests in this lesson do not depend on memory. They look at every controller and every endpoint the API has, so a new endpoint is checked the day it is added.
The concept
Think of a school safety inspection. One inspector checks the science lab's gas taps in detail. Another walks through every room with a short checklist: exit clear, lock works, lights on.
The first is like a normal test: deep, about one thing. The second is like a whole-API test: shallow, but nothing is skipped.
Example: the SMS test map
| Layer | Folder | Tests | Time | What they check |
|---|---|---|---|---|
| Unit | Unit/ | 55 | 0.2 s | services, rules, jobs; fakes instead of a database (Lessons 63–95) |
| Integration | Integration/ | 37 | 3 s | repositories and SQL against the real SchoolDb |
| API | Api/ | 82 | 10 s | the whole API in memory: routes, security, responses, background work |
| Library | Library/ | 6 | the homework from Lesson 63 |
This shape is often called the test pyramid: many fast tests at the bottom, fewer slow ones at the top. The unit tests run in a fifth of a second, so you can run them after every small change.
Example: running one layer
dotnet test --filter "FullyQualifiedName~SchoolApp.Tests.Unit" # 55 tests, a fifth of a second
dotnet test --filter "FullyQualifiedName~WholeApiTests" # this lesson's safety nets
dotnet test # everything, before every commit
Apply it to the SMS
All three safety nets live in one file. It asks the running API, through ApiFactory, for its endpoints.
Show tests/SchoolApp.Tests/Api/WholeApiTests.cs
using System.Runtime.CompilerServices;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace SchoolApp.Tests.Api;
// Lesson 96: safety nets for the whole API. Each test looks at every controller or every endpoint at once,
// so a new one is covered the day it is added, without anyone writing a test for it.
[Collection("Api")]
public class WholeApiTests
{
private readonly ApiFactory _factory;
public WholeApiTests(ApiFactory factory)
{
_factory = factory;
}
// Every endpoint the running API has: controllers, health checks, OpenAPI, and Scalar.
private List<RouteEndpoint> Endpoints() =>
_factory.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>().ToList();
// "GET api/students/{admissionNumber}", or "ANY health/live" for an endpoint that takes every method.
// A route with a version in it (Lesson 93) also names its version, so version 1 and version 2 are two lines.
private static string Describe(RouteEndpoint endpoint)
{
IReadOnlyList<string>? methods = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods;
string method = methods is { Count: > 0 } ? string.Join(",", methods) : "ANY";
string route = endpoint.RoutePattern.RawText?.TrimStart('/') ?? "";
ApiVersionMetadata? versions = endpoint.Metadata.GetMetadata<ApiVersionMetadata>();
if (route.Contains("{version") && versions is not null)
{
route += $" (v{string.Join(", v", versions.Map(ApiVersionMapping.Implicit).DeclaredApiVersions)})";
}
return $"{method} {route}";
}
// Lesson 69 promised this: a missing registration only fails when that controller is first used.
// MVC creates controllers with ActivatorUtilities, so this test does the same for every one of them.
[Fact]
public void EveryController_CanBeCreated()
{
Type[] controllers = typeof(Program).Assembly.GetTypes()
.Where(type => typeof(ControllerBase).IsAssignableFrom(type) && !type.IsAbstract)
.ToArray();
using IServiceScope scope = _factory.Services.CreateScope();
List<string> failures = [];
foreach (Type controller in controllers)
{
try
{
ActivatorUtilities.CreateInstance(scope.ServiceProvider, controller);
}
catch (InvalidOperationException exception)
{
failures.Add($"{controller.Name}: {exception.Message}");
}
}
Assert.NotEmpty(controllers);
Assert.True(failures.Count == 0, "Controllers that cannot be created:\n" + string.Join("\n", failures));
}
// Lesson 85's fallback policy locks every endpoint that does not say [AllowAnonymous]. This test lists the
// ones that do say it. A new open endpoint fails the test until someone adds it here on purpose.
[Fact]
public void OnlyTheApprovedEndpoints_AreOpenWithoutSigningIn()
{
string[] open = Endpoints()
.Where(endpoint => endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null)
.Select(Describe)
.Select(name => name.StartsWith("GET scalar/") ? "GET scalar/*" : name) // Scalar's page and its files
.Distinct()
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(
[
"ANY health/live", // Lesson 95
"ANY health/ready", // Lesson 95
"GET api/school", // Lesson 66
"GET openapi/{documentName}.json", // Lesson 71, Development only
"GET scalar/*", // Lesson 71, Development only
"POST api/auth/login" // Lesson 85
], open);
}
// Lesson 93: removing or renaming a route breaks every app that calls it. The approved list of routes is a
// file in this folder. When the API's routes change, this test fails and writes api-surface.received.txt:
// compare the two files, and if the change is intended, copy the received file over the approved one.
[Fact]
public void TheApiSurface_MatchesTheApprovedList()
{
string[] routes = Endpoints()
.Where(endpoint => endpoint.Metadata.GetMetadata<ControllerActionDescriptor>() is not null)
.Select(Describe)
.Distinct()
.Order(StringComparer.Ordinal)
.ToArray();
string received = string.Join("\n", routes) + "\n";
string approvedPath = Path.Combine(ThisFolder(), "api-surface.approved.txt");
string receivedPath = Path.Combine(ThisFolder(), "api-surface.received.txt");
string approved = File.Exists(approvedPath) ? File.ReadAllText(approvedPath).Replace("\r\n", "\n") : "";
if (received != approved)
{
File.WriteAllText(receivedPath, received);
string[] approvedRoutes = approved.Split('\n', StringSplitOptions.RemoveEmptyEntries);
string removed = string.Join(", ", approvedRoutes.Except(routes));
string added = string.Join(", ", routes.Except(approvedRoutes));
Assert.Fail($"The API's routes changed. Removed: [{removed}]. Added: [{added}]. " +
$"If intended, copy {Path.GetFileName(receivedPath)} over {Path.GetFileName(approvedPath)}.");
}
File.Delete(receivedPath);
}
// The folder of this source file, so the approved list lives next to the test, not in bin/.
private static string ThisFolder([CallerFilePath] string path = "") => Path.GetDirectoryName(path)!;
}
Step 1: every controller can be created
ASP.NET Core creates a controller only when a request reaches it. The test creates every controller the same way
MVC does, with ActivatorUtilities, and collects the ones that fail.
To prove it, we removed one line from Program.cs: AddScoped<FeeReminderService>(). Here is what happened.
API started yes
GET /health/ready 200 {"status":"Healthy", ...}
POST /api/fee-reminders 500 "Something went wrong on our side. Please try again later."
WholeApiTests.EveryController_CanBeCreated FAILED
Controllers that cannot be created:
FeeRemindersController: Unable to resolve service for type 'SchoolApp.Core.Services.FeeReminderService'
while attempting to activate 'SchoolApp.Api.Controllers.FeeRemindersController'.
The API started, and even the health check said Healthy. Only this test found the problem before a user did, and its message names the exact missing service.
Step 2: only approved endpoints are open
Lesson 85's fallback policy locks every endpoint that does not say [AllowAnonymous]. The test lists the ones
that do and compares them with an approved list: health checks, school info, sign-in, and the development
documentation pages.
We added [AllowAnonymous] to the fee reminders controller by mistake:
WholeApiTests.OnlyTheApprovedEndpoints_AreOpenWithoutSigningIn FAILED
Expected: [···, "GET api/school", "GET openapi/{documentName}.json", "GET scalar/*", "POST api/auth/login"]
Actual: [···, "GET api/school", "GET openapi/{documentName}.json", "GET scalar/*", "POST api/auth/login", "POST api/fee-reminder"]
Opening an endpoint is sometimes right. The test does not forbid it; it makes it a decision someone writes down, in the test, where a reviewer sees it.
Step 3: an approved list of routes
The third test writes every controller route as one line, sorted, and compares the result with a file kept next to the test:
GET api/students/{admissionNumber}
GET api/v{version:apiVersion}/students/{admissionNumber} (v1.0)
GET api/v{version:apiVersion}/students/{admissionNumber} (v2.0)
POST api/fee-invoices/{invoiceNumber}/payments
POST api/fee-reminders
Our first version printed version 1 and version 2 of "get a student" as the same line. Removing version 1 would have gone unnoticed. So a route with a version in it now names its version too.
When we also renamed the route to api/fee-reminder, the test said exactly what changed:
WholeApiTests.TheApiSurface_MatchesTheApprovedList FAILED
The API's routes changed. Removed: [POST api/fee-reminders]. Added: [POST api/fee-reminder].
If intended, copy api-surface.received.txt over api-surface.approved.txt.
This is called approval testing. A failing run writes api-surface.received.txt next to the approved file.
If the change is intended, the developer copies it over the approved file. The pull request then shows the route
change line by line for review. The received file is listed in .gitignore, so it is never committed by accident.
Step 4: measure coverage
Code coverage is the share of code lines that ran during the tests. The test project has included the
coverlet.collector package since Lesson 63, so one option turns it on:
dotnet test --settings tests/coverage.runsettings --collect:"XPlat Code Coverage"
The report is a coverage.cobertura.xml file in TestResults. Our first run, without the settings file, said:
| Project | Lines covered |
|---|---|
| SchoolApp.Api | 58.0% |
| SchoolApp.Core | 68.3% |
| SchoolApp.Data | 57.9% |
| All | 60.4% (2,589 of 4,287) |
The API's figure looked low, and the reason was not our code. 425 of its lines were in a file the OpenAPI
package generates during the build, in obj/. Nobody writes tests for generated code.
<?xml version="1.0" encoding="utf-8"?>
<!-- Lesson 96: settings for the coverage report.
Use it with the settings option of dotnet test, together with the collect option "XPlat Code Coverage";
Lesson 96 shows the full command.
Code the compiler or a source generator writes (in obj/) is left out: nobody writes tests for it,
and counting it only hides the numbers for our own code. -->
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Format>cobertura</Format>
<ExcludeByFile>**/obj/**/*.cs</ExcludeByFile>
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
Our first settings file also left out CompilerGeneratedAttribute. The numbers jumped to 77.5%, which looked
great and was wrong.
The lines counted had fallen from 4,287 to 1,579. The compiler turns every async method into a generated
class, so that setting quietly dropped almost all of our async code. A coverage number you do not check can
mislead you.
Step 5: use the report to find gaps
The report lists coverage for every file. Sorted from lowest, a few lines stood out:
| File | Covered | Why |
|---|---|---|
EfAttendanceRepository.cs, other EF Core repositories | 0–32% | Module 9 code; the API switched to Dapper in Lesson 81 |
GuardiansController.cs (Lesson 77) | 19% | no API tests at all |
DapperPeopleRepository.cs | 4% | the same gap, one layer down |
DapperStudentQueries.cs (Lesson 72) | 7% | the student list was never tested through the API |
The EF Core rows are expected: the API no longer uses those classes. The guardian and student list rows are a real gap. These endpoints hold parents' phone numbers and are protected by the Lesson 87 record rules.
Show tests/SchoolApp.Tests/Api/PeopleEndpointTests.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SchoolApp.Api.Security;
using SchoolApp.Data;
namespace SchoolApp.Tests.Api;
// Lesson 96: the coverage report showed that the guardian endpoints (Lesson 77) and the student list (Lesson 72)
// had almost no API tests. These fill that gap: the answers, the paging, the validation, and the record rules.
[Collection("Api")]
public class PeopleEndpointTests
{
private static readonly SqlConnectionFactory Connections = SqlConnectionFactory.FromEnvironment();
private readonly ApiFactory _factory;
public PeopleEndpointTests(ApiFactory factory)
{
_factory = factory;
}
private static async Task<JsonElement> DataAsync(HttpResponseMessage response) =>
(await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
private static string[] Names(JsonElement list) =>
list.EnumerateArray().Select(item => item.GetProperty("fullName").GetString()!).ToArray();
[Fact]
public async Task StudentList_PagesThroughOneClass()
{
HttpClient client = await _factory.CreateSignedInClientAsync(Roles.Principal);
await using SqlConnection connection = Connections.Create();
int inClass10 = await connection.ExecuteScalarAsync<int>("""
SELECT COUNT(*) FROM dbo.Students AS s
WHERE s.IsDeleted = 0
AND EXISTS (SELECT 1 FROM dbo.Enrollments AS e
JOIN dbo.AcademicYears AS y ON y.Id = e.AcademicYearId AND y.IsCurrent = 1
JOIN dbo.Sections AS sec ON sec.Id = e.SectionId
WHERE e.StudentId = s.Id AND sec.ClassId = 10);
""");
JsonElement page = await DataAsync(await client.GetAsync("/api/students?classId=10&page=2&pageSize=5"));
Assert.Equal(5, page.GetProperty("items").GetArrayLength());
Assert.Equal((2, 5, inClass10), (page.GetProperty("page").GetInt32(), page.GetProperty("pageSize").GetInt32(),
page.GetProperty("totalCount").GetInt32()));
Assert.True(page.GetProperty("hasNextPage").GetBoolean());
}
[Theory]
[InlineData("/api/students?pageSize=101", "pageSize")] // more than Paging.MaxPageSize (100)
[InlineData("/api/students?classId=11", "classId")] // the school has Classes 1 to 10
public async Task StudentList_RefusesBadQueries(string url, string field)
{
HttpClient client = await _factory.CreateSignedInClientAsync(Roles.Principal);
HttpResponseMessage response = await client.GetAsync(url);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
JsonElement errors = (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("errors");
Assert.True(errors.TryGetProperty(field, out _));
}
[Fact]
public async Task StudentList_IsForStaffOnly()
{
HttpClient parent = await _factory.CreateSignedInClientAsync(Roles.Parent);
Assert.Equal(HttpStatusCode.Forbidden, (await parent.GetAsync("/api/students")).StatusCode);
}
[Fact]
public async Task GuardiansOfAStudent_PrimaryContactFirst()
{
HttpClient client = await _factory.CreateSignedInClientAsync(Roles.Principal);
JsonElement guardians = await DataAsync(await client.GetAsync("/api/students/ADM-2017-0001/guardians"));
Assert.Equal(["Rajesh Sharma", "Neha Sharma"], Names(guardians));
Assert.True(guardians[0].GetProperty("isPrimaryContact").GetBoolean());
}
[Theory]
[InlineData("/api/students/ADM-2017-0001/guardians", HttpStatusCode.OK)] // Neha's son Aarav
[InlineData("/api/students/ADM-2017-0003/guardians", HttpStatusCode.Forbidden)] // Kabir is not her child
public async Task GuardiansOfAStudent_ParentSeesOnlyTheirOwnChildren(string url, HttpStatusCode expected)
{
HttpClient parent = await _factory.CreateSignedInClientAsync(Roles.Parent);
Assert.Equal(expected, (await parent.GetAsync(url)).StatusCode);
}
[Fact]
public async Task ChildrenOfAGuardian_OldestFirst_AndUnknownGuardianIs404()
{
HttpClient client = await _factory.CreateSignedInClientAsync(Roles.Principal);
JsonElement children = await DataAsync(await client.GetAsync("/api/guardians/2/students"));
HttpResponseMessage unknown = await client.GetAsync("/api/guardians/99999/students");
Assert.Equal(["Aarav Sharma", "Advika Sharma"], Names(children));
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
}
[Fact]
public async Task AddGuardian_InvalidPhone_Is400_ValidOne_Is201()
{
HttpClient client = await _factory.CreateSignedInClientAsync(Roles.Principal);
var guardian = new
{
firstName = "Test", lastName = "Guardian", phone = "98765", email = (string?)null,
relationship = "Guardian", isPrimaryContact = false
};
HttpResponseMessage invalid = await client.PostAsJsonAsync("/api/students/ADM-2017-0003/guardians", guardian);
HttpResponseMessage valid = await client.PostAsJsonAsync("/api/students/ADM-2017-0003/guardians",
guardian with { phone = "9876500096" });
int? addedId = valid.IsSuccessStatusCode ? (await DataAsync(valid)).GetProperty("id").GetInt32() : null;
try
{
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
Assert.Equal(HttpStatusCode.Created, valid.StatusCode);
Assert.EndsWith($"/api/guardians/{addedId}/students", valid.Headers.Location?.ToString());
}
finally
{
await using SqlConnection connection = Connections.Create();
await connection.ExecuteAsync("""
DELETE FROM dbo.StudentGuardians WHERE GuardianId = @Id;
DELETE FROM dbo.Guardians WHERE Id = @Id;
""", new { Id = addedId ?? 0 });
}
}
}
The nine new tests check paging, bad queries, staff-only access, the primary contact order, the parent rule, a 404, and adding a guardian. All passed at once, so the code was right; it was just unproven.
| File | Before | After |
|---|---|---|
GuardiansController.cs | 19% | 85% |
DapperPeopleRepository.cs | 4% | 67% |
DapperStudentQueries.cs | 7% | 64% |
| SchoolApp.Api | 58.0% | 82.7% |
| All | 60.4% | 70.0% |
The suite now has 180 tests, all passing, and SchoolDb is back to its sample data after every run.
Example: what coverage does not tell you
A covered line is a line that ran, not a line that was checked. A test with no Assert can cover a whole
controller and prove nothing.
So use coverage to find code that no test touches, as we did. Do not chase 100%: the last few percent are usually error branches that cost more to test than they protect. Ask instead: "if this broke, would a test fail?"
Example: running the tests on every change
Teams usually run the whole suite automatically on every pull request, with a CI service such as GitHub Actions or Azure Pipelines. A failing test then blocks the merge, not just the developer's own run.
The SMS tests need a SQL Server with SchoolDb for the integration and API layers. In CI, that is usually a SQL
Server container started for the run, with the scripts in database/ run in order, as run-all.cmd does.
Common mistakes
- One test per endpoint, written by hand. The newest endpoint is the one without a test. Fix: tests that look at every endpoint, so new ones are covered automatically.
- Trusting a coverage number without checking it. Our 77.5% left out all async code. Fix: check how many lines were counted, and exclude only generated files.
- Chasing 100% coverage. Time goes into trivial lines. Fix: use coverage to find untested features.
- Tests that only run on one machine. Nobody else runs them. Fix: the same command for everyone, and CI on every change.
- Tests that leave data behind. The next run fails for no clear reason. Fix: clean up in
finally, as every SMS test does.
In real projects
A team tidied up their routes and renamed /api/fee-invoice to /api/fee-invoices, to match the other plural
names. Every test passed, because the tests had been updated in the same change.
The mobile app, released months earlier, still called the old address. Fee screens on parents' phones showed an error for a week until an app update went out.
We added an approved route list like this lesson's. The next rename made the test fail, the change showed up in review, and the team kept the old route alongside the new one until the app had moved.
Best practices
- Keep fast unit tests at the bottom and fewer, slower API tests at the top.
- Add whole-API tests: every controller, every open endpoint, every route.
- Make changes to open endpoints and routes a written, reviewed decision.
- Measure coverage to find gaps, and check that the numbers make sense.
- Run every test on every change, and clean up the data each test creates.
Recap
- The SMS has 55 unit, 37 integration, 82 API, and 6 library tests: 180 in all.
EveryController_CanBeCreatedcaught a missing registration that the app, its health check, and every other test missed.- The open-endpoint list and the approved route list turn security and breaking changes into reviewed decisions.
- Coverage rose from 60.4% to 70.0% after excluding generated code and testing the guardian and student list endpoints.
- Module 12 is complete: the SMS API signs users in, checks permissions, audits changes, logs, caches, limits, versions, works in the background, reports its health, and is tested as a whole.
Homework
Library module task: Write a single-file check for a small library API that runs it in memory with
TestServer. The API has GET /books, GET /books/{isbn}, POST /loans, POST /loans/{id}/return, and
GET /health; every request except /health needs the header X-Library-Key.
- Compare the routes with an approved list in the file.
- Call every endpoint without the key:
/healthmust answer 200, everything else 401. - Print PASS or FAIL for each check, and exit with 0 when all pass, otherwise 1.
A correct result looks like:
PASS routes match the approved list
PASS GET /health without a key 200, expected 200
PASS GET /books without a key 401, expected 401
PASS GET /books/9780143039655 without a key 401, expected 401
PASS POST /loans without a key 401, expected 401
PASS POST /loans/1/return without a key 401, expected 401
All checks passed.
Show the solution
// Library homework, Lesson 96: whole-API checks for a small library API, run in memory with TestServer.
// 1. The API's routes must match the approved list (a removed or renamed route is a breaking change).
// 2. Every endpoint except /health must refuse a request without the library key (401).
// The file starts the API in memory, runs both checks, prints PASS or FAIL for each, and exits with 0 or 1,
// so a build script can stop a release when a check fails.
// Run with: dotnet run library-assignment/096-library-api-checks.cs
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.AspNetCore.TestHost@10.0.11
#:property PublishAot=false
using Microsoft.AspNetCore.TestHost;
string[] approvedRoutes =
[
"GET books",
"GET books/{isbn}",
"GET health",
"POST loans",
"POST loans/{id:int}/return"
];
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseTestServer(); // in memory: no port, no network
builder.Logging.ClearProviders();
WebApplication app = builder.Build();
// Every request needs X-Library-Key, except the health check.
app.Use(async (context, next) =>
{
if (context.Request.Path != "/health" && context.Request.Headers["X-Library-Key"] != "library-key-1")
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
await next(context);
});
app.MapGet("/health", () => "Healthy");
app.MapGet("/books", () => new[] { "Malgudi Days", "Wings of Fire" });
app.MapGet("/books/{isbn}", (string isbn) => Results.Ok(new { isbn, title = "Malgudi Days" }));
app.MapPost("/loans", () => Results.Created("/loans/1", new { id = 1 }));
app.MapPost("/loans/{id:int}/return", (int id) => Results.Ok(new { id, returned = true }));
await app.StartAsync();
HttpClient client = app.GetTestClient();
List<RouteEndpoint> endpoints = app.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>().ToList();
bool allPassed = true;
void Report(string check, bool passed, string detail)
{
Console.WriteLine($"{(passed ? "PASS" : "FAIL")} {check}{(detail.Length > 0 ? " " + detail : "")}");
allPassed &= passed;
}
// Check 1: the routes.
string[] routes = endpoints
.Select(e => $"{e.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods[0]} {e.RoutePattern.RawText?.TrimStart('/')}")
.Order(StringComparer.Ordinal)
.ToArray();
string removed = string.Join(", ", approvedRoutes.Except(routes));
string added = string.Join(", ", routes.Except(approvedRoutes));
Report("routes match the approved list", removed.Length == 0 && added.Length == 0,
removed.Length + added.Length > 0 ? $"removed: [{removed}] added: [{added}]" : "");
// Check 2: every endpoint without the key. Route values are filled with sample values.
foreach (RouteEndpoint endpoint in endpoints)
{
string method = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()!.HttpMethods[0];
string path = "/" + endpoint.RoutePattern.RawText!.TrimStart('/').Replace("{isbn}", "9780143039655").Replace("{id:int}", "1");
HttpResponseMessage response = await client.SendAsync(new HttpRequestMessage(new HttpMethod(method), path));
int expected = path == "/health" ? 200 : 401;
Report($"{method} {path} without a key", (int)response.StatusCode == expected, $"{(int)response.StatusCode}, expected {expected}");
}
await app.StopAsync();
Console.WriteLine(allPassed ? "All checks passed." : "Some checks failed.");
return allPassed ? 0 : 1;
We tested the checks themselves on a broken copy that left /books open and renamed return to give-back:
FAIL routes match the approved list removed: [POST loans/{id:int}/return] added: [POST loans/{id:int}/give-back]
FAIL GET /books without a key 200, expected 401
Some checks failed. (exit code 1)
Quiz
FeeReminderService was not registered. What noticed it first?
Show answer
Answer: C. EveryController_CanBeCreated The API started and the health check said Healthy; only the whole-API test caught it.
What does an approved route list protect against?
Show answer
Answer: B. A route removed or renamed by accident, which would break installed apps An intended change means updating the approved file, which a reviewer sees.
What does 70% line coverage mean?
Show answer
Answer: B. 70% of the lines ran during the tests Ran, not checked: a test without asserts still covers lines.
Why did excluding CompilerGeneratedAttribute make coverage look better but wrong?
Show answer
Answer: B. Async methods become compiler-generated classes, so most async code was no longer counted Lines counted fell from 4,287 to 1,579.
Why are there many more unit tests than API tests?
Show answer
Answer: B. Unit tests are fast and pinpoint failures; API tests are slower but check the whole pipeline 55 unit tests take 0.2 s; 82 API tests take 10 s.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.
Fresher (0-2 yrs)
F What is the difference between unit tests, integration tests, and API tests? concept
Unit tests check one class with fakes instead of a database, and run in milliseconds. Integration tests check code together with a real dependency, such as SQL that runs against SchoolDb. API tests run the whole application in memory (WebApplicationFactory) and send real HTTP requests through routing, security, validation, and the database.
F What is the test pyramid? concept
A guideline for the shape of a test suite: many fast unit tests at the bottom, fewer integration tests in the middle, and fewer still slow end-to-end or API tests at the top. Fast tests give quick feedback and pinpoint failures; the slower ones prove the parts work together.
F What does code coverage measure, and what does it not tell you? concept
The share of code lines (or branches) that ran during the tests. It does not say the lines were checked: a test without asserts still covers code. Use it to find code no test touches, not as proof of quality.
Mid (3-5 yrs)
M How can a test catch a missing dependency injection registration for any controller? coding
Find every controller type in the API assembly and create each one in a DI scope with ActivatorUtilities.CreateInstance, the way MVC does. Collect the exceptions and fail with their messages. It catches the mistake even though the app starts normally, because controllers are only created when a request reaches them.
M How would you test that no endpoint is accidentally open to anonymous users? coding
Read the endpoints from EndpointDataSource in a WebApplicationFactory, select those with IAllowAnonymous metadata, and compare them with an approved list in the test. With a fallback policy that requires sign-in, every other endpoint is locked; a newly opened one fails the test until someone approves it.
M Your coverage jumped from 60% to 77% after a settings change, with no new tests. What do you check? scenario
The number of lines counted. Excluding CompilerGeneratedAttribute, for example, removes every async method's generated state machine, so most async code disappears from the report. Exclude only truly generated files (such as obj/), and treat any change in coverage without a change in tests as suspicious.
Senior (6-9 yrs)
S What is approval testing, and how can it protect a public API? design
The test produces output (here, the sorted list of routes with versions) and compares it with an approved file in source control. Any difference fails the test and writes a received file; if the change is intended, the developer replaces the approved file and the change appears in code review. It stops routes being removed or renamed by accident, which would break installed clients.
How to answer: Define the technique, give a concrete use, and explain how it turns accidental changes into reviewed ones.
S API tests share one real database. How do you keep them reliable? design
Each test creates its own data and removes it in finally, or works on data no other test changes; tests that touch the same rows share an xUnit collection so they never run at the same time. Avoid depending on today's date (inject TimeProvider) and on counts that other tests change. Alternatives are a transaction rolled back per test, a tool that resets tables (such as Respawn), or a fresh database container per run.
How to answer: Cover isolation, cleanup, parallel runs, time, and the main reset strategies with their trade-offs.
Architect / Lead (10+ yrs)
A Design a testing strategy for a product team of 40 developers working on one large API. design
A pyramid with clear ownership: fast unit tests on every save, integration and API tests in CI on every pull request against containerised dependencies, and contract tests (approved routes, OpenAPI diffs, consumer contracts) to protect clients. Add whole-API safety nets for security and DI, coverage reports per pull request to spot untested changes, flaky-test tracking with quarantine, time budgets for the suite, and smoke tests after each deployment.
How to answer: Cover layers and speed, CI gates, contracts with clients, security nets, coverage use, flaky tests, and post-deployment checks.
Resources
- Code for this lesson: git tag
lesson-96-api-tests(code repository coming soon) - Files:
tests/SchoolApp.Tests/Api/WholeApiTests.cs,tests/SchoolApp.Tests/Api/api-surface.approved.txt,tests/SchoolApp.Tests/Api/PeopleEndpointTests.cs,tests/SchoolApp.Tests/Api/ApiFactory.cs,tests/coverage.runsettings,library-assignment/096-library-api-checks.cs - Video: coming soon
What's next
Module 12 is complete, and with it the backend of the School Management System: database, C#, and a secure, tested Web API. Next, Module 13 looks at the SMS as a product: system design, and preparing for interviews. Lesson 97: From one school to 1,000
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.