Skip to main content
Published / updated

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 ruleWhat happens if it is lost
An absent student is not a zeroEvery class average is wrong, silently
SchoolId comes from the token, never the requestOne school reads another school's children
Money is decimal, never doubleTotals drift by paise for a year
A payment and its balance update are one transactionA 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​

  1. Right-click the solution → Add → New Project.
  2. Search xUnit Test Project, pick the C# one. Name it Api.Tests. Create.
  3. 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:

OptionGoodBad
A separate test database, rebuilt from your Track 06 scriptReal SQL Server, real constraints, real behaviourSlower, and you must reset it between runs
SQLite in-memory, swapped in by the factoryFast, isolated per testNot SQL Server — DECIMAL and date behaviour differ
Your development databaseNothingTests 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 testWhy
That a property returns what you set it toYou are testing the C# compiler
That Dapper maps a columnYou are testing Dapper
That [Required] rejects nullYou are testing ASP.NET Core — test your validation rule instead
Every branch, to reach a coverage numberCoverage measures lines run, not rules checked
Private methodsTest 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​

ErrorCauseFix
CS0122: 'Program' is inaccessible due to its protection levelThe public partial class Program line is missingAdd it at the bottom of Program.cs
CS0234: The type or namespace name 'Xunit' does not existWrong project template, or packages not restoredCreate an xUnit Test Project; Build → Rebuild Solution
InvalidOperationException: Can't find 'appsettings.json'The factory looks in the test project's output folderSet the API project's appsettings.json to Copy if newer, or set the content root in the factory
Every test returns 401The test token is signed with a different key than the API validates withOne key, one issuer, one audience, read from the same test configuration
Tests pass alone, fail togetherThey share database rows and run in any orderRebuild the test database per run, or give each test its own SchoolId
Assert.Equal() Failure: Expected: 70, Actual: 46.666Good. Someone removed the absent checkThe 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​

  1. Add the Api.Tests project and the two packages. Make Program public.
  2. Write FakeExamResultRepository by hand. No mocking library.
  3. Write the absent-student unit test. Run it green.
  4. Delete the absent check from the service. Confirm it goes red. Put it back.
  5. Write the all-absent test, and watch it throw before you add the count guard.
  6. Add the 401-without-token integration test.
  7. Add the cross-school 404 test. Remove the WHERE SchoolId clause, confirm red, restore it.
  8. Write a test for the payment endpoint asserting that a failure between the two writes leaves the balance unchanged.
  9. 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 Program reachable
  • 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​

  1. What can an integration test catch that a unit test cannot, and what is the cost of that?
  2. Why is a test that has never been seen to fail worth close to nothing?
  3. Why does a cross-school request return 404 rather than 403?
  4. Which of the four silent failures would survive a suite of tests that only assert 200 responses?

Next: Guided API project