Testing
AppointMe ships 221 xUnit v3 unit tests in six .NET 10 test projects, one per module plus the API and the shared kernel. There are no integration, end-to-end or frontend tests yet.
- Framework
- xUnit v3,
xunit.v3.mtp-off4.0.2 - Tests today
- 221, in 6 projects
- Kind
- Unit tests: no database, no HTTP host
- Frontend
- No tests yet
How tests run
Section titled “How tests run”dotnet test AppointMe.sln runs every test project through VSTest. CI runs the same command on every pull request and every push to main or a v* tag, then compiles Wolverine’s generated handler code.
dotnet build AppointMe.sln -c Releasedotnet test … --no-build, all six projectscodegen test compiles the handler codenpm run check, then npm run build| Project | Tests | What it covers |
|---|---|---|
src/AppointMe.Shared.Tests |
118 | Value objects (Email, PersonName, DateOfBirth, Gender), periods and stats bucketing, pagination, roles, principal.Require, company helpers |
src/Booking/AppointMe.Booking.Tests |
34 | The appointment, attendee, service-provider and booking-company aggregates, the dashboard and peak-hours calculators, and two handlers’ permission checks |
src/Organizations/AppointMe.Organizations.Tests |
28 | Permission resolution (grant and deny policies, overrides) as specs, company ownership, role updates, invitations |
src/AppointMe.Api.Tests |
25 | The antiforgery and security-header middleware, the BadHttpRequestException handler, the super-admin authorization handler |
src/Identity/AppointMe.Identity.Tests |
9 | ClaimsPrincipalExtensions and user registration |
src/CRM/AppointMe.Crm.Tests |
7 | Customer registration and deletion, new-customer stats, demo seeding |
Each module test project references its module, and AppointMe.Organizations.Tests also references AppointMe.Shared.Tests for its Specification base class. AppointMe.Api.Tests references the API project. All six are executables (OutputType Exe), as xUnit v3 requires.
- name: Test run: dotnet test AppointMe.sln -c Release --no-build --logger "console;verbosity=normal"
# The image runs Wolverine handlers pre-generated by the Dockerfile's `codegen write`, but the # image is only built on deploys; this generates and compiles the same code in memory on every run. - name: Wolverine codegen check run: dotnet run --project src/AppointMe.Api/AppointMe.Api.csproj -c Release --no-build --no-launch-profile -- codegen testcodegen test isn’t a test suite. It generates every Wolverine handler chain and compiles it in memory, so a chain that won’t generate or compile fails CI instead of the next image build.
Frameworks and libraries
Section titled “Frameworks and libraries”xUnit v3 with its own Assert, Microsoft’s FakeTimeProvider, and hand-written fakes and builders. There’s no mocking library and no assertion library.
<PackageVersion Include="coverlet.collector" Version="10.1.0" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.14" /> <PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.11.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" /> <PackageVersion Include="xunit.v3.mtp-off" Version="4.0.2" /> <PackageVersion Include="xunit.runner.visualstudio" Version="4.0.1" />| Package | Used for |
|---|---|
xunit.v3.mtp-off |
xUnit v3, set up to run under VSTest rather than Microsoft Testing Platform |
xunit.runner.visualstudio, Microsoft.NET.Test.Sdk |
The VSTest adapter and host behind dotnet test and IDE test runners |
Microsoft.Extensions.TimeProvider.Testing |
FakeTimeProvider, for a fixed “now” |
coverlet.collector |
Coverage, when you pass --collect "XPlat Code Coverage". CI doesn’t collect it. |
Microsoft.EntityFrameworkCore.InMemory |
Referenced by four test projects, but no test uses it yet |
Every test project has <Using Include="Xunit" />, so [Fact], [Theory] and Assert need no using.
Conventions
Section titled “Conventions”Test methods are snake_case sentences, test classes aren’t sealed, and test data comes from builders behind a static Create class.
| Convention | Example | Where it comes from |
|---|---|---|
Method names read as a sentence, usually starting should_ |
should_throw_when_cancelling_an_already_cancelled_appointment |
Root CLAUDE.md. Four test projects’ .editorconfig files silence CA1707, IDE1006 and VSTHRD200 for them. |
Test classes are public class, not sealed |
public class CancelAppointmentTests |
Root CLAUDE.md |
| Test folders mirror the module’s slices | Appointments/CancelAppointment/CancelAppointmentTests.cs |
The module’s Appointments/CancelAppointment/ folder |
| Builders for valid entities | Create.Appointment.WithStatus(AppointmentStatus.Cancelled).Build() |
Create.cs plus one <Entity>Builder per entity, in each module test project |
| Specs for rule tables | when_deny_wins_policy_is_used_and_one_role_denies |
Specification (Context(), Because()) in AppointMe.Shared.Tests |
| Fixed clock and culture | new FakeTimeProvider(DateTimeOffset.Parse("5 Oct 2025 12:00Z", DateTimeFormatInfo.InvariantInfo)) |
Builders and tests that touch dates |
A typical aggregate test arranges with a builder, calls one method, and checks state and the raised event:
public class CancelAppointmentTests{ [Fact] public void should_set_status_to_cancelled_and_raise_event() { var appointment = Create.Appointment.Build();
appointment.Cancel();
Assert.Equal(AppointmentStatus.Cancelled, appointment.Status);
var @event = appointment.Events.OfType<AppointmentCancelledEvent>().Single(); Assert.Equal(appointment.Id.Value, @event.AppointmentId); Assert.Equal(appointment.CompanyId.Value, @event.CompanyId); }Run the tests
Section titled “Run the tests”Name the solution file: the repo root holds both AppointMe.sln and AppointMe.slnx, so a bare dotnet test there stops with MSB1011.
dotnet test AppointMe.slndotnet test src/CRM/AppointMe.Crm.Testsdotnet test src/Booking/AppointMe.Booking.Tests --filter "FullyQualifiedName~CancelAppointmentTests"The first runs everything, the second runs one project, and the third runs one class. A longer FullyQualifiedName~ value narrows the filter to one test. No test needs Docker, SQL Server or Keycloak.
To reproduce CI, build with dotnet build AppointMe.sln -c Release, test with -c Release --no-build, and run codegen test as in the workflow above.
What’s covered and what isn’t
Section titled “What’s covered and what isn’t”The tests cover domain rules, calculators, permission resolution and API middleware. Nothing yet exercises a database, an HTTP endpoint from end to end, or the Wolverine pipeline at runtime.
| Area | Covered today | Not covered |
|---|---|---|
| Aggregates and value objects | Factories and state changes, including the events they raise | — |
| Query and command handlers | The permission check in GetDashboardStatsQueryHandler, GetPeakHoursQueryHandler and GetNewCustomerStatsQueryHandler |
Any handler path that loads or saves through EF Core or Dapper |
| Reads (Dapper SQL) | — | Every query, including the [CompanyId] = @CompanyId predicate |
| EF Core | — | Mappings, named query filters, migrations |
| HTTP and auth | Individual middleware and authorization handlers | Endpoints, routing, authentication and company resolution in a running host. There’s no WebApplicationFactory test. |
| Wolverine | Handler code generation and compilation (codegen test) |
Handler policies, tenancy propagation, the outbox |
| Module boundaries | — | No architecture tests |
| Frontend | — | No test files and no test script. Vite+ bundles Vitest 5, unused so far. |
The multi-tenancy rule that Dapper queries filter by company is a convention with no test behind it. If you add integration tests, the read repositories are the first place they pay off.
Add a test for a handler
Section titled “Add a test for a handler”Construct the handler yourself, pass a fake IPrincipal, and assert on the result or the exception. Logic that needs data goes into a pure calculator, which you test with plain inputs.
The handler below checks the permission first, loads rows through two repositories, then hands everything to a static calculator:
public async Task<GetDashboardStatsResponse> HandleAsync(GetDashboardStatsQuery query, CompanyId companyId, IPrincipal principal, CancellationToken cancellationToken) { principal.Require(AppointmentPermissions.ViewStatistics);
var timeZone = TimeZoneInfo.Create(query.TimeZone); var rows = await repository.GetAppointmentRows(query.Period, companyId, cancellationToken); var compareRows = query.ComparePeriod is not null ? await repository.GetAppointmentRows(query.ComparePeriod, companyId, cancellationToken) : null; var providers = await providersRepository.GetAll(companyId, cancellationToken);
return DashboardStatsCalculator.Calculate( rows, compareRows, providers, query.Period, query.ComparePeriod, query.Bucket, timeZone); }Its test needs no repositories at all, because the check throws before they’re touched:
public class GetDashboardStatsQueryHandlerTests{ private sealed class NoPermissionsPrincipal : IPrincipal { public bool HasRole(Role role) => false; public bool HasPermission(Permission permission) => false; }
[Fact] public async Task should_deny_when_principal_lacks_view_statistics_permission() { // Repositories are passed as null! on purpose: the permission check must // throw before any dependency is touched. var handler = new GetDashboardStatsQueryHandler(null!, null!); var query = new GetDashboardStatsQuery { // … };
await Assert.ThrowsAsync<AccessDeniedException>(() => handler.HandleAsync( query, new CompanyId(NewId()), new NoPermissionsPrincipal(), CancellationToken.None)); }}| Step | Where | What to do |
|---|---|---|
| 1. Pick the folder | <Module>.Tests/<Feature>/ |
Mirror the handler’s folder in the module. |
| 2. Add the class | <Handler>Tests.cs |
public class, with [Fact] or [Theory] methods named should_… |
| 3. Fake the principal | A nested private sealed class |
Implement IPrincipal’s two members, HasRole and HasPermission, to return what the case needs. |
| 4. Build the handler | new <Handler>(…) |
Pass real collaborators where you have them. null! is fine for ones the path under test never reaches. |
| 5. Assert | Assert.ThrowsAsync<AccessDeniedException>(…) or Assert.Equal(…) |
Test one outcome per method. |
| 6. Test the logic | The calculator or aggregate | DashboardStatsCalculatorTests builds DashboardAppointmentRows in the test and calls Calculate directly. |
| 7. Run it | dotnet test <project> --filter "FullyQualifiedName~<Handler>Tests" |
A handler that writes through a DbContext has no test harness in the repo. The existing pattern tests the aggregate method the handler calls instead: CancelAppointmentTests calls appointment.Cancel(), not the cancel handler.
Gotchas
Section titled “Gotchas”| Symptom | Cause | Fix |
|---|---|---|
MSB1011: Specify which project or solution file to use |
The repo root has two solution files | Run dotnet test AppointMe.sln, or point at one test project. |
| The IDE flags your snake_case test name | AppointMe.Api.Tests and AppointMe.Identity.Tests have no .editorconfig |
Copy the .editorconfig from AppointMe.Crm.Tests into the project. |
| A test passes today and fails next year | It used DateTimeOffset.UtcNow or the machine’s culture |
Use FakeTimeProvider and DateTimeFormatInfo.InvariantInfo, as the builders do. |
| The template smoke test fails with “expected 221” after you add a test | templates/smoke-test.sh asserts the generated solution’s test count in EXPECTED_TOTAL_TESTS |
Bump the constant. This applies only in the upstream template repo, since generated projects don’t contain templates/. |
| CI’s “Wolverine codegen check” fails while the tests pass | Wolverine can’t generate or compile one of the handler chains | Run the same codegen test command locally and read the first error. |
Why xunit.v3.mtp-off instead of plain xunit.v3?1 min · optional
xUnit v3 defaults to Microsoft Testing Platform. The -mtp-off package keeps the projects on VSTest with the .NET 10 SDK, so dotnet test and its output are the same as they were under xUnit v2. Moving to Microsoft Testing Platform is left as a separate step.
Why hand-written fakes instead of a mocking library?1 min · optional
The seams the tests touch are small. IPrincipal has two members, so a nested fake takes a few lines, reads like the scenario it sets up, and breaks at compile time when the interface changes. The heavier collaborators, repositories and DbContexts, aren’t mocked at all: the logic that matters is pushed into aggregates and calculators that take plain values.
The cost shows in the coverage table. Nothing checks the SQL or the EF mappings, and a mock wouldn’t either, so that gap needs integration tests against a real database.