Files
git.stella-ops.org/docs/modules/release-orchestrator/test-structure.md
2026-01-22 19:08:46 +02:00

14 KiB

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

[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

[Trait("Category", "Integration")]
public class PromotionRepositoryTests : IClassFixture<PostgresFixture>
{
    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

[Trait("Category", "Acceptance")]
public class PromotionWorkflowTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public PromotionWorkflowTests(WebApplicationFactory<Program> 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<PromotionDto>();

        // 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

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

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

public class PromotionCancellationTests
{
    [Fact]
    public async Task ApprovePromotionAsync_CancellationRequested_ThrowsOperationCanceledException()
    {
        // Arrange
        var cts = new CancellationTokenSource();
        var repository = new Mock<IPromotionRepository>();

        repository
            .Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
            .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<OperationCanceledException>(async () =>
            await manager.ApprovePromotionAsync(Guid.NewGuid(), Guid.NewGuid(), cts.Token)
        );
    }
}

Testing Immutability

public class ReleaseImmutabilityTests
{
    [Fact]
    public void GetComponents_ReturnsImmutableCollection()
    {
        // Arrange
        var release = new Release
        {
            Components = new Dictionary<string, ComponentDigest>
            {
                ["api"] = new ComponentDigest("registry.io/api", "sha256:abc123", "v1.0.0")
            }.ToImmutableDictionary()
        };

        // Act
        var components = release.Components;

        // Assert: Attempting to modify throws
        Assert.Throws<NotSupportedException>(() =>
        {
            var mutable = (IDictionary<string, ComponentDigest>)components;
            mutable["web"] = new ComponentDigest("registry.io/web", "sha256:def456", "v1.0.0");
        });
    }
}

Testing Evidence Hash Determinism

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

dotnet test src/ReleaseOrchestrator/StellaOps.ReleaseOrchestrator.sln

Run Only Unit Tests

dotnet test src/ReleaseOrchestrator/StellaOps.ReleaseOrchestrator.sln --filter "Category=Unit"

Run Only Integration Tests

dotnet test src/ReleaseOrchestrator/StellaOps.ReleaseOrchestrator.sln --filter "Category=Integration"

Run Specific Test Class

dotnet test --filter "FullyQualifiedName~PromotionValidatorTests"

Run with Coverage

dotnet test src/ReleaseOrchestrator/StellaOps.ReleaseOrchestrator.sln --collect:"XPlat Code Coverage"

Test Data Builders

Use builder pattern for complex test data:

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