Skip to main content

Tests for the Whole API: Safety Nets and Coverage – Web API | Lesson 96

Published / updated

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​

LayerFolderTestsTimeWhat they check
UnitUnit/550.2 sservices, rules, jobs; fakes instead of a database (Lessons 63–95)
IntegrationIntegration/373 srepositories and SQL against the real SchoolDb
APIApi/8210 sthe whole API in memory: routes, security, responses, background work
LibraryLibrary/6the 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​

Terminal
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
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.

Output
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:

Output
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:

tests/SchoolApp.Tests/Api/api-surface.approved.txt (a few of its 47 lines)
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:

Output
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:

Terminal
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:

ProjectLines covered
SchoolApp.Api58.0%
SchoolApp.Core68.3%
SchoolApp.Data57.9%
All60.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.

tests/coverage.runsettings
<?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:

FileCoveredWhy
EfAttendanceRepository.cs, other EF Core repositories0–32%Module 9 code; the API switched to Dapper in Lesson 81
GuardiansController.cs (Lesson 77)19%no API tests at all
DapperPeopleRepository.cs4%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
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.

FileBeforeAfter
GuardiansController.cs19%85%
DapperPeopleRepository.cs4%67%
DapperStudentQueries.cs7%64%
SchoolApp.Api58.0%82.7%
All60.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​

  1. 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.
  2. 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.
  3. Chasing 100% coverage. Time goes into trivial lines. Fix: use coverage to find untested features.
  4. Tests that only run on one machine. Nobody else runs them. Fix: the same command for everyone, and CI on every change.
  5. 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​

From 16 years of .NET 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_CanBeCreated caught 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.

  1. Compare the routes with an approved list in the file.
  2. Call every endpoint without the key: /health must answer 200, everything else 401.
  3. Print PASS or FAIL for each check, and exit with 0 when all pass, otherwise 1.

A correct result looks like:

Output
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-assignment/096-library-api-checks.cs
// 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:

Output
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​

  1. FeeReminderService was not registered. What noticed it first?

    1. The API refused to start
    2. The health check
    3. EveryController_CanBeCreated
    4. Nothing until a user pressed the button
    Show answer

    Answer: C. EveryController_CanBeCreated The API started and the health check said Healthy; only the whole-API test caught it.

  2. What does an approved route list protect against?

    1. Slow queries
    2. A route removed or renamed by accident, which would break installed apps
    3. SQL injection
    4. Missing tests
    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.

  3. What does 70% line coverage mean?

    1. 70% of the code is correct
    2. 70% of the lines ran during the tests
    3. 70% of tests pass
    4. 70% of endpoints have tests
    Show answer

    Answer: B. 70% of the lines ran during the tests Ran, not checked: a test without asserts still covers lines.

  4. Why did excluding CompilerGeneratedAttribute make coverage look better but wrong?

    1. It is a bug in coverlet
    2. Async methods become compiler-generated classes, so most async code was no longer counted
    3. It removed the tests
    4. It counted lines twice
    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.

  5. Why are there many more unit tests than API tests?

    1. API tests are not useful
    2. Unit tests are fast and pinpoint failures; API tests are slower but check the whole pipeline
    3. Unit tests are required by .NET
    4. API tests cannot use a database
    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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.