# Test Structure & Guidelines > Test organization, categorization, and patterns for Release Orchestrator modules. --- ## Test Directory Layout Release Orchestrator tests follow the Stella Ops standard test structure: ``` src/ReleaseOrchestrator/ ├── __Libraries/ │ ├── StellaOps.ReleaseOrchestrator.Core/ │ ├── StellaOps.ReleaseOrchestrator.Workflow/ │ ├── StellaOps.ReleaseOrchestrator.Promotion/ │ └── StellaOps.ReleaseOrchestrator.Deploy/ ├── __Tests/ │ ├── StellaOps.ReleaseOrchestrator.Core.Tests/ # Unit tests for Core │ ├── StellaOps.ReleaseOrchestrator.Workflow.Tests/ # Unit tests for Workflow │ ├── StellaOps.ReleaseOrchestrator.Promotion.Tests/ # Unit tests for Promotion │ ├── StellaOps.ReleaseOrchestrator.Deploy.Tests/ # Unit tests for Deploy │ ├── StellaOps.ReleaseOrchestrator.Integration.Tests/ # Integration tests │ └── StellaOps.ReleaseOrchestrator.Acceptance.Tests/ # End-to-end tests └── StellaOps.ReleaseOrchestrator.WebService/ ``` **Shared test infrastructure**: ``` src/__Tests/__Libraries/ ├── StellaOps.Infrastructure.Postgres.Testing/ # PostgreSQL Testcontainers fixtures └── StellaOps.Testing.Common/ # Common test utilities ``` --- ## Test Categories Tests **MUST** be categorized using xUnit traits to enable selective execution: ### Unit Tests ```csharp [Trait("Category", "Unit")] public class PromotionValidatorTests { [Fact] public void Validate_MissingReleaseId_ReturnsFalse() { // Arrange var validator = new PromotionValidator(); var promotion = new Promotion { ReleaseId = Guid.Empty }; // Act var result = validator.Validate(promotion); // Assert Assert.False(result.IsValid); Assert.Contains("ReleaseId is required", result.Errors); } } ``` **Characteristics**: - No database, network, or file system access - Fast execution (< 100ms per test) - Isolated from external dependencies - Deterministic and repeatable ### Integration Tests ```csharp [Trait("Category", "Integration")] public class PromotionRepositoryTests : IClassFixture { private readonly PostgresFixture _fixture; public PromotionRepositoryTests(PostgresFixture fixture) { _fixture = fixture; } [Fact] public async Task SaveAsync_ValidPromotion_PersistsToDatabase() { // Arrange await using var connection = _fixture.CreateConnection(); var repository = new PromotionRepository(connection, _fixture.TimeProvider); var promotion = new Promotion { Id = Guid.NewGuid(), TenantId = _fixture.DefaultTenantId, ReleaseId = Guid.NewGuid(), TargetEnvironmentId = Guid.NewGuid(), Status = PromotionState.PendingApproval, RequestedAt = _fixture.TimeProvider.GetUtcNow(), RequestedBy = Guid.NewGuid() }; // Act await repository.SaveAsync(promotion, CancellationToken.None); // Assert var retrieved = await repository.GetByIdAsync(promotion.Id, CancellationToken.None); Assert.NotNull(retrieved); Assert.Equal(promotion.ReleaseId, retrieved.ReleaseId); } } ``` **Characteristics**: - Uses Testcontainers for PostgreSQL - Requires Docker to be running - Slower execution (hundreds of ms per test) - Tests data access layer and database constraints ### Acceptance Tests ```csharp [Trait("Category", "Acceptance")] public class PromotionWorkflowTests : IClassFixture> { private readonly WebApplicationFactory _factory; private readonly HttpClient _client; public PromotionWorkflowTests(WebApplicationFactory factory) { _factory = factory; _client = factory.CreateClient(); } [Fact] public async Task PromotionWorkflow_EndToEnd_SuccessfullyDeploysRelease() { // Arrange: Create environment, release, and promotion var envId = await CreateEnvironmentAsync("Production"); var releaseId = await CreateReleaseAsync("v2.3.1"); // Act: Request promotion var promotionResponse = await _client.PostAsJsonAsync( "/api/v1/promotions", new { releaseId, targetEnvironmentId = envId }); promotionResponse.EnsureSuccessStatusCode(); var promotion = await promotionResponse.Content.ReadFromJsonAsync(); // Act: Approve promotion var approveResponse = await _client.PostAsync( $"/api/v1/promotions/{promotion.Id}/approve", null); approveResponse.EnsureSuccessStatusCode(); // Assert: Verify deployment completed var status = await GetPromotionStatusAsync(promotion.Id); Assert.Equal("deployed", status.Status); } } ``` **Characteristics**: - Tests full API surface and workflows - Uses `WebApplicationFactory` for in-memory hosting - Tests end-to-end scenarios - May involve multiple services --- ## PostgreSQL Test Fixtures ### Testcontainers Fixture ```csharp public class PostgresFixture : IAsyncLifetime { private PostgreSqlContainer? _container; private NpgsqlConnection? _connection; public TimeProvider TimeProvider { get; private set; } = null!; public IGuidGenerator GuidGenerator { get; private set; } = null!; public Guid DefaultTenantId { get; private set; } public async Task InitializeAsync() { // Start PostgreSQL container _container = new PostgreSqlBuilder() .WithImage("postgres:16") .WithDatabase("stellaops_test") .WithUsername("postgres") .WithPassword("postgres") .Build(); await _container.StartAsync(); // Create connection _connection = new NpgsqlConnection(_container.GetConnectionString()); await _connection.OpenAsync(); // Run migrations await ApplyMigrationsAsync(); // Setup test infrastructure TimeProvider = new ManualTimeProvider(); GuidGenerator = new SequentialGuidGenerator(); DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001"); // Seed test data await SeedTestDataAsync(); } public NpgsqlConnection CreateConnection() { if (_container == null) throw new InvalidOperationException("Container not initialized"); return new NpgsqlConnection(_container.GetConnectionString()); } private async Task ApplyMigrationsAsync() { // Apply schema migrations await ExecuteSqlFileAsync("schema/release-orchestrator-schema.sql"); } private async Task SeedTestDataAsync() { // Create default tenant await using var cmd = _connection!.CreateCommand(); cmd.CommandText = @" INSERT INTO tenants (id, name, created_at) VALUES (@id, @name, @created_at) ON CONFLICT DO NOTHING"; cmd.Parameters.AddWithValue("id", DefaultTenantId); cmd.Parameters.AddWithValue("name", "Test Tenant"); cmd.Parameters.AddWithValue("created_at", TimeProvider.GetUtcNow()); await cmd.ExecuteNonQueryAsync(); } public async Task DisposeAsync() { if (_connection != null) { await _connection.DisposeAsync(); } if (_container != null) { await _container.DisposeAsync(); } } } ``` --- ## Test Patterns ### Deterministic Time in Tests ```csharp public class PromotionTimingTests { [Fact] public void CreatePromotion_SetsCorrectTimestamp() { // Arrange var manualTime = new ManualTimeProvider(); manualTime.SetUtcNow(new DateTimeOffset(2026, 1, 10, 14, 30, 0, TimeSpan.Zero)); var guidGen = new SequentialGuidGenerator(); var manager = new PromotionManager(manualTime, guidGen); // Act var promotion = manager.CreatePromotion( releaseId: Guid.Parse("00000000-0000-0000-0000-000000000001"), targetEnvId: Guid.Parse("00000000-0000-0000-0000-000000000002") ); // Assert Assert.Equal( new DateTimeOffset(2026, 1, 10, 14, 30, 0, TimeSpan.Zero), promotion.RequestedAt ); } } ``` ### Testing CancellationToken Propagation ```csharp public class PromotionCancellationTests { [Fact] public async Task ApprovePromotionAsync_CancellationRequested_ThrowsOperationCanceledException() { // Arrange var cts = new CancellationTokenSource(); var repository = new Mock(); repository .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .Returns(async (Guid id, CancellationToken ct) => { await Task.Delay(100, ct); // Simulate delay return new Promotion { Id = id }; }); var manager = new PromotionManager(repository.Object, TimeProvider.System, new SystemGuidGenerator()); // Act & Assert cts.Cancel(); // Cancel before operation completes await Assert.ThrowsAsync(async () => await manager.ApprovePromotionAsync(Guid.NewGuid(), Guid.NewGuid(), cts.Token) ); } } ``` ### Testing Immutability ```csharp public class ReleaseImmutabilityTests { [Fact] public void GetComponents_ReturnsImmutableCollection() { // Arrange var release = new Release { Components = new Dictionary { ["api"] = new ComponentDigest("registry.io/api", "sha256:abc123", "v1.0.0") }.ToImmutableDictionary() }; // Act var components = release.Components; // Assert: Attempting to modify throws Assert.Throws(() => { var mutable = (IDictionary)components; mutable["web"] = new ComponentDigest("registry.io/web", "sha256:def456", "v1.0.0"); }); } } ``` ### Testing Evidence Hash Determinism ```csharp public class EvidenceHashDeterminismTests { [Fact] public void ComputeEvidenceHash_SameInputs_ProducesSameHash() { // Arrange var decisionRecord = new DecisionRecord { PromotionId = Guid.Parse("00000000-0000-0000-0000-000000000001"), DecidedAt = new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero), Outcome = "approved", GateResults = ImmutableArray.Create( new GateResult("security", "pass", null) ) }; // Act: Compute hash multiple times var hash1 = EvidenceHasher.ComputeHash(decisionRecord); var hash2 = EvidenceHasher.ComputeHash(decisionRecord); // Assert: Hashes are identical Assert.Equal(hash1, hash2); } } ``` --- ## Running Tests ### Run All Tests ```bash dotnet test src/ReleaseOrchestrator/StellaOps.ReleaseOrchestrator.sln ``` ### Run Only Unit Tests ```bash dotnet test src/ReleaseOrchestrator/StellaOps.ReleaseOrchestrator.sln --filter "Category=Unit" ``` ### Run Only Integration Tests ```bash dotnet test src/ReleaseOrchestrator/StellaOps.ReleaseOrchestrator.sln --filter "Category=Integration" ``` ### Run Specific Test Class ```bash dotnet test --filter "FullyQualifiedName~PromotionValidatorTests" ``` ### Run with Coverage ```bash dotnet test src/ReleaseOrchestrator/StellaOps.ReleaseOrchestrator.sln --collect:"XPlat Code Coverage" ``` --- ## Test Data Builders Use builder pattern for complex test data: ```csharp public class PromotionBuilder { private Guid _id = Guid.NewGuid(); private Guid _tenantId = Guid.NewGuid(); private Guid _releaseId = Guid.NewGuid(); private Guid _targetEnvId = Guid.NewGuid(); private PromotionState _status = PromotionState.PendingApproval; private DateTimeOffset _requestedAt = DateTimeOffset.UtcNow; public PromotionBuilder WithId(Guid id) { _id = id; return this; } public PromotionBuilder WithStatus(PromotionState status) { _status = status; return this; } public PromotionBuilder WithReleaseId(Guid releaseId) { _releaseId = releaseId; return this; } public Promotion Build() { return new Promotion { Id = _id, TenantId = _tenantId, ReleaseId = _releaseId, TargetEnvironmentId = _targetEnvId, Status = _status, RequestedAt = _requestedAt, RequestedBy = Guid.NewGuid() }; } } // Usage in tests [Fact] public void ApprovePromotion_PendingStatus_TransitionsToApproved() { var promotion = new PromotionBuilder() .WithStatus(PromotionState.PendingApproval) .Build(); // ... test logic } ``` --- ## Code Coverage Requirements - **Unit tests**: Aim for 80%+ coverage of business logic - **Integration tests**: Cover all data access paths and constraints - **Acceptance tests**: Cover critical user journeys **Exclusions from coverage**: - Program.cs / Startup.cs configuration code - DTOs and simple data classes - Generated code --- ## Summary Checklist Before merging: - [ ] All tests categorized with `[Trait("Category", "...")]` - [ ] Unit tests use `TimeProvider` and `IGuidGenerator` for determinism - [ ] Integration tests use `PostgresFixture` with Testcontainers - [ ] `CancellationToken` propagation tested where applicable - [ ] Evidence hash determinism verified - [ ] No test reimplements production logic - [ ] All tests pass locally and in CI - [ ] Code coverage meets requirements --- ## References - [Implementation Guide](./implementation-guide.md) — .NET implementation patterns - [CLAUDE.md](../../../CLAUDE.md) — Stella Ops coding rules - [PostgreSQL Testing Guide](../../infrastructure/Postgres.Testing/README.md) — Testcontainers setup - [src/__Tests/AGENTS.md](../../../src/__Tests/AGENTS.md) — Global test infrastructure