Testing Guide
Every Granit package has a matching *.Tests project under tests/. This is
enforced by the architecture test Every_src_package_should_have_a_test_project.
| Library | Role |
|---|---|
| xUnit | Test framework |
| Shouldly | Assertions |
| NSubstitute | Mocking |
| Bogus | Test data generation |
| FakeTimeProvider | Deterministic time control (Microsoft.Extensions.TimeProvider.Testing) |
Naming
Section titled “Naming”Test classes follow the pattern {ClassUnderTest}Tests:
| Source class | Test class |
|---|---|
Clock | ClockTests |
AuditedEntityInterceptor | AuditedEntityInterceptorTests |
CurrentUserService | CurrentUserServiceTests |
Test methods follow Method_Scenario_ExpectedBehavior:
[Fact]public void Now_ReturnsUtcFromTimeProvider() { ... }
[Fact]public async Task SaveChangesAsync_OnAdd_SetsCreatedFields() { ... }
[Fact]public void UserId_WithAuthenticatedUser_ReturnsSubClaim() { ... }AAA pattern
Section titled “AAA pattern”All tests follow Arrange-Act-Assert strictly:
[Fact]public void Normalize_ConvertsLocalOffsetToUtc(){ // Arrange - DateTimeOffset with +02:00 offset (Europe/Brussels in summer) var localTime = new DateTimeOffset(2026, 6, 15, 14, 30, 0, TimeSpan.FromHours(2));
// Act var normalized = _clock.Normalize(localTime);
// Assert - Must be converted to UTC (+00:00), same instant normalized.Offset.ShouldBe(TimeSpan.Zero); normalized.ShouldBe(new DateTimeOffset(2026, 6, 15, 12, 30, 0, TimeSpan.Zero));}Test class structure
Section titled “Test class structure”Every test class is public sealed class. No inheritance hierarchies, no shared
base classes. Dependencies are initialized in the constructor:
public sealed class ClockTests{ private readonly FakeTimeProvider _fakeTimeProvider; private readonly ICurrentTimezoneProvider _timezoneProvider; private readonly Clock _clock;
public ClockTests() { _fakeTimeProvider = new FakeTimeProvider(); _timezoneProvider = Substitute.For<ICurrentTimezoneProvider>(); _clock = new Clock(_fakeTimeProvider, _timezoneProvider); }
// ... tests}Descriptive file headers
Section titled “Descriptive file headers”Each test file starts with a header describing what is tested and the approach:
// =============================================================================// Tests - AuditedEntityInterceptor// =============================================================================// Verifies that ISO 27001 audit fields are correctly populated// during entity creation and modification.//// Approach: register the interceptor in the DbContext and call// SaveChangesAsync directly, which triggers the interceptor naturally.// IClock is mocked for exact assertions.// =============================================================================CancellationToken in tests
Section titled “CancellationToken in tests”[Fact]public async Task GetAsync_ReturnsEntity(){ // Arrange var store = CreateStore();
// Act var result = await store.GetAsync(entityId, TestContext.Current.CancellationToken);
// Assert result.ShouldNotBeNull();}Assertions with Shouldly
Section titled “Assertions with Shouldly”Common patterns:
// Exact valueentity.CreatedAt.ShouldBe(fixedNow);
// Not emptyguid.ShouldNotBe(Guid.Empty);
// Collectionsut.Roles.ShouldBe(new[] { "admin", "practitioner" });
// Booleansut.IsAuthenticated.ShouldBeTrue();
// Nullsut.UserId.ShouldBeNull();
// Countguids.Count().ShouldBe(10_000, "all GUIDs should be unique");
// Contextual message for non-obvious assertionsnow.Offset.ShouldBe(TimeSpan.Zero, "Clock must always return UTC (ISO 27001 compliance)");Exception assertions
Section titled “Exception assertions”// SyncInvalidOperationException ex = Should.Throw<InvalidOperationException>(act);ex.Message.ShouldContain("expected text");
// AsyncInvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(act);ex.Message.ShouldContain("expected text");Mocking with NSubstitute
Section titled “Mocking with NSubstitute”Interface mocking
Section titled “Interface mocking”var clock = Substitute.For<IClock>();var fixedNow = new DateTimeOffset(2026, 6, 15, 10, 30, 0, TimeSpan.Zero);clock.Now.Returns(fixedNow);IOptions mocking
Section titled “IOptions mocking”// Using NSubstitutevar options = Substitute.For<IOptions<GuidGeneratorOptions>>();options.Value.Returns(new GuidGeneratorOptions{ DefaultSequentialGuidType = SequentialGuidType.SequentialAsString});
// Using the Options helper (simpler)var options = Microsoft.Extensions.Options.Options.Create(new VaultOptions{ TransitMountPoint = "transit"});Mocking strategy
Section titled “Mocking strategy”| Dependency | Strategy | Reason |
|---|---|---|
IClock | Mock | Deterministic time assertions |
IGuidGenerator | Mock | Control generated identifiers |
ICurrentUserService | Mock | Simulate different user contexts |
TimeProvider | FakeTimeProvider (real implementation) | Provided by Microsoft for time tests |
DbContext | In-memory (real implementation) | Test EF Core interceptors with real pipeline |
IVaultClient | Mock | No Vault in unit tests |
Deterministic time
Section titled “Deterministic time”Use FakeTimeProvider for time-dependent tests:
FakeTimeProvider fakeTimeProvider = new();ICurrentTimezoneProvider timezoneProvider = Substitute.For<ICurrentTimezoneProvider>();Clock clock = new(fakeTimeProvider, timezoneProvider);
// Pin time to a fixed instantDateTimeOffset fixedTime = new(2026, 6, 15, 10, 30, 0, TimeSpan.Zero);fakeTimeProvider.SetUtcNow(fixedTime);
clock.Now.ShouldBe(fixedTime);EF Core integration tests
Section titled “EF Core integration tests”Integration tests use UseInMemoryDatabase with a unique name per test for
isolation:
private TestDbContext CreateContext(){ var interceptor = new AuditedEntityInterceptor( _currentUserService, _clock, _guidGenerator); var options = new DbContextOptionsBuilder<TestDbContext>() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .AddInterceptors(interceptor) .Options; return new TestDbContext(options);}Define test entities and DbContexts as internal types within the test class:
private sealed class TestEntity : AuditedEntity{ public string Name { get; set; } = string.Empty;}
private sealed class TestDbContext : DbContext{ public TestDbContext(DbContextOptions<TestDbContext> options) : base(options) { }
public DbSet<TestEntity> TestEntities => Set<TestEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<TestEntity>() .Property(e => e.Id).ValueGeneratedNever(); }}Architecture tests
Section titled “Architecture tests”Granit.ArchitectureTests enforces structural rules across all packages using
ArchUnitNET (assembly analysis) and source code scanning (regex). These tests
cover:
- Class design — sealed DbContexts, no MVC controllers, sealed Options,
no public types in
Internalnamespaces - Layer dependencies — Core/Timing/Guids must not depend on EF Core,
Endpoints must not depend on EF Core, no
IQueryableoutside persistence - Naming conventions — interfaces start with
I, Reader/Writer suffixes, noDtosuffix in endpoints - Module conventions — modules must be sealed, names must start with
Granitand end withModule - File organization — modules at root, extensions in
Extensions/, DTOs inDtos/, etc. - Anti-patterns — no
async void, nothrow ex;, namespace matches directory structure
Run them with:
dotnet test tests/Granit.ArchitectureTestsRunning tests
Section titled “Running tests”# All testsdotnet test
# Specific packagedotnet test tests/Granit.Security.Tests
# Architecture tests onlydotnet test tests/Granit.ArchitectureTests
# With detailed outputdotnet test --logger "console;verbosity=detailed"