release orchestrator pivot, architecture and planning
This commit is contained in:
508
docs/modules/release-orchestrator/test-structure.md
Normal file
508
docs/modules/release-orchestrator/test-structure.md
Normal file
@@ -0,0 +1,508 @@
|
||||
# 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<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
|
||||
|
||||
```csharp
|
||||
[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
|
||||
|
||||
```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<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
|
||||
|
||||
```csharp
|
||||
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
|
||||
|
||||
```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/StellaOps.sln
|
||||
```
|
||||
|
||||
### Run Only Unit Tests
|
||||
|
||||
```bash
|
||||
dotnet test src/StellaOps.sln --filter "Category=Unit"
|
||||
```
|
||||
|
||||
### Run Only Integration Tests
|
||||
|
||||
```bash
|
||||
dotnet test src/StellaOps.sln --filter "Category=Integration"
|
||||
```
|
||||
|
||||
### Run Specific Test Class
|
||||
|
||||
```bash
|
||||
dotnet test --filter "FullyQualifiedName~PromotionValidatorTests"
|
||||
```
|
||||
|
||||
### Run with Coverage
|
||||
|
||||
```bash
|
||||
dotnet test src/StellaOps.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
|
||||
Reference in New Issue
Block a user