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

Source page: https://appointme.dev/docs/testing/overview/

* **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

## 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.**

*Figure 1. The test job in .github/workflows/devtest.yml, in order.*

1. **Build** — `dotnet build AppointMe.sln -c Release`
2. **Test** — `dotnet test … --no-build`, all six projects
3. **Codegen check** — `codegen test` compiles the handler code
4. **Frontend** — `npm 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.

.github/workflows/devtest.yml

```yaml
      - 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.

## 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.**

Directory.Packages.props

```xml
    <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

**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.cs

```cs
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

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

```bash
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.

## 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](/docs/concepts/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

**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.cs

```cs
    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.cs

```cs
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 `DashboardAppointmentRow`s 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

| 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?

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?

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 `DbContext`s, 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.

## Next

* [Add an endpoint](/docs/guides/add-an-endpoint/) (Guide)
* [Vertical slices](/docs/concepts/vertical-slices/) (Concept)
* [GitHub Actions CI/CD](/docs/deploy/ci-cd/) (Deploy)
