Skip to content
bitzorcas
中EN

Guide

Unit tests

Write fast, deterministic tests for aggregates, value objects, Result errors, request rules, and application pipelines as readable business contracts.

Last updated

Unit tests enumerate business branches at the lowest cost. They run without a database, message broker, Redis, network, or drifting system clock and express rules through public behavior. BitzOrcas keeps pure foundation/domain tests in BitzOrcas.Unit.Tests and application control flow—handlers, authorization policies, and Mediator pipelines—in BitzOrcas.Application.Tests.

Decide the boundary

noyesaggregate/valueobject/algorithmhandler/rule/pipeline

Fact to prove

Needs real adapter semantics?

Unit / Application

Integration

Pure domain or orchestration?

Unit.Tests

Application.Tests

Testcontainers / API Host

SQL translation, index conflicts, transactions, CAP, wire protocol, and middleware ordering cross the unit boundary. EF InMemory or a List<T> repository is not a provider contract.

Behaviors worth testing

TypeHigh-value assertionLow-value substitute
Aggregatelegal transitions, rejection, domain eventsproperty getter
Value objectnormalization, equality, boundary, errorduplicating the algorithm in test
Result<T>stable code, error kind, short circuitonly IsFailure
Request ruletenant-specific rule, cancellation, compositionmocking a private method
Handlerobservable port result and failure mappingevery mock call count
Pipelineauthorization/validation/transaction short circuitframework DI itself
Pure algorithmquota, time window, masking, pagingreal clock and randomness

Names should state scenario and result, such as Should_ShortCircuit_With_Failure_And_Skip_Handler_When_Rule_Fails. A failure should explain the contract without opening source.

Entity equality and domain events

Repository EntityEqualityTests distinguish same type/ID, different IDs, different entity types, null, and domain-event collection/clearing rather than testing only the happy path.

[Fact]
public void SameIdAndType_ShouldBeEqual_ButDifferentTypeShouldNot()
{
// Fix one ID to separate identity equality from runtime-type equality.
var id = Guid.CreateVersion7();
var first = new SampleEntity(id);
var sameType = new SampleEntity(id);
var otherType = new OtherEntity(id);
// Equals, operators, and hash code must uphold one collection contract.
first.Equals(sameType).ShouldBeTrue();
(first == sameType).ShouldBeTrue();
first.GetHashCode().ShouldBe(sameType.GetHashCode());
// Equal IDs across entity types must not confuse sets or identity maps.
first.Equals(otherType).ShouldBeFalse();
}

When an aggregate raises an event, assert its type and important business fields rather than only Count == 1. Cover rejection paths where failure must raise no event.

[Fact]
public void RaiseAndClear_ShouldMaintainDomainEventCollection()
{
// A new aggregate starts without pending events to dispatch.
var entity = new SampleEntity(Guid.CreateVersion7());
entity.DomainEvents.ShouldBeEmpty();
entity.Raise(new SampleEvent());
entity.DomainEvents.ShouldHaveSingleItem();
// Clearing follows persistence/dispatch; replayed clear remains safe.
entity.ClearDomainEvents();
entity.ClearDomainEvents();
entity.DomainEvents.ShouldBeEmpty();
}

Application-pipeline short circuit

ValidationPipelineBehaviorTests use the real pipeline and small IRequestRule<T> implementations while substituting only tenant and strategy ports. The key fact is not “rule called once”: failure skips the handler and returns a stable code; success reaches the handler.

[Fact]
public async Task FailingRule_ShouldSkipHandler_AndReturnStableError()
{
// The strategy exposes one deterministic failure; no Host or database starts.
var pipeline = CreatePipeline(new FailingRule());
var handlerCalled = false;
MessageHandlerDelegate<CreateGreetingCommand, Result<Greeting>> next = (_, _) =>
{
handlerCalled = true;
return ValueTask.FromResult(Result<Greeting>.Success(CreateGreeting()));
};
// Drive the complete validation control flow through the public Handle API.
var result = await pipeline.Handle(
new CreateGreetingCommand("name"), next, CancellationToken.None);
// Prove both typed failure and that the side-effect boundary was not crossed.
result.Error.Code.ShouldBe("Greeting.Invalid");
handlerCalled.ShouldBeFalse();
}

Tenant-specific rules should cover the target tenant receiving extra rules, other tenants receiving base rules, and no-tenant fail-closed/default behavior according to the contract. Never share one mutable static tenant across tests.

Substitute external ports only

Substitute true external ports, not internal methods of the subject. Reasonable examples include ICurrentTenant, clock, ID generator, authorization policy, and notification/repository ports. Repository fakes may prove handler control flow; real provider concurrency, filtering, and transactions belong in integration tests.

// Tenant identity is security-sensitive input, so use an exact value.
var currentTenant = Substitute.For<ICurrentTenant>();
currentTenant.Tenant.Returns(new CurrentTenant("TENANT_A"));
// The strategy returns only the one passing rule required by this scenario.
var strategy = Substitute.For<IValidationStrategy>();
strategy.GetRules<CreateGreetingCommand>(currentTenant)
.Returns(new IRequestRule<CreateGreetingCommand>[] { new PassingRule() });

Avoid ReturnsForAll, broad argument matching, and dozens of setup calls that let incorrect collaboration pass. Use exact tenant, resource, and error-code values where security matters.

Time, randomness, and concurrency

Inject a fixed clock for time rules and assert before, at, and after the boundary. Use a predictable ID generator when only uniqueness matters; construct an explicit UUID v7 scenario when ordering matters. Use controllable signals and cancellation rather than Task.Delay to prove timeout.

Concurrency correctness normally depends on database version columns, unique indexes, or locks and belongs in integration. A unit test can verify conflict-Result mapping but cannot claim to prove two competing transactions.

Parameterized boundaries

[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(100, true)]
[InlineData(101, false)]
public void BatchSize_ShouldRespectInclusiveRange(int value, bool expected)
{
// A theory makes one boundary table readable and reports each input separately.
// Direct boolean comparison leaves both input and expected value in failure output.
BatchPolicy.IsAllowed(value).ShouldBe(expected);
}

Do not combine unrelated business cases into one theory merely to reduce lines. Every row should share one rule and one failure explanation.

Run and diagnose

Terminal window
# Run the two layers separately so ownership remains visible.
dotnet test tests/BitzOrcas.Unit.Tests --configuration Release
dotnet test tests/BitzOrcas.Application.Tests --configuration Release
# Focus on one class while changing the validation pipeline.
dotnet test tests/BitzOrcas.Application.Tests \
--configuration Release \
--filter 'FullyQualifiedName~ValidationPipelineBehaviorTests'

For intermittent failures look for real time, randomness, static state, shared collections, and execution-order dependence. Do not make nondeterminism permanent through retries or weaker assertions.

Review checklist

  • Names state business scenario and observable result.
  • Arrange creates only state required by this contract.
  • Success, rejection, boundary, and cancellation paths follow risk.
  • Error assertions include stable code/kind, not mutable full message.
  • Substitutes sit at external ports and expose no private implementation.
  • Tests need no network, real clock, fixed port, or execution order.
  • Fakes are not overstated as provider, transaction, or messaging evidence.
  • The regression test fails before the fix and passes after it.

See also

100%

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