Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 20269 min read

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-off 4.0.2
Tests today
221, in 6 projects
Kind
Unit tests: no database, no HTTP host
Frontend
No tests yet

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.

Builddotnet build AppointMe.sln -c Release
Testdotnet test … --no-build, all six projects
Codegen checkcodegen test compiles the handler code
Frontendnpm run check, then npm run build
Figure 1. The test job in .github/workflows/devtest.yml, in order.
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.

.github/workflows/devtest.ymlView source ↗
- 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 test

codegen 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.

xUnit v3 with its own Assert, Microsoft’s FakeTimeProvider, and hand-written fakes and builders. There’s no mocking library and no assertion library.

Directory.Packages.propsView source ↗
<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.

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:

src/Booking/AppointMe.Booking.Tests/Appointments/CancelAppointment/CancelAppointmentTests.csView source ↗
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);
}

Name the solution file: the repo root holds both AppointMe.sln and AppointMe.slnx, so a bare dotnet test there stops with MSB1011.

Terminal window
dotnet test AppointMe.sln
dotnet test src/CRM/AppointMe.Crm.Tests
dotnet 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.

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.

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:

src/Booking/AppointMe.Booking/Dashboard/GetDashboardStats/GetDashboardStatsQueryHandler.csView source ↗
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:

src/Booking/AppointMe.Booking.Tests/Dashboard/GetDashboardStatsQueryHandlerTests.csView source ↗
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.

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.