14 KiB
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
WebApplicationFactoryfor 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
TimeProviderandIGuidGeneratorfor determinism - Integration tests use
PostgresFixturewith Testcontainers CancellationTokenpropagation 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 — .NET implementation patterns
- CLAUDE.md — Stella Ops coding rules
- PostgreSQL Testing Guide — Testcontainers setup
- src/__Tests/AGENTS.md — Global test infrastructure