Skip to content
bitzorcas
中EN

Guide

Writing new tests

Start from risk and evidence, put regressions in the correct project, and write tests that fail first, reproduce reliably, remain maintainable, and enter the owning gate.

Last updated

The first step in a new test is not copying the nearest [Fact]; state which incorrect implementation it must reject. Enumerate a business rule at the lowest-cost layer and keep only important cross-component contracts at higher layers. This prevents one assertion from being duplicated across Unit, API, Docker, and Consumer suites.

From risk to evidence

pure rulestructureHost/APISQL/messagepackage consumption

Describe the possible regression

Define observable result

Lowest credible layer

Unit / Application

Architecture

Integration no Docker

Integration Docker

Consumer Contract

Red before green

Join owning gate and record evidence

“Increase coverage” is not a sufficient target. “A query without a tenant cannot return arbitrary tenant data” and “a template consumer cannot ProjectReference product source” are decidable contracts.

Layer-selection matrix

Fact to provePreferred project/entryWhy
Aggregate state, value object, error codeUnit.Testsno external dependency; enumerate branches
Handler, rule, Mediator pipelineApplication.Testsreal application class, port substitutes only
Project edge, path, old-pattern red lineArchitecture.Testsparse assembly/project/source/manifest
API composition, auth, Problem DetailsIntegration Category!=DockerHost pipeline without real dependency
SQL, transaction, migration, ORM parityIntegration Category=Dockerreal provider required
CAP/Outbox/retryDocker messaging shardSQL Server + RabbitMQ required
Generator algorithmCodeGeneration.Testsfast generation input/output
Generator NuGet analyzer consumptionGenerator.Package.Testsisolated package boundary
Template instantiation/buildConsumerTemplate + verify-template.shProfile/asset matrix
Commercial NuGet consumptionFramework.ConsumerContract.Testson-demand pack + isolated feed/cache
Runtime licenseLicensing.Testssignature, binding, expiry, fail-closed

When a fact needs two layers, split ownership explicitly. Unit can enumerate error mapping while Docker only proves the real unique index triggers the same business error; do not duplicate every input in both.

Write the failing contract first

  1. Name the regression in business terms with condition and result.
  2. Arrange the smallest legal precondition and change only the trigger.
  3. Run the focused test and confirm the defect or temporary bad implementation fails.
  4. Verify failure is the target contract, not a broken fixture or wrong path.
  5. Implement the smallest correction and make the test pass.
  6. Refactor test and production code while preserving explanatory failure output.
  7. Run the owning project and adjacent gates; local green is not enough.

A regression test that was never red may assert unrelated existing behavior or be unable to catch the defect. If the broken version cannot be retained, use a small negative fixture to prove the rule fails.

Domain regression example

[Fact]
public void Create_WithoutTenant_ShouldReturnFailure()
{
// DocumentCategory is tenant-owned and rejects an empty TenantId before ORM.
var result = DocumentCategory.Create(
string.Empty, "kb-1", null, "Category A", null, null, null, 0);
// Assert both failure classification and the stable caller-facing code.
result.IsFailure.ShouldBeTrue();
result.Error.Code.ShouldBe("Docs.Category.TenantRequired");
}

This contract comes from the current DocumentCategoryTests. It starts no database because it proves an aggregate-creation invariant; real ORM tenant filters have separate parity contracts.

API contract example

[Fact]
public async Task Ping_WithoutToken_ShouldReturnUnauthorized()
{
// Use the repository API factory but intentionally omit Authorization.
await using var factory = new TestApiFactory();
var client = factory.CreateClient();
// Call the real Ping route, which requires authentication.
var response = await client.GetAsync("/api/ping");
// Authentication and authorization are distinct protocols: preserve 401 here.
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}

This is the contract currently guarded by ApiShellTests. Before writing API tests, verify route, authorization, and response types in endpoint source; read the actual error catalog before asserting Problem Details. Do not reuse stale /api/v1/<endpoint> documentation or invent status/error codes.

Docker parity example

Website currently drives EF Core and SqlSugar in adjacent contracts and uses the same WebsiteSlugConflictExceptionMapper to recognize the named unique conflict. The complete critical EF Core path is below; the SqlSugar side writes the same conflict through a real ISqlSugarClient and rolls back.

// The Docker trait lives on the contract class, keeping this out of no-container jobs.
[Fact]
public async Task EfCore_Commit_Should_Expose_Named_Slug_Conflict()
{
// Register the real EF Core adapter, initialize schema, and insert the first Slug.
var services = CreateBaseServices();
services.AddBitzOrcasEfCore(options =>
options.ConnectionString = _database.GetConnectionString());
await using var provider = services.BuildServiceProvider();
await EnsureEfSchemaAsync(provider);
await InsertEfAsync(provider, "ef-same-slug");
// Insert the duplicate and trigger the named unique index at transaction commit.
using var scope = provider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<BitzOrcasDbContext>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
await unitOfWork.BeginAsync(CancellationToken.None);
context.Set<WebsiteSlugProviderFixture>()
.Add(new WebsiteSlugProviderFixture { Slug = "ef-same-slug" });
var exception = await Should.ThrowAsync<DbUpdateException>(
() => unitOfWork.CommitAsync(CancellationToken.None));
// Recognize the target conflict and explicitly roll the transaction back.
WebsiteSlugConflictExceptionMapper.IsConflict(exception).ShouldBeTrue();
await unitOfWork.RollbackAsync(CancellationToken.None);
}

This does not mean every exception maps to a Slug conflict: the mapper recognizes the target named index. New provider contracts also cover affected tenant filtering, paging, concurrency, and transaction semantics and belong to exactly one CI shard.

Architecture-gate example

Structural regressions should return all offenders and the correction reason. This shape bans direct ORM packages from Application projects:

[Fact]
public void ApplicationProjects_ShouldNotReferenceOrmPackages()
{
// Scan every Application project and collect instead of stopping at the first hit.
var offenders = DiscoverApplicationProjects()
.SelectMany(ReadPackageReferences)
.Where(reference => ForbiddenOrmPackages.Contains(reference.Package))
.Select(reference => $"{reference.Project}: {reference.Package}")
.OrderBy(value => value)
.ToArray();
// Report every violation so developers do not discover them one at a time.
offenders.ShouldBeEmpty(
"Application isolates ORM behind ports; implementation packages belong in Infrastructure.");
}

Honor .aiignore during source scans, normalize / and \, and put precise exceptions with exit conditions in a machine-readable registry.

Determinism and isolation

  • Time: inject a fixed clock; do not assert against DateTimeOffset.UtcNow.
  • IDs: generate only for uniqueness; fix samples when asserting order.
  • Tenant: create explicitly per test; do not depend on a global default.
  • Database: unique database/schema/business key and cleanup after failure.
  • Messaging: unique correlation/business key, bounded polling, no fixed sleep.
  • Files: use a temporary directory and delete in finally/async dispose.
  • Packages: isolate NuGet feed, HTTP cache, and global-packages.
  • Ports: let Testcontainers bind dynamically; do not assume a host port is free.

Integration parallelism is currently disabled, but that does not authorize shared dirty data. CI shards and future execution changes will expose the coupling.

Failure messages and assertion scope

Assert stable business fields: error code, state, tenant, business key, event type, and persisted result. Avoid full localized messages, raw provider exceptions, and JSON property order. Collection failures should show missing/extra items; architecture failures should show relative paths.

One test should normally express one business contract, but several assertions may support it. A fail-closed test can reasonably assert status, error code, and absence of side effects.

Wire projects into CI

Add a new test project to BitzOrcas.Modern.slnx and inherit shared SDK/package versions and Release warning policy. Docker tests use TestCategories.Docker, update .github/workflows/ci.yml, and need Architecture.Tests coverage for traits and shard ownership. Template/commercial tests must join their GA workflow rather than running only on a developer machine.

Terminal window
# After the focused regression passes, run the owning project and architecture gate.
dotnet test tests/BitzOrcas.Application.Tests --configuration Release
dotnet test tests/BitzOrcas.Architecture.Tests --configuration Release
# Replace the token with this change's old pattern; expected result is zero.
rg -n 'ForbiddenOldPattern' src tests --glob '*.cs'
git diff --check

Review checklist

  • The test states which incorrect implementation it rejects.
  • It uses the lowest credible layer without duplicated enumeration.
  • It is red before the fix for the intended reason.
  • Routes, types, and error codes come from source, not guesses.
  • External ports, time, IDs, tenant, and resources are controlled.
  • Docker tests have a trait, one shard, and deterministic cleanup.
  • Consumer tests isolate caches and never reference product source.
  • New projects/categories enter solution and CI.
  • Focused, owning, Architecture, and diff sweeps ran.
  • PR evidence records commands, results, and omitted proof.

See also

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%