Testing the API
Before you start
You need: Articles 01–11. You have a service, a Dapper repository and a secured controller.
In Visual Studio: the solution from Article 09, with Api, Core and Data projects.
Time: 3–4 hours.
Learning objective
Write two kinds of test — a fast unit test over a service and a full integration test over a running API — and know which failures each one can and cannot catch.
Topics
- Why the four silent failures need tests, not care
- The test project and xUnit
- Unit-testing a service with a hand-written fake
- Integration tests with
WebApplicationFactory - Testing an endpoint that requires a token
- The tenant-isolation test that must fail when the rule is removed
- What not to test
Why test at all
You wrote a unit test in Track 03 Article 09. This article is that idea applied to an API, and the reason is the one this whole programme keeps returning to.
Recall the four failures that produce no error message:
| The rule | What happens if it is lost |
|---|---|
| An absent student is not a zero | Every class average is wrong, silently |
SchoolId comes from the token, never the request | One school reads another school's children |
Money is decimal, never double | Totals drift by paise for a year |
| A payment and its balance update are one transaction | A receipt exists and the balance is wrong |
None of these crash. No exception, no log line, no red screen. They cannot be caught by running the application and clicking around, because the application looks fine.
A test is the only thing that notices. And the test that matters is not "does this endpoint return 200" — it is "does this fail if someone deletes the rule?" That question is the whole design of this article.
This is also the round-three interview question. "How do you know your code is correct?" A candidate who says "I tested it manually" is one of forty. A candidate who says "I have an integration test that fails if the tenant filter is removed — I proved it by removing the filter" is not.
The test project
- Right-click the solution → Add → New Project.
- Search xUnit Test Project, pick the C# one. Name it
Api.Tests. Create. - Right-click
Api.Tests→ Add → Project Reference → tick Api, Core and Data.
Two packages, added through Manage NuGet Packages on Api.Tests:
Microsoft.AspNetCore.Mvc.Testing
Microsoft.Data.Sqlite
The first gives you WebApplicationFactory. The second is only needed if you follow the optional in-memory database note near the end.
Make Program visible to the test project. A minimal-hosting Program class is internal, so the test project cannot name it. Add this at the very bottom of Program.cs:
// Lets the test project reference Program in WebApplicationFactory<Program>.
public partial class Program
{
}
Without it you get CS0122: 'Program' is inaccessible due to its protection level, which is a confusing first error because the file is plainly there.
Unit-testing a service
A unit test exercises one class with everything around it replaced. It is fast — milliseconds — because there is no database and no HTTP.
Here is the service under test. It calculates a class average, and the absent rule lives inside it:
public class ResultService
{
private readonly IExamResultRepository _repository;
public ResultService(IExamResultRepository repository)
{
_repository = repository;
}
public decimal GetClassAverage(int schoolId, int examId)
{
List<ExamResult> results = _repository.GetByExam(schoolId, examId);
List<ExamResult> present = new List<ExamResult>();
foreach (ExamResult result in results)
{
if (result.IsAbsent == false)
{
present.Add(result);
}
}
if (present.Count == 0)
{
return 0m;
}
decimal total = 0m;
foreach (ExamResult result in present)
{
total = total + result.MarksObtained;
}
return total / present.Count;
}
}
To test it you need an IExamResultRepository that does not touch SQL Server. Write one by hand in the test project:
// A fake repository. It returns whatever the test hands it,
// so the test controls the data completely.
public class FakeExamResultRepository : IExamResultRepository
{
private readonly List<ExamResult> _results;
public FakeExamResultRepository(List<ExamResult> results)
{
_results = results;
}
public List<ExamResult> GetByExam(int schoolId, int examId)
{
List<ExamResult> matching = new List<ExamResult>();
foreach (ExamResult result in _results)
{
if (result.SchoolId == schoolId && result.ExamId == examId)
{
matching.Add(result);
}
}
return matching;
}
}
Write the fake by hand rather than reaching for Moq. You can read every line of this one, and a beginner who starts with a mocking library learns the library instead of the idea. Add Moq later, when hand-writing fakes becomes tedious — which is the correct reason to adopt any tool.
Now the tests:
public class ResultServiceTests
{
[Fact]
public void GetClassAverage_ExcludesAbsentStudents()
{
// Arrange — Ravi 80, Priya 60, Arjun absent.
List<ExamResult> results = new List<ExamResult>
{
new ExamResult { SchoolId = 1, ExamId = 5, MarksObtained = 80m, IsAbsent = false },
new ExamResult { SchoolId = 1, ExamId = 5, MarksObtained = 60m, IsAbsent = false },
new ExamResult { SchoolId = 1, ExamId = 5, MarksObtained = 0m, IsAbsent = true },
};
FakeExamResultRepository repository = new FakeExamResultRepository(results);
ResultService service = new ResultService(repository);
// Act
decimal average = service.GetClassAverage(1, 5);
// Assert — 70, not 46.67. Arjun is not a zero.
Assert.Equal(70m, average);
}
[Fact]
public void GetClassAverage_EveryoneAbsent_ReturnsZeroAndDoesNotThrow()
{
List<ExamResult> results = new List<ExamResult>
{
new ExamResult { SchoolId = 1, ExamId = 5, MarksObtained = 0m, IsAbsent = true },
};
FakeExamResultRepository repository = new FakeExamResultRepository(results);
ResultService service = new ResultService(repository);
decimal average = service.GetClassAverage(1, 5);
Assert.Equal(0m, average);
}
}
The first test is the whole point. Delete if (result.IsAbsent == false) from the service and it fails with Assert.Equal() Failure: Expected: 70, Actual: 46.666.... That is a test earning its keep — it converts a silent failure into a loud one.
The second test exists because dividing by present.Count when the count is zero throws DivideByZeroException. Every class was absent on the day of a strike. It happens.
Run both with Test Explorer (Ctrl+R, A).
Integration tests
A unit test cannot tell you whether routing works, whether [Authorize] is applied, whether model validation runs, or whether the middleware order from Article 02 is right. Those live in the pipeline, and testing them needs the pipeline.
WebApplicationFactory starts your real application in memory and hands you an HttpClient pointed at it. No port, no dotnet run, no browser.
public class StudentEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public StudentEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task GetStudents_WithoutToken_Returns401()
{
HttpClient client = _factory.CreateClient();
HttpResponseMessage response = await client.GetAsync("/api/students");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task CreateStudent_WithBlankName_Returns400()
{
HttpClient client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokens.ForSchool(1, "Admin"));
StudentRequest request = new StudentRequest();
request.Name = "";
request.RollNumber = "NCA-2024-0001";
HttpResponseMessage response = await client.PostAsJsonAsync("/api/students", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
IClassFixture starts the application once for the whole class rather than once per test. Starting it per test works and is slow enough to notice by the twentieth test.
The 401 test is more valuable than it looks. It fails the day someone removes [Authorize] while debugging and forgets to put it back — which is a thing that happens, and which no amount of clicking around would reveal, because a developer testing by hand is always logged in.
The token
Your integration tests need a valid token. Write a small helper in the test project that signs one with the same key your appsettings.Development.json uses — the issuing code is in Article 11:
public static class TestTokens
{
public static string ForSchool(int schoolId, string role)
{
List<Claim> claims = new List<Claim>
{
new Claim("schoolId", schoolId.ToString()),
new Claim(ClaimTypes.Role, role),
};
// Same key, issuer and audience as the test configuration.
// Reuse the token-building method from Article 11 rather than
// duplicating it here.
return TokenBuilder.Create(claims);
}
}
Never point a test at your real signing key from appsettings.json, and never commit one. Article 03 covers configuration per environment; tests are an environment.
The test that must fail
This is the one to write first and the one to talk about in an interview.
[Fact]
public async Task GetStudent_FromAnotherSchool_Returns404()
{
HttpClient client = _factory.CreateClient();
// A token for school 1.
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokens.ForSchool(1, "Admin"));
// Student 4001 belongs to school 2. Seeded by the test database script.
HttpResponseMessage response = await client.GetAsync("/api/students/4001");
// 404, not 403. School 1 must not learn that this student exists.
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
Then prove the test works. Open the repository, remove the WHERE SchoolId = @SchoolId clause, and run it again. It must go red. If it stays green the test is decoration — it is passing for some reason unrelated to the rule, and you have proof of nothing.
Put the clause back.
404 and not 403 is deliberate. A 403 tells the caller the record exists and they may not have it, which is itself a leak: school 1 now knows student 4001 is real. Article 11 covers the distinction; this test is what stops it regressing.
Where the data comes from
Integration tests need a database. Three options, in order of how much you should prefer them as a fresher:
| Option | Good | Bad |
|---|---|---|
| A separate test database, rebuilt from your Track 06 script | Real SQL Server, real constraints, real behaviour | Slower, and you must reset it between runs |
| SQLite in-memory, swapped in by the factory | Fast, isolated per test | Not SQL Server — DECIMAL and date behaviour differ |
| Your development database | Nothing | Tests delete your data. Do not |
Start with the separate test database. Your Track 06 script already drops and rebuilds; call it in a fixture before the test run. Fidelity matters more than speed at this scale, and "the test passed on SQLite and failed on SQL Server" is a bad afternoon.
What not to test
Beginners overshoot here and then abandon testing because it feels like busywork. It was busywork.
| Do not test | Why |
|---|---|
| That a property returns what you set it to | You are testing the C# compiler |
| That Dapper maps a column | You are testing Dapper |
That [Required] rejects null | You are testing ASP.NET Core — test your validation rule instead |
| Every branch, to reach a coverage number | Coverage measures lines run, not rules checked |
| Private methods | Test them through the public method that calls them |
Test your rules, not the framework's. The absent rule, the tenant rule, the transaction rule, the money rule. Four rules, perhaps a dozen tests, and they are the dozen you can talk about.
Errors you will hit
| Error | Cause | Fix |
|---|---|---|
CS0122: 'Program' is inaccessible due to its protection level | The public partial class Program line is missing | Add it at the bottom of Program.cs |
CS0234: The type or namespace name 'Xunit' does not exist | Wrong project template, or packages not restored | Create an xUnit Test Project; Build → Rebuild Solution |
InvalidOperationException: Can't find 'appsettings.json' | The factory looks in the test project's output folder | Set the API project's appsettings.json to Copy if newer, or set the content root in the factory |
| Every test returns 401 | The test token is signed with a different key than the API validates with | One key, one issuer, one audience, read from the same test configuration |
| Tests pass alone, fail together | They share database rows and run in any order | Rebuild the test database per run, or give each test its own SchoolId |
Assert.Equal() Failure: Expected: 70, Actual: 46.666 | Good. Someone removed the absent check | The test is doing its job |
Common mistakes
- Writing a test that would still pass with the rule deleted
- Testing the framework instead of your own rules
- Chasing a coverage percentage instead of covering the four rules
- Pointing tests at the development database and losing data
- Starting the application per test instead of per class, then concluding tests are slow
- Committing a real signing key so that tests can pass
- Asserting only the happy path — no empty list, no all-absent class, no unknown id
- Leaving the tenant filter removed after proving the test fails
Practice
- Add the
Api.Testsproject and the two packages. MakeProgrampublic. - Write
FakeExamResultRepositoryby hand. No mocking library. - Write the absent-student unit test. Run it green.
- Delete the absent check from the service. Confirm it goes red. Put it back.
- Write the all-absent test, and watch it throw before you add the count guard.
- Add the 401-without-token integration test.
- Add the cross-school 404 test. Remove the
WHERE SchoolIdclause, confirm red, restore it. - Write a test for the payment endpoint asserting that a failure between the two writes leaves the balance unchanged.
- Count your tests. If any of them would pass with its rule deleted, delete the test.
You can now
- Create an xUnit project against an ASP.NET Core API and make
Programreachable - Write a hand-rolled fake repository and explain why you did not start with Moq
- Unit-test a service rule, including the empty and all-absent cases
- Start the real pipeline in memory with
WebApplicationFactory - Authenticate an integration test with a signed test token
- Prove a test works by removing the rule it guards
- Say why a cross-tenant read returns 404 and not 403
- Decide what is not worth testing, and defend it
Review questions
- What can an integration test catch that a unit test cannot, and what is the cost of that?
- Why is a test that has never been seen to fail worth close to nothing?
- Why does a cross-school request return 404 rather than 403?
- Which of the four silent failures would survive a suite of tests that only assert 200 responses?
Next: Guided API project