Skip to content

Testing

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

Everything lives in one project, forge.tests, organised by subject rather than by test kind. Three different things run inside it with three different requirements, and the fastest way to waste an afternoon is to write a test in the wrong one. The hub's Developer Setup has the cross-repo view of which suite needs what; this page is the repo-level mechanics.

dotnet test                          # everything
dotnet test --filter Architecture    # the ratchet and gate tests only

There are no xUnit category traits. --filter "Category=Unit" matches nothing and returns a green run over zero tests, which looks like success. Filter by namespace or class name instead.

Test parallelisation is disabled assembly-wide. Two WebApplicationFactory<Program> collections racing on the same entry point produced "the entry point exited without ever building an IHost" failures, so AssemblyInfo.cs turns collection parallelism off. Expect the suite to take real wall-clock time, and do not re-enable it to speed up a local run.

The three suites

Unit and in-memory endpoint tests

The default, and most of the suite. Two helpers cover them:

  • TestDbContextFactory.Create() returns a TestAppDbContext on the EF Core in-memory provider with a fresh database name per call.
  • TestWebApplicationFactory (and CapabilityTestWebApplicationFactory) boot the real Program, then strip every EF Core registration and substitute the in-memory context, swap Hangfire onto memory storage, remove the external health checks, force the mock-integration posture and supply a test JWT key.

Three properties of that host are worth knowing before you debug a surprising result:

  • It runs under the Testing environment, not Development. So the OpenAPI document, the Scalar UI and the developer-only endpoints are not mapped, and the clock is a real SystemClock rather than the controllable one Development gets. The rate limiter is technically active, but the loopback bypass means tests never hit it.
  • TestAppDbContext ignores the DocumentEmbedding entity, because the pgvector Vector type is not something the in-memory provider can validate. Anything touching document embeddings or RAG search cannot be covered here.
  • The in-memory provider enforces no database constraints. Filtered unique indexes do not exist, ExecuteUpdate/ExecuteDelete do not run, and triggers do not fire. A test that passes here proves your C# is self-consistent, not that the database will accept it.

The Postgres-backed collection

Roughly three dozen test classes opt into [Collection(PostgresCollection.Name)], concentrated in the accounting, calendar, compliance, persistence and remediation areas. They exist because a real PostgreSQL is the only thing that can observe the behaviour they were written for: filtered unique indexes (the set-default races), set-based ExecuteUpdate, pgvector columns, and the accounting-journal immutability triggers. Those are precisely the cases the in-memory provider is blind to, which is why the collection cannot be faked away.

PostgresFixture starts a pgvector/pgvector container through Testcontainers and applies the schema once per collection by calling the same SchemaBootstrapper the application boots with — so these tests run against the exact DDL a real install gets, not against an EF-derived approximation.

Two consequences:

  • A reachable Docker daemon is required for these tests, and only these tests.
  • The schema is applied once and the data is not reset between test classes. Tests in this collection must create the rows they need and not assume an empty table. That is the price of a per-collection fixture rather than a per-test one.

The FORGE_TEST_PG escape hatch

If Testcontainers' Docker client cannot reach your daemon socket — a sandbox that proxies the docker CLI but blocks the raw socket, a uid outside the docker group — set FORGE_TEST_PG to a connection string and the fixture connects to that database instead of starting a container:

export FORGE_TEST_PG="Host=localhost;Port=<port>;Database=<db>;Username=<user>;Password=<password>"
dotnet test

It must be a pgvector-capable Postgres that you started yourself; the schema creates the vector extension. Unset the variable and the fixture goes back to Testcontainers, which is the default and the intended path. The variable is documented only in an XML comment on the fixture, which is why it is repeated here.

Do not point it at a database you care about. The fixture applies the schema to it and the tests write to it freely.

The Architecture ratchet tests

forge.tests/Architecture/ promotes rules that used to live only in prose into tests that fail the build. They exist because prose was measured and found not to hold — the clock rule and the capability-attribute rule had both eroded substantially before they were made executable.

Test Rule Shape
ControllerCapabilityGateTests Every controller carries [RequiresCapability] or [CapabilityBootstrap] — at class level, or on every HTTP action Hard fail. The legacy exemption register is empty, and the second test evicts an entry the moment it becomes gated
SourceStandardsRatchetTests IClock over DateTime.UtcNow in Features/Services/Jobs; no try/catch in Controllers; fewer than five top-level types per file Per-file ratchet against standards-baseline.json
TrainingCoverageRatchetTests Every catalog capability is claimed by a training seeder Shrink-only baseline of untaught capabilities
TrainingContentShapeTests Every training module parses, has app routes starting with /, and has a scorable quiz Hard fail
ClaudeMdFactsTests The verifiable numbers in the repo's CLAUDE.md match the code Hard fail — fix the doc, not the test

These tests read the source tree, not the compiled assembly. RepoRoot walks up from the test binary until it finds forge.slnx, so they only run from a source checkout — which is always true locally and in CI, and would not be true from a published artifact.

The checks are regex over source text, not Roslyn analysis. That is a deliberate trade for speed and zero build-time cost, and it means the counts are approximate at the edges: a try inside a string literal in a controller counts, and a type declaration split unusually across lines might not. Do not fight the regex — restructure the code.

How the ratchet works, and how to regenerate a baseline

Per rule, per file:

  • A file not in the baseline must have zero violations. New code follows the rule, full stop.
  • A file in the baseline may not exceed its recorded count. Debt never grows.
  • A file whose count fell, or that no longer exists, fails with RATCHET DOWN or STALE ENTRY.

That last one surprises everyone the first time. Improving a baselined file fails the build until you record the improvement. That is the mechanism working: it costs one extra command to make the register honest, and a red build to add debt.

FORGE_STANDARDS_UPDATE_BASELINE=1 dotnet test --filter Architecture

This rewrites standards-baseline.json (and the training baseline) to the current state and passes. Commit the rewritten file in the same commit as the change that caused it. A baseline committed separately makes the register lie about which change moved which number.

Two rules about the baselines that are not negotiable:

  • Never hand-edit a baseline upward. The file is a debt register, not a permission slip. If a rule genuinely needs an exemption, that is a conversation about the rule, recorded in CLAUDE.md — not a bigger number in a JSON file.
  • Never run the update flag to make an unrelated red build go green. It will happily record your new violations as accepted debt, and the diff looks almost identical to a legitimate ratchet-down. Read what the rewrite changed before you commit it.

The practical corollary: when you touch a baselined file for other reasons, fix its violations while you are there. That is how the register drains without a dedicated cleanup effort.

CI

ci.yml runs on every push and pull request to main: restore, dotnet build --configuration Release -warnaserror, then the full suite, with test results uploaded as an artifact. Compiler warnings break the build; there is no broader analyzer or style pack wired in, only nullable reference types plus warnings-as-errors.

One thing to know if you are reading that workflow: it declares a postgres service container that the database-backed tests do not use. PostgresFixture starts its own pgvector container via Testcontainers unless FORGE_TEST_PG is set, and CI does not set it — so the service container's connection string reaches the workflow environment but not the fixture. It is also a stock postgres image without pgvector, which the schema would refuse. Do not "fix" a local failure by mimicking it.

Two other workflows matter here. schema-drift-check re-assembles forge-db's schema tree and fails if forge.data/Schema/forge-schema.sql has drifted; it currently runs on demand rather than on every push, so a stale embedded schema will not stop a merge on its own. And codeql runs the standard security analysis.

Clone this wiki locally