Backend Development with .NET
Session 22
Unit & Integration Testing
Eng. Seif Mansour  ·  Andalusia Academy
Week 9  ·  2.5 hours
Session Goals

By the end of this session, you will be able to:

  • Write unit tests for a service class using xUnit and Moq
  • Spin up the real HTTP pipeline in a test with WebApplicationFactory
  • Swap the production database for an in-memory one inside the test host
  • Test authenticated endpoints by generating real JWTs in tests
  • Decide what deserves a test — and what is a waste of your time
Agenda
Time Segment Type Duration
0:00Why testing matters — the pyramidTheory15 min
0:15xUnit setup & first unit testDemo25 min
0:40Mocking with MoqDemo25 min
1:05Break10 min
1:15Integration tests with WebApplicationFactoryDemo30 min
1:45Testing auth-protected endpointsDemo15 min
2:00Lab — write tests for TaskServiceLab30 min
Why Testing Matters
You already test your API — by hand, in Postman, one endpoint at a time. Automated tests do the same work, in two seconds, forever.
In this section
What automated tests actually buy you
The testing pyramid
Unit vs integration vs end-to-end
What makes a test good
What Automated Tests Actually Buy You
  • A regression net. You change the pagination code in Session 12 and instantly learn that filtering broke
  • Executable documentation. A test named Delete_WhenCallerIsNotOwner_ThrowsForbidden states a business rule better than a comment
  • Design pressure. Code that is hard to test is usually code with too many responsibilities
  • Speed. Re-running 200 assertions takes less time than clicking through Postman once
  • Confidence to refactor. Without tests, working code is code nobody dares to touch
The honest trade-off
Tests are code you also have to maintain. That is why what you test matters as much as that you test — we come back to this at the end of the session.
The Testing Pyramid
Unit Integration E2E Unit tests One class in isolation, every dependency mocked Milliseconds each · hundreds of them · run on every save Integration tests A vertical slice: HTTP request in, database query out Hundreds of milliseconds · dozens of them · run on every push End-to-end tests The whole deployed system, real browser, real network Seconds or minutes · a handful · run nightly
Aim for many unit tests, some integration tests, minimal end-to-end.
The Three Layers Compared
Property Unit Integration End-to-end
ScopeOne classOne request pathWhole system
DependenciesAll mockedReal, but in-memoryAll real
Typical duration< 5 ms50–500 msSeconds to minutes
Failure tells youExactly which method brokeWhich layer brokeSomething broke
Breaks when you refactorOftenRarelyAlmost never
Count in this courseManySomeNone
The inverted pyramid
Teams that write mostly end-to-end tests end up with a suite that takes 40 minutes, fails randomly, and gets disabled. Slow, flaky tests are worse than no tests — nobody trusts a red build that is red every day.
What Makes a Test Worth Keeping
Fast
A suite you run only before a release is a suite that finds bugs a week late. Unit tests must never touch disk, network, or a clock you cannot control.
Isolated
Any test must pass alone and in any order. If test B only passes after test A ran, you have shared state to remove.
Deterministic
Same input, same result, every run. DateTime.Now, Guid.NewGuid(), and Random are the usual culprits behind flaky tests.
Focused
One behaviour per test. When it fails, the test name alone should tell you what is broken without opening the file.
Readable
A test is documentation. Arrange–Act–Assert with blank lines between the three parts costs nothing and reads instantly.
Behaviour-driven
Assert what the class promises, not how it does it. Tests bound to private details break on every refactor.
xUnit & Your First Unit Test
The default test framework for .NET — a test project, an attribute, and three lines of Arrange, Act, Assert.
In this section
Creating the test project
Arrange – Act – Assert
[Fact] and [Theory]
FluentAssertions
Naming and running tests
Creating the Test Project
terminal — solution root
dotnet new xunit -n TaskManagerApi.Tests

dotnet sln add TaskManagerApi.Tests/TaskManagerApi.Tests.csproj

dotnet add TaskManagerApi.Tests reference TaskManagerApi.Api
dotnet add TaskManagerApi.Tests package Moq
dotnet add TaskManagerApi.Tests package FluentAssertions
dotnet add TaskManagerApi.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add TaskManagerApi.Tests package Microsoft.EntityFrameworkCore.InMemory
The reference points one way only: tests know about the API, the API never knows about the tests.
Naming convention
Name the test project after the project it covers, with a .Tests suffix. Mirror the folder structure too: Services/TaskServiceTests.cs tests Services/TaskService.cs.
What the Test Project Looks Like
TaskManagerApi.Tests/TaskManagerApi.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
    <PackageReference Include="xunit" Version="2.9.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
    <PackageReference Include="Moq" Version="4.20.72" />
    <PackageReference Include="FluentAssertions" Version="6.12.1" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\TaskManagerApi.Api\TaskManagerApi.Api.csproj" />
  </ItemGroup>

</Project>
IsPackable=false keeps the test project out of any NuGet package you publish later.
Arrange – Act – Assert
1 · ARRANGE Build the inputs, configure the mocks, create the SUT 2 · ACT Call exactly one method on the system under test 3 · ASSERT Check the returned value, the exception, or the call var task = new TaskItem { Id = 1 }; var result = _sut.GetById(1); result.Should().NotBeNull(); One Act per test. If you need two, you need two tests.
[Fact] and [Theory]
  • [Fact] — a test with no parameters. One scenario, one assertion set
  • [Theory] — the same test body run once per data row
  • [InlineData] supplies compile-time constants as arguments
  • Each row is reported as a separate test in the runner output
  • Use a theory when only the values change; use separate facts when the behaviour changes
[Fact]
public void Add_TwoPositiveNumbers_ReturnsSum()
{
    var result = Calculator.Add(2, 3);

    result.Should().Be(5);
}

[Theory]
[InlineData("", false)]
[InlineData("   ", false)]
[InlineData("Write the report", true)]
public void IsValidTitle_ReturnsExpected(
    string title, bool expected)
{
    TaskValidator.IsValidTitle(title)
                 .Should().Be(expected);
}
Your First Unit Test, Line by Line
Services/TaskValidatorTests.cs
using FluentAssertions;
using TaskManagerApi.Api.Validation;
using Xunit;

namespace TaskManagerApi.Tests.Validation;

public class TaskValidatorTests
{
    [Fact]
    public void IsValidTitle_WhenTitleIsWhitespaceOnly_ReturnsFalse()
    {
        // Arrange
        var title = "   ";

        // Act
        var result = TaskValidator.IsValidTitle(title);

        // Assert
        result.Should().BeFalse();
    }
}
The class is public, the method is public void, and the attribute is what makes the runner find it.
FluentAssertions — Better Failure Messages
  • xUnit's built-in Assert works fine — FluentAssertions just reads better and fails louder
  • Failure text names the variable: "Expected result to be 5, but found 4"
  • BeEquivalentTo compares objects property by property, ignoring reference identity
  • Collection assertions like HaveCount and Contain avoid manual loops
// xUnit built-in
Assert.Equal(5, result);
Assert.NotNull(task);

// FluentAssertions
result.Should().Be(5);
task.Should().NotBeNull();
task!.Title.Should().Be("Write report");

tasks.Should().HaveCount(3);
tasks.Should().OnlyContain(t => t.OwnerId == 7);
dto.Should().BeEquivalentTo(expected,
    o => o.Excluding(x => x.CreatedAt));
Naming Tests So Failures Explain Themselves

The convention used throughout this course: MethodUnderTest_Scenario_ExpectedResult

MethodScenarioExpected result
GetByIdAsyncWhenTaskExistsReturnsTask
GetByIdAsyncWhenTaskNotFoundThrowsNotFoundException
CreateAsyncWithEmptyTitleThrowsValidationException
DeleteAsyncWhenCallerIsNotOwnerThrowsForbiddenException
Why it matters
The CI log shows only the test name. Test1 failing tells you nothing; DeleteAsync_WhenCallerIsNotOwner_ThrowsForbiddenException tells you the ownership check regressed.
Running Tests from the CLI
terminal
dotnet test

# Only tests whose full name contains TaskServiceTests
dotnet test --filter "FullyQualifiedName~TaskServiceTests"

# Only tests tagged with a trait
dotnet test --filter "Category=Integration"

# Full per-test output instead of a summary
dotnet test --logger "console;verbosity=detailed"
Passed!  - Failed: 0, Passed: 24, Skipped: 0, Total: 24, Duration: 412 ms
This one command is what your CI pipeline runs. If it is green locally, it should be green there.
Mocking with Moq
A unit test must not touch a database. Moq builds a fake ITaskRepository that returns whatever the test needs.
In this section
Why the interface from Session 16 matters
Setup, Returns, ReturnsAsync, Throws
Testing the exception paths
Verify — asserting interactions
Argument matchers and common mistakes
Why Mock — The Dependency Problem
  • TaskService depends on ITaskRepository, which talks to SQL Server
  • Testing the service with the real repository means a database, a schema, and seed data — that is an integration test
  • A mock is a stand-in object that implements the interface and returns exactly what the test dictates
  • This is why we extracted ITaskRepository in Session 16 — testability was the whole point
public interface ITaskRepository
{
    Task<TaskItem?> GetByIdAsync(int id);
    Task<IReadOnlyList<TaskItem>> GetAllAsync(int ownerId);
    Task AddAsync(TaskItem task);
    Task DeleteAsync(TaskItem task);
    Task SaveChangesAsync();
}

public class TaskService(ITaskRepository repository)
{
    private readonly ITaskRepository _repository = repository;
    // ...
}
Rule
You can only mock what you can substitute: an interface, or a virtual member. Concrete sealed classes cannot be mocked.
Moq in Three Calls
Moq essentials
var repoMock = new Mock<ITaskRepository>();

// 1. Return a value for a specific argument
repoMock.Setup(r => r.GetByIdAsync(1))
        .ReturnsAsync(new TaskItem { Id = 1, Title = "Test task" });

// 2. Return null for anything else
repoMock.Setup(r => r.GetByIdAsync(It.IsAny<int>()))
        .ReturnsAsync((TaskItem?)null);

// 3. Make a call blow up
repoMock.Setup(r => r.SaveChangesAsync())
        .ThrowsAsync(new DbUpdateException("constraint violated"));

// .Object is the thing that actually implements ITaskRepository
ITaskRepository repository = repoMock.Object;
Later setups override earlier ones for the same argument. Register the general case first, the specific case second.
The Test Class — Fresh Mocks Per Test
Services/TaskServiceTests.cs
public class TaskServiceTests
{
    private readonly Mock<ITaskRepository> _repoMock = new();
    private readonly TaskService _sut;

    public TaskServiceTests()
    {
        _sut = new TaskService(_repoMock.Object);
    }

    [Fact]
    public async Task GetByIdAsync_WhenTaskExists_ReturnsTask()
    {
        var task = new TaskItem { Id = 1, Title = "Test task" };
        _repoMock.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(task);

        var result = await _sut.GetByIdAsync(1);

        result.Should().NotBeNull();
        result!.Title.Should().Be("Test task");
    }
}
_sut means "system under test" — the one object the test is about. xUnit constructs the class once per test, so every test gets a clean mock.
Testing the Failure Paths
  • The custom exceptions from Session 10 are business rules — they deserve tests more than the happy path does
  • xUnit's Assert.ThrowsAsync<T> returns the exception so you can assert on it
  • FluentAssertions wraps the call in a lambda and reads closer to a sentence
  • Assert on the exception type; only assert on the message when the message itself is the contract
[Fact]
public async Task GetByIdAsync_WhenNotFound_Throws()
{
    _repoMock
        .Setup(r => r.GetByIdAsync(99))
        .ReturnsAsync((TaskItem?)null);

    await Assert.ThrowsAsync<NotFoundException>(
        () => _sut.GetByIdAsync(99));
}

// Same test, FluentAssertions style
var act = async () => await _sut.GetByIdAsync(99);

await act.Should()
         .ThrowAsync<NotFoundException>()
         .WithMessage("*99*");
Verify — Asserting on Behaviour, Not State
Services/TaskServiceTests.cs
[Fact]
public async Task CreateAsync_WithValidDto_PersistsTaskOnce()
{
    var dto = new CreateTaskDto("Write report", DateTime.UtcNow.AddDays(3));

    await _sut.CreateAsync(dto, ownerId: 7);

    _repoMock.Verify(r => r.AddAsync(
        It.Is<TaskItem>(t => t.Title == "Write report" && t.OwnerId == 7)),
        Times.Once);

    _repoMock.Verify(r => r.SaveChangesAsync(), Times.Once);

    _repoMock.VerifyNoOtherCalls();
}
When a method returns void or Task, its only observable effect is the calls it makes — Verify is how you assert on them.
Argument Matchers and Call Counts
ExpressionMatches
It.IsAny<int>()Any integer at all
It.Is<TaskItem>(t => t.OwnerId == 7)Any task whose owner is user 7
It.IsNotNull<string>()Any non-null string
It.IsIn(1, 2, 3)One of the listed values
Times.Once / Times.NeverExactly one call / no call at all
Times.Exactly(3) / Times.AtLeastOncePrecise or minimum call counts
Prefer specific over It.IsAny
Verify(r => r.AddAsync(It.IsAny<TaskItem>()), Times.Once) passes even if the service saved the wrong title. Matching on the fields that matter turns a weak test into a real one.
Four Mocking Mistakes
Forgetting .Object
Passing the Mock<T> itself to the constructor instead of the proxy it wraps. The compiler catches this one.
Fix: pass _repoMock.Object
Unconfigured async method
A method with no Setup returns default — for Task<T> that is null, and awaiting it throws a confusing NullReferenceException.
Fix: set up every awaited call, or use MockBehavior.Strict
Mocking the class you are testing
Mocking TaskService in TaskServiceTests means the test asserts on the mock, not on your code. It always passes.
Fix: mock only the SUT's dependencies
Mocking DbContext
Mocking DbSet and IQueryable is painful and proves nothing about the SQL EF Core actually generates.
Fix: test data access with an integration test instead
Integration Tests
WebApplicationFactory boots your real Program.cs in memory — routing, model binding, filters and middleware all included.
In this section
What an integration test actually exercises
Making Program visible to the test project
Swapping the database for in-memory
A reusable TestApiFactory
Seeding and asserting on the response body
What an Integration Test Covers
Test method WebApplicationFactory<Program> — in-process host, no socket, no port HttpClient real requests Middleware CORS, auth, routing Controller binding, filters Service + Repository In-memory database Only the database is replaced. Everything else is the code you ship. A unit test would cover one of these boxes. An integration test covers the whole row.
Making Program Visible to the Tests
  • WebApplicationFactory<TEntryPoint> needs a type from the API assembly to locate the host
  • Top-level statements generate an internal Program class — the test project cannot see it
  • Adding public partial class Program { } at the bottom of Program.cs makes it public
  • Alternative: InternalsVisibleTo in the API's .csproj
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

// Required for WebApplicationFactory<Program>
public partial class Program { }
<ItemGroup>
  <InternalsVisibleTo
      Include="TaskManagerApi.Tests" />
</ItemGroup>
The Simplest Integration Test
Integration/HealthEndpointTests.cs
public class HealthEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public HealthEndpointTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetHealth_ReturnsOkAndHealthyBody()
    {
        var response = await _client.GetAsync("/health");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
        (await response.Content.ReadAsStringAsync()).Should().Be("Healthy");
    }
}
IClassFixture<T> builds the host once for every test in the class — booting it per test would be needlessly slow.
Swapping the Real Database Out
Integration/TasksEndpointTests.cs
public class TasksEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public TasksEndpointTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
                if (descriptor is not null) services.Remove(descriptor);

                services.AddDbContext<AppDbContext>(options =>
                    options.UseInMemoryDatabase("TestDb"));
            });
        }).CreateClient();
    }

    [Fact]
    public async Task GetTasks_ReturnsOk()
    {
        var response = await _client.GetAsync("/api/v1/tasks");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}
Remove the registered DbContextOptions first, then register your own. Adding without removing leaves two registrations and the last one silently wins.
A Reusable TestApiFactory
Integration/TestApiFactory.cs
public class TestApiFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");

        builder.ConfigureServices(services =>
        {
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor is not null) services.Remove(descriptor);

            services.AddDbContext<AppDbContext>(options =>
                options.UseInMemoryDatabase($"TestDb-{Guid.NewGuid()}"));

            using var provider = services.BuildServiceProvider();
            using var scope = provider.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            db.Database.EnsureCreated();
            Seed(db);
        });
    }

    private static void Seed(AppDbContext db)
    {
        db.Tasks.AddRange(
            new TaskItem { Id = 1, Title = "Seeded task",  OwnerId = 1 },
            new TaskItem { Id = 2, Title = "Another task", OwnerId = 2 });
        db.SaveChanges();
    }
}
A unique database name per factory instance keeps test classes from leaking data into one another.
Asserting on the Response Body
Integration/TasksEndpointTests.cs
[Fact]
public async Task GetTasks_ReturnsSeededTasksAsJson()
{
    var response = await _client.GetAsync("/api/v1/tasks");

    response.StatusCode.Should().Be(HttpStatusCode.OK);
    response.Content.Headers.ContentType!.MediaType
            .Should().Be("application/json");

    var tasks = await response.Content.ReadFromJsonAsync<List<TaskDto>>();

    tasks.Should().NotBeNull();
    tasks!.Should().Contain(t => t.Title == "Seeded task");
}

[Fact]
public async Task CreateTask_WithValidBody_Returns201WithLocation()
{
    var body = new { title = "From an integration test", dueDate = "2026-09-01" };

    var response = await _client.PostAsJsonAsync("/api/v1/tasks", body);

    response.StatusCode.Should().Be(HttpStatusCode.Created);
    response.Headers.Location.Should().NotBeNull();

    var created = await response.Content.ReadFromJsonAsync<TaskDto>();
    created!.Title.Should().Be("From an integration test");
}
This single test proves routing, model binding, validation, the service, the repository, and the 201 contract from Session 9 all still work together.
Choosing a Test Database
OptionSpeedFidelityUse it when
EF In-Memory Fastest Lowest — no SQL, no constraints Testing controller and service wiring
SQLite in-memory Fast Real SQL, real relational constraints Testing queries and relationships
Real SQL Server (Docker) Slow Highest — the same engine as production Testing migrations and provider-specific SQL
In-memory is not a database
It ignores unique constraints, foreign keys, and required columns, and it does not translate LINQ to SQL. A query that passes in-memory can still throw against SQL Server. Use it for wiring, not for data-integrity guarantees.
Testing Protected Endpoints
Every endpoint you secured in Sessions 19 and 20 now answers your tests with 401. Here is how to get past the guard — legitimately.
In this section
Why every test suddenly returns 401
A dedicated appsettings.Testing.json
Generating a real JWT in tests
Asserting 401 and 403 deliberately
The test authentication handler alternative
The 401 Problem

The moment you add [Authorize], the integration tests you just wrote start failing like this:

GET /api/v1/tasks  (no Authorization header)401
GET /api/v1/tasks  Authorization: Bearer not-a-real-token401
DELETE /api/v1/tasks/1  as user 99, task owned by user 1403
GET /api/v1/tasks  Authorization: Bearer <valid JWT>200
Two of these are tests, not problems
The 401 and the 403 rows are exactly what you should be asserting. Security rules only count as implemented once a test proves an unauthorised caller is turned away.
A Dedicated Testing Configuration
TaskManagerApi.Api/appsettings.Testing.json
{
  "Jwt": {
    "Issuer": "TaskManagerApi",
    "Audience": "TaskManagerApiClients",
    "Key": "test-signing-key-at-least-32-characters-long",
    "ExpiryMinutes": 15
  },
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  }
}
The factory selects this file with builder.UseEnvironment("Testing"). Tests sign tokens with the same key the API validates against.
Never reuse the production key
This file is committed, so it must contain a throwaway key that exists only for tests. The real signing key stays in user secrets or an environment variable, as covered in Session 19.
Generating a Real JWT in Tests
Integration/TestTokenGenerator.cs
public static class TestTokenGenerator
{
    public const string Key = "test-signing-key-at-least-32-characters-long";

    public static string Create(int userId, string role = "User")
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
            new Claim(ClaimTypes.Name, $"user{userId}@test.local"),
            new Claim(ClaimTypes.Role, role)
        };

        var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Key));
        var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer:   "TaskManagerApi",
            audience: "TaskManagerApiClients",
            claims:   claims,
            expires:  DateTime.UtcNow.AddMinutes(15),
            signingCredentials: credentials);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}
Issuer, audience, and key must match appsettings.Testing.json exactly, or validation fails with a 401 and no explanation.
Testing 200, 401 and 403
Integration/ProtectedTasksTests.cs
private HttpClient CreateAuthenticatedClient(int userId, string role = "User")
{
    var client = _factory.CreateClient();
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", TestTokenGenerator.Create(userId, role));
    return client;
}

[Fact]
public async Task GetTasks_WithoutToken_Returns401()
{
    var response = await _factory.CreateClient().GetAsync("/api/v1/tasks");

    response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task DeleteTask_WhenCallerIsNotOwner_Returns403()
{
    var client = CreateAuthenticatedClient(userId: 99);

    var response = await client.DeleteAsync("/api/v1/tasks/1");

    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

[Fact]
public async Task DeleteTask_AsAdmin_Returns204()
{
    var client = CreateAuthenticatedClient(userId: 99, role: "Admin");

    var response = await client.DeleteAsync("/api/v1/tasks/1");

    response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
Alternative — A Test Authentication Handler
Integration/TestAuthHandler.cs
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public const string SchemeName = "TestScheme";

    public TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder)
        : base(options, logger, encoder) { }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, "1"),
            new Claim(ClaimTypes.Role, "Admin")
        };

        var identity  = new ClaimsIdentity(claims, SchemeName);
        var principal = new ClaimsPrincipal(identity);
        var ticket    = new AuthenticationTicket(principal, SchemeName);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}
Registered as the default scheme in the factory, this stamps every request as an authenticated admin — convenient, but it stops testing your real token validation.
Real Tokens or a Fake Handler?
Real JWT generation
Exercises the full authentication middleware: signature, issuer, audience, and expiry validation all run for real. A misconfigured TokenValidationParameters fails the test, which is exactly what you want.
Use for: auth and authorization test classes
Test auth handler
Bypasses JWT validation entirely and injects a principal directly. Faster to write and immune to key or clock problems, but a broken JWT configuration will pass silently.
Use for: tests about business logic, not auth
Recommendation for the capstone
Use real tokens. Your API's security is part of what you are being graded on, and the generator is fifteen lines you write once.
What to Test, What to Skip
A suite of 400 tests that all break when you rename a field is a liability. Testing well is mostly about choosing.
In this section
What is worth a test
What is not
Coverage as a compass
xUnit lifecycle and test isolation
Worth Testing vs Not Worth Testing

Test this

  • Business rules — ownership, status transitions, due-date logic
  • Every branch that throws a domain exception
  • Boundary values: empty string, zero, page size 0 and 101
  • The HTTP contract: status codes, Location headers, error shape
  • Any bug you fix — write the failing test first

Do not test this

  • Framework behaviour — EF Core, model binding, JSON serialization
  • Auto-properties and trivial getters
  • Third-party libraries you did not write
  • Private methods directly — reach them through the public method
  • Configuration values and DI registrations, one by one
Quick heuristic
If the test would still pass after you deleted the implementation and returned a hard-coded value, it is testing the wrong thing.
Coverage Is a Compass, Not a Target
terminal
dotnet test --collect:"XPlat Code Coverage"
  • Coverage tells you which lines ran — never whether the assertions were meaningful
  • A test with no Assert at all still produces 100% coverage of the code it calls
  • Use the report to find untested branches, especially the error paths you forgot
  • Chasing a mandated percentage produces tests written to satisfy a metric, not to catch bugs
  • 60–80% on business logic is a healthy place to be for this course's capstone
Keeping Tests Independent
ScopeMechanismCreated
Per testConstructor and IDisposable.DisposeOnce for every test method
Per test classIClassFixture<T>Once, shared by all tests in that class
Per collectionICollectionFixture<T>Once, shared across several classes
Async setupIAsyncLifetimeInitializeAsync / DisposeAsync per test
xUnit runs test classes in parallel
Two classes sharing one in-memory database name will interleave and fail unpredictably. Give each factory a unique database name, or put the classes in the same collection so they run sequentially.
Four Signs Your Suite Is Going Wrong
The flaky test
Passes locally, fails in CI, passes on retry. Usually DateTime.Now, parallel execution, or shared state.
Fix: inject a clock, isolate state, never re-run and move on
The mirror test
The test re-implements the same calculation it is checking, so both are wrong in the same way.
Fix: assert against hand-written expected values
The everything test
Three hundred lines, twelve asserts, four Acts. When it fails you still have to debug to learn what broke.
Fix: one behaviour per test method
The commented-out test
Disabled because it started failing during a refactor and nobody came back to it.
Fix: delete it, or fix it — a disabled test protects nothing
Key Concept
"A unit test proves a class keeps its promise. An integration test proves the classes still talk to each other. You need both, and you need far more of the first."
— Session 22
Lab — Write Tests for TaskService

Write at least five unit tests against your own TaskService, mocking ITaskRepository with Moq:

01
GetById — the task exists: assert the returned task's Title and Id
02
GetById — the task does not exist: assert a NotFoundException is thrown
03
Create — happy path: Verify that AddAsync and SaveChangesAsync were each called exactly once
04
Create — invalid input, if validation lives in the service: assert a ValidationException and that AddAsync was never called
05
Delete — the task belongs to another user: assert a ForbiddenException is thrown
Finish with
dotnet test — all five green, in under a second, with no database running.
Summary
1
Many fast unit tests, some integration tests, minimal end-to-end — the pyramid, not the ice cream cone
2
Every test is Arrange, Act, Assert, with one Act and a name that explains the failure on its own
3
Moq replaces ITaskRepository so a unit test never touches a database — Setup to control input, Verify to assert behaviour
4
WebApplicationFactory<Program> boots the real pipeline in memory; swap only the DbContextOptions registration
5
Test protected endpoints with a real JWT signed by a test-only key, and assert the 401 and 403 cases deliberately
6
Test business rules and error paths. Do not test the framework, and never trust coverage as a substitute for judgement
What's Next

Session 23 — Logging, Monitoring & Health Checks

  • Structured logging with ILogger<T> and Serilog
  • Log levels, scopes, and correlation IDs that survive across requests
  • Health check endpoints for the API and its database
  • What to log, and what must never appear in a log file
Before next session
Get your test project building and at least the five lab tests green. In Session 23 we add a health check endpoint — and the first thing we do is write an integration test for it using the factory you built today.
Assignment

Add a TaskManagerApi.Tests project to your Task Management API containing both unit and integration tests, and commit it alongside the API.

  • At least eight unit tests for TaskService using Moq, covering the five lab cases plus three of your own error paths
  • At least one test using Verify(..., Times.Never) to prove the repository is not called when validation fails
  • A TestApiFactory : WebApplicationFactory<Program> that replaces the database with EF In-Memory and seeds at least two tasks
  • Four integration tests: GET returns 200 with seeded data, POST returns 201 with a Location header, an unauthenticated call returns 401, and a non-owner DELETE returns 403
  • dotnet test runs green from a clean clone with no database installed, and the output is pasted into your README
Bonus
Convert your validation tests to a single [Theory] with at least six [InlineData] rows covering empty, whitespace, over-length, and valid titles — then run dotnet test --collect:"XPlat Code Coverage" and note in your README which branch of TaskService is still uncovered and why.
Questions?
Session 22 — Unit & Integration Testing