Testing the Colden kit
How the kit's .NET tests are written and run: the test stack, the shared test infrastructure in Colden.Testing, integration tests on PostgreSQL and Redis containers, host tests, and how the pipeline runs them.
Every feature ships with its tests, and integration tests run against the same PostgreSQL and Redis versions the kit deploys, in containers started by the tests themselves. Nothing is mocked that the kit owns; external services (Stripe, the email providers) are replaced at the HTTP or interface boundary, and the identity server runs for real where a test needs it.
Test stack
- xUnit v3 on Microsoft.Testing.Platform, selected for
dotnet testbyglobal.json; every project whose name ends in.Testsgets xUnit, Shouldly and the global usings fromDirectory.Build.props. - Shouldly is the only assertion library.
Microsoft.AspNetCore.Mvc.Testinghosts the api and the worker in memory;Microsoft.AspNetCore.TestHosthosts smaller test applications.- Testcontainers starts PostgreSQL (
postgres:17.11-alpine), Redis (redis:7.4.12-alpine), Ory Kratos (oryd/kratos:v26.2.0) and Mailpit (axllent/mailpit:v1.33.0), the images of the Compose stack. tests/Colden.Testingholds the shared infrastructure;tests/Colden.Testing.Teststests it.
Running the tests
dotnet test --solution Colden.slnx # everything
dotnet test --project apps/api/tests/Colden.Api.Tests # one project
dotnet test apps/api/tests/Colden.Api.Tests --filter "FullyQualifiedName~Health"
dotnet test --project packages/core/tests/Colden.Core.Tests --filter-namespace "Colden.Core.Tests.Configuration"
The summary line reports passed, failed and skipped tests; a skipped integration test is never a pass.
Integration tests and Docker
PostgresFixture starts one PostgreSQL server per test run (register it with [assembly: AssemblyFixture(typeof(PostgresFixture))], or as a class fixture when only one class needs it). Each test asks for its own database with CreateDatabaseAsync(), so tests never share rows and need no cleanup; apply the migrations with Database.MigrateAsync() when the test needs the schema. RedisFixture does the same for Redis.
Without a reachable Docker engine the fixtures record why, and every test that needs them is reported as skipped with that reason. Point DOCKER_HOST at any engine, for example DOCKER_HOST=tcp://127.0.0.1:2375 dotnet test --solution Colden.slnx.
Hosting the api or the worker in a test
ColdenHostFactory<Program> starts a host in memory. By default it needs no external service: the memory cache, a database host that does not exist, no message persistence or transports, and no Hangfire server, which is enough for endpoint, OpenAPI and configuration tests. For a test against PostgreSQL:
await using var factory = new ColdenHostFactory<Program>()
.WithDatabase(await postgres.CreateDatabaseAsync(cancellationToken)) // messaging stores there, in solo mode
.WithSetting("Jobs:Server:Enabled", "true")
.WithServices(services => services.AddColdenMessageRoute<WorkerProbe>(ColdenQueues.Maintenance));
using var client = factory.CreateClient();
WithSetting sets a configuration key, WithServices replaces or adds services after the host registered its own (a stubbed HttpMessageHandler for an external API, an extra handler assembly for Wolverine). The api's tests use ApiFactory, the offline factory, as a class fixture.
Signing in
Slice tests do not need the identity server. WithTestSessions() on the host factory sets Auth:TestSessions:Enabled and puts TestSessionHandler behind the Session scheme; a request then names its person in the X-Test-Session header and gets the same claims a confirmed Kratos session produces:
await using var factory = new ColdenHostFactory<Program>().WithDatabase(connectionString).WithTestSessions();
var organization = await TestOrganizations.CreateAsync(db);
var user = await new TestUserBuilder().WithSecondFactor().MemberOf(organization, OrganizationRole.Admin).Build().SeedAsync(db);
using var client = factory.CreateClient().AsUser(user);
TestUserBuilder makes a unique identity with one factor by default; WithSecondFactor(), AsSuperAdmin(), MemberOf(...) and ExpiringIn(...) change it, and SeedAsync saves the user row and memberships. A user that is not seeded is created by the API on its first request, as a real one would be. The handler refuses everything unless test sessions are enabled, and always in Production, where the API also refuses to start with them on. The host serves each in-memory request on the port of its address, so factory.CreateClient(new() { BaseAddress = new Uri("http://localhost:<internal port>") }) reaches the internal endpoints. For the identity server's admin operations, FakeIdentityProvider replaces IIdentityProvider: identities with their credentials and sessions (With(user, sessions, credentials)), and a record of the sessions revoked and the identities deleted.
The identity server in tests
KratosFixture starts oryd/kratos:v26.2.0 with the kit's own infra/ory/kratos files and an in-memory database, so a suite that uses it also checks the configuration. CreateClient() returns a KratosTestClient that creates identities through the admin API and signs them in through the native login flow (KratosSession holds the session token and id); RenderJsonnetAsync renders a hook template with Kratos' own Jsonnet engine, which the API's contract tests send to the hook endpoints. The fixture's web hooks point at an address that does not exist, so registration through Kratos is tested in the Compose stack, not here.
Stripe in tests
No test calls Stripe unless asked to. StripeTestData.WithTestBilling(gateway) gives a host the test catalog (plans starter, growth and scale in USD and EUR), a test webhook secret and a FakeStripeGateway, an in-memory Stripe with idempotency keys, scripted failures (FailNext) and a record of the calls it received. The sample events in tests/fixtures/stripe (one per applied type, in Stripe's current API shape) are loaded with StripeTestData.Event(name, organization) and signed with StripeTestData.Sign(payload). The gateway itself is tested against a scripted Stripe API, and against Stripe's test mode when both variables are set:
STRIPE_TEST_SECRET_KEY=sk_test_... STRIPE_TEST_PRICE_ID=price_... dotnet test --project packages/billing/tests/Colden.Billing.Tests --filter-class "*StripeContractTests"
Without them those tests are reported skipped, never passed.
Counting database commands
EfCommandCounter counts the SQL commands Entity Framework Core sends. Add it to a context (options.AddInterceptors(counter)) or to a host (services.ConfigureDbContext<ColdenDbContext>(options => options.AddInterceptors(counter))), call Reset() before the code under test and assert Count, so a list endpoint is pinned to one query rather than one per row.
Configuration contracts
AppSettingsCoverage.Check(services, path) lists every key of the options classes a host registers that its appsettings.json does not declare, and every secret that has a value there. EnvExampleCoverage.Check(services, path) lists every secret missing from infra/compose/.env.example or given a value there. Each host has a test for both, so a new key without its declaration fails the build.
Snapshots
Output that buyers and other systems depend on is pinned by snapshot files next to its tests: the email templates in every culture, as HTML and text (packages/email/tests/Colden.Email.Tests/Templates/__snapshots__). A missing snapshot is written and the test fails once; a different output fails until the change is reviewed. After a deliberate change, run the tests with COLDEN_UPDATE_SNAPSHOTS=1, review the diff of the snapshot files and commit them with the change.
Conventions
- Test names state the behavior in a sentence:
Ready_is_unhealthy_when_postgres_is_down. - One behavior per test; arrange, act and assert are separated by a blank line.
- Every test passes
TestContext.Current.CancellationTokento the asynchronous calls it makes. - No test refers to a planning document, a task or a ticket.
- A test that changes the process environment restores it and runs in its own collection.
In the pipeline
The dotnet:test job runs dotnet test --solution Colden.slnx with a Docker-in-Docker service (DOCKER_HOST=tcp://docker:2375, TESTCONTAINERS_HOST_OVERRIDE=docker), so the integration tests run on every merge request; the runner must allow privileged services. JUnit reports appear in the merge request.