# .NET Integration Testing — VSA

> **Scope:** dotnet-vsa
> **Layer:** 0 (always load)
> **Keywords:** testing, xunit, integration test, TimeProvider, FakeTimeProvider, WebApplicationFactory, ProblemDetails, error body assertion
> **Load When:** writing or reviewing tests for a dotnet VSA project

**Verified against:** .NET 10 + xUnit 2 + `Microsoft.Extensions.TimeProvider.Testing`. Last-verified: 2026-08-12.

---

## Overview

Integration tests exercise the real ASP.NET Core pipeline (`WebApplicationFactory`) against
an in-memory/test database — the pattern used across `backend-dotnet-vsa-handler-patterns`'
handler examples. Two failure modes recur in generated test suites and each has a dedicated
rule below: tests that read the real system clock instead of an injected, fakeable one; and
error-path tests that assert only the HTTP status code, leaving the actual failure contract
(the response body) unverified.

---

## Rule 1 — Inject `TimeProvider`, fake it with `FakeTimeProvider` in tests

Any logic that compares against "now" (a past-date validation, an expiry check, a due-date
default) MUST read the clock through an injected `TimeProvider` — never `DateTime.UtcNow` or
`DateOnly.FromDateTime(DateTime.UtcNow)` directly in a handler or validator. A validator that
reads the real clock makes "today" a moving target: a test asserting "today is valid, yesterday
is rejected" flakes for real around UTC midnight, and is untestable for the "exactly at the
boundary" case at all.

```csharp
// Features/VisitFeature/CloseVisit/CloseVisitValidator.cs
public sealed class CloseVisitValidator : AbstractValidator<CloseVisitRequest>
{
    public CloseVisitValidator(TimeProvider timeProvider)
    {
        RuleFor(c => c.FollowUpDate)
            .Must(date => date!.Value >= Today(timeProvider))
            .WithErrorCode(VisitErrors.FollowUpDateInPast.Code)
            .WithMessage(VisitErrors.FollowUpDateInPast.Description)
            .When(c => c.FollowUpDate.HasValue);
    }

    private static DateOnly Today(TimeProvider timeProvider) =>
        DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime);
}
```

Register the real clock once in `Program.cs`:

```csharp
builder.Services.AddSingleton(TimeProvider.System);
```

In tests, swap it for `Microsoft.Extensions.TimeProvider.Testing`'s `FakeTimeProvider` —
deterministic, no dependency on when the suite happens to run:

```csharp
// tests/.../Infrastructure/VetClinicApiFactory.cs
public sealed class VetClinicApiFactory : WebApplicationFactory<Program>
{
    public FakeTimeProvider TimeProvider { get; } = new(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero));

    protected override void ConfigureWebHost(IWebHostBuilder builder) =>
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<TimeProvider>();
            services.AddSingleton<TimeProvider>(TimeProvider);
        });
}
```

```csharp
// The boundary case ("today is accepted, yesterday is not") becomes a fact about
// the fake clock, not about when CI happens to run.
DateOnly today = DateOnly.FromDateTime(factory.TimeProvider.GetUtcNow().UtcDateTime);
DateOnly yesterday = today.AddDays(-1);
```

---

## Rule 2 — Error-path tests assert the response BODY, not just the status code

A test that only checks `response.StatusCode` proves the endpoint returns *a* 400/404/409 — it
proves nothing about *why*, and lets the wrong validation rule or the wrong error code pass
silently as long as the status happens to match. Every error-path test MUST also assert the
`ProblemDetails` body: at minimum `title` (or `type`) and the specific field/reason that
triggered the failure.

```csharp
// Wrong — proves only that SOMETHING was rejected, not what:
HttpResponseMessage response = await client.PostAsJsonAsync($"visits/{id}/close", body);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
```

```csharp
// Right — the body is the actual contract a client integrates against:
HttpResponseMessage response = await client.PostAsJsonAsync($"visits/{id}/close", body);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

ProblemDetails? problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problem);
Assert.Contains("FollowUpDate", problem!.Extensions["errors"]!.ToString());
Assert.Equal(VisitErrors.FollowUpDateInPast.Code, GetErrorCode(problem));
```

The same discipline applies to 404 (`NotFound` — assert the entity/id named in the problem
detail) and 409 (`Conflict` — assert the conflict reason, e.g. `"Visit.AlreadyClosed"`, not just
that a conflict happened). A status-only assertion is a regression magnet: swapping
`VisitErrors.NotFound` for `VisitErrors.AlreadyClosed` in the wrong branch still returns 404/409
respectively and the test suite stays green.

---

## Anti-Patterns

| Do NOT | Do Instead |
|--------|------------|
| Read `DateTime.UtcNow` / `DateOnly.FromDateTime(DateTime.UtcNow)` directly in a handler or validator | Inject `TimeProvider`, read `timeProvider.GetUtcNow()` |
| Leave a test's "past date" derived from the real system clock (`DateTime.UtcNow.AddDays(-1)`) | Swap `TimeProvider` for `FakeTimeProvider` in the test factory and derive dates from it |
| Assert only `response.StatusCode` on an error path | Also deserialize and assert the `ProblemDetails` body (title/detail/violated field or error code) |
| Treat a passing status-only test as proof the error message/code is correct | Assert the specific error code/field — a wrong branch can still return the right status |

---

*MORPH-SPEC by Polymorphism Tech*
