By the end of this session, you will be able to:
WebApplicationFactory| Time | Segment | Type | Duration |
|---|---|---|---|
| 0:00 | Why testing matters — the pyramid | Theory | 15 min |
| 0:15 | xUnit setup & first unit test | Demo | 25 min |
| 0:40 | Mocking with Moq | Demo | 25 min |
| 1:05 | Break | — | 10 min |
| 1:15 | Integration tests with WebApplicationFactory | Demo | 30 min |
| 1:45 | Testing auth-protected endpoints | Demo | 15 min |
| 2:00 | Lab — write tests for TaskService | Lab | 30 min |
Delete_WhenCallerIsNotOwner_ThrowsForbidden states a business rule better than a comment| Property | Unit | Integration | End-to-end |
|---|---|---|---|
| Scope | One class | One request path | Whole system |
| Dependencies | All mocked | Real, but in-memory | All real |
| Typical duration | < 5 ms | 50–500 ms | Seconds to minutes |
| Failure tells you | Exactly which method broke | Which layer broke | Something broke |
| Breaks when you refactor | Often | Rarely | Almost never |
| Count in this course | Many | Some | None |
DateTime.Now, Guid.NewGuid(), and Random are the usual culprits behind flaky tests.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
.Tests suffix. Mirror the folder structure too: Services/TaskServiceTests.cs tests Services/TaskService.cs.
<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.[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[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);
}
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();
}
}
public, the method is public void, and the attribute is what makes the runner find it.Assert works fine — FluentAssertions just reads better and fails louderBeEquivalentTo compares objects property by property, ignoring reference identityHaveCount 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));
The convention used throughout this course: MethodUnderTest_Scenario_ExpectedResult
| Method | Scenario | Expected result |
|---|---|---|
GetByIdAsync | WhenTaskExists | ReturnsTask |
GetByIdAsync | WhenTaskNotFound | ThrowsNotFoundException |
CreateAsync | WithEmptyTitle | ThrowsValidationException |
DeleteAsync | WhenCallerIsNotOwner | ThrowsForbiddenException |
Test1 failing tells you nothing; DeleteAsync_WhenCallerIsNotOwner_ThrowsForbiddenException tells you the ownership check regressed.
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"
ITaskRepository that returns whatever the test needs.TaskService depends on ITaskRepository, which talks to SQL ServerITaskRepository in Session 16 — testability was the whole pointpublic 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;
// ...
}
virtual member. Concrete sealed classes cannot be mocked.
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;
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.Assert.ThrowsAsync<T> returns the exception so you can assert on it[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*");
[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();
}
void or Task, its only observable effect is the calls it makes — Verify is how you assert on them.| Expression | Matches |
|---|---|
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.Never | Exactly one call / no call at all |
Times.Exactly(3) / Times.AtLeastOnce | Precise or minimum call counts |
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.
.ObjectMock<T> itself to the constructor instead of the proxy it wraps. The compiler catches this one._repoMock.ObjectSetup returns default — for Task<T> that is null, and awaiting it throws a confusing NullReferenceException.MockBehavior.StrictTaskService in TaskServiceTests means the test asserts on the mock, not on your code. It always passes.DbContextDbSet and IQueryable is painful and proves nothing about the SQL EF Core actually generates.Program.cs in memory — routing, model binding, filters and middleware all included.Program Visible to the TestsWebApplicationFactory<TEntryPoint> needs a type from the API assembly to locate the hostProgram class — the test project cannot see itpublic partial class Program { } at the bottom of Program.cs makes it publicInternalsVisibleTo in the API's .csprojapp.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
// Required for WebApplicationFactory<Program>
public partial class Program { }
<ItemGroup>
<InternalsVisibleTo
Include="TaskManagerApi.Tests" />
</ItemGroup>
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.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);
}
}
DbContextOptions first, then register your own. Adding without removing leaves two registrations and the last one silently wins.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();
}
}
[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");
}
| Option | Speed | Fidelity | Use 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 |
The moment you add [Authorize], the integration tests you just wrote start failing like this:
{
"Jwt": {
"Issuer": "TaskManagerApi",
"Audience": "TaskManagerApiClients",
"Key": "test-signing-key-at-least-32-characters-long",
"ExpiryMinutes": 15
},
"Logging": {
"LogLevel": {
"Default": "Warning"
}
}
}
builder.UseEnvironment("Testing"). Tests sign tokens with the same key the API validates against.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);
}
}
appsettings.Testing.json exactly, or validation fails with a 401 and no explanation.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);
}
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));
}
}
TokenValidationParameters fails the test, which is exactly what you want.
Location headers, error shapedotnet test --collect:"XPlat Code Coverage"
Assert at all still produces 100% coverage of the code it calls| Scope | Mechanism | Created |
|---|---|---|
| Per test | Constructor and IDisposable.Dispose | Once for every test method |
| Per test class | IClassFixture<T> | Once, shared by all tests in that class |
| Per collection | ICollectionFixture<T> | Once, shared across several classes |
| Async setup | IAsyncLifetime | InitializeAsync / DisposeAsync per test |
DateTime.Now, parallel execution, or shared state.Write at least five unit tests against your own TaskService, mocking ITaskRepository with Moq:
GetById — the task exists: assert the returned task's Title and IdGetById — the task does not exist: assert a NotFoundException is thrownCreate — happy path: Verify that AddAsync and SaveChangesAsync were each called exactly onceCreate — invalid input, if validation lives in the service: assert a ValidationException and that AddAsync was never calledDelete — the task belongs to another user: assert a ForbiddenException is throwndotnet test — all five green, in under a second, with no database running.
ITaskRepository so a unit test never touches a database — Setup to control input, Verify to assert behaviourWebApplicationFactory<Program> boots the real pipeline in memory; swap only the DbContextOptions registrationSession 23 — Logging, Monitoring & Health Checks
ILogger<T> and SerilogAdd a TaskManagerApi.Tests project to your Task Management API containing both unit and integration tests, and commit it alongside the API.
TaskService using Moq, covering the five lab cases plus three of your own error pathsVerify(..., Times.Never) to prove the repository is not called when validation failsTestApiFactory : WebApplicationFactory<Program> that replaces the database with EF In-Memory and seeds at least two tasksGET returns 200 with seeded data, POST returns 201 with a Location header, an unauthenticated call returns 401, and a non-owner DELETE returns 403dotnet test runs green from a clean clone with no database installed, and the output is pasted into your README[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.