14 KiB
Cross-Cutting Testing Standards Guide
This guide documents the cross-cutting testing standards implemented for StellaOps, including blast-radius annotations, schema evolution testing, dead-path detection, and config-diff testing.
Sprint Reference: SPRINT_20260105_002_005_TEST_cross_cutting
Table of Contents
- Overview
- Blast-Radius Annotations
- Schema Evolution Testing
- Dead-Path Detection
- Config-Diff Testing
- CI Workflows
- Best Practices
Overview
Cross-cutting testing standards ensure consistent test quality across all modules:
| Standard | Purpose | Enforcement |
|---|---|---|
| Blast-Radius | Categorize tests by operational surface | CI validation on PRs |
| Schema Evolution | Verify backward compatibility | CI on schema changes |
| Dead-Path Detection | Identify uncovered code | CI with baseline comparison |
| Config-Diff | Validate config behavioral isolation | Integration tests |
Blast-Radius Annotations
Purpose
Blast-radius annotations categorize tests by the operational surfaces they affect. During incidents, this enables targeted test runs for specific areas (e.g., run only Auth-related tests when investigating an authentication issue).
Categories
| Category | Description | Examples |
|---|---|---|
Auth |
Authentication, authorization, tokens | Login, OAuth, DPoP |
Scanning |
SBOM generation, vulnerability scanning | Scanner, analyzers |
Evidence |
Attestation, evidence storage | EvidenceLocker, Attestor |
Compliance |
Audit, regulatory, GDPR | Compliance reports |
Advisories |
Advisory ingestion, VEX processing | Concelier, VexLens |
RiskPolicy |
Risk scoring, policy evaluation | RiskEngine, Policy |
Crypto |
Cryptographic operations | Signing, verification |
Integrations |
External systems, webhooks | Notifications, webhooks |
Persistence |
Database operations | Repositories, migrations |
Api |
API surface, contracts | Controllers, endpoints |
Usage
using StellaOps.TestKit;
using Xunit;
// Single blast-radius
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Auth)]
public class TokenValidationTests
{
[Fact]
public async Task ValidToken_ReturnsSuccess()
{
// Test implementation
}
}
// Multiple blast-radii (affects multiple surfaces)
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Auth)]
[Trait("BlastRadius", TestCategories.BlastRadius.Api)]
public class AuthenticatedApiTests
{
// Tests that affect both Auth and Api surfaces
}
Requirements
- Integration tests: Must have at least one BlastRadius annotation
- Contract tests: Must have at least one BlastRadius annotation
- Security tests: Must have at least one BlastRadius annotation
- Unit tests: BlastRadius optional but recommended
Running Tests by Blast-Radius
# Run all Auth-related tests
dotnet test --filter "BlastRadius=Auth"
# Run tests for multiple surfaces
dotnet test --filter "BlastRadius=Auth|BlastRadius=Api"
# Run incident response test suite
dotnet run --project src/__Libraries/StellaOps.TestKit \
-- run-blast-radius Auth,Api --fail-fast
Schema Evolution Testing
Purpose
Schema evolution tests verify that code remains compatible with previous database schema versions. This prevents breaking changes during:
- Rolling deployments (new code, old schema)
- Rollbacks (old code, new schema)
- Migration windows
Schema Versions
| Version | Description |
|---|---|
N |
Current schema (HEAD) |
N-1 |
Previous schema version |
N-2 |
Two versions back |
Using SchemaEvolutionTestBase
using StellaOps.Testing.SchemaEvolution;
using Testcontainers.PostgreSql;
using Xunit;
[Trait("Category", TestCategories.SchemaEvolution)]
public class ScannerSchemaEvolutionTests : PostgresSchemaEvolutionTestBase
{
public ScannerSchemaEvolutionTests()
: base(new SchemaEvolutionConfig
{
ModuleName = "Scanner",
CurrentVersion = new SchemaVersion("v2.1.0",
DateTimeOffset.Parse("2026-01-01")),
PreviousVersions =
[
new SchemaVersion("v2.0.0",
DateTimeOffset.Parse("2025-10-01")),
new SchemaVersion("v1.9.0",
DateTimeOffset.Parse("2025-07-01"))
],
ConnectionStringTemplate =
"Host={0};Port={1};Database={2};Username={3};Password={4}"
})
{
}
[Fact]
public async Task ReadOperations_CompatibleWithPreviousSchema()
{
var result = await TestReadBackwardCompatibilityAsync(
async (connection, version) =>
{
// Test read operations against old schema
var repository = new ScanRepository(connection);
var scans = await repository.GetRecentScansAsync(10);
return scans.Count >= 0;
});
Assert.True(result.IsSuccess);
}
[Fact]
public async Task WriteOperations_CompatibleWithPreviousSchema()
{
var result = await TestWriteForwardCompatibilityAsync(
async (connection, version) =>
{
// Test write operations
var repository = new ScanRepository(connection);
await repository.CreateScanAsync(new ScanRequest { /* ... */ });
return true;
});
Assert.True(result.IsSuccess);
}
}
Versioned Container Images
Build versioned PostgreSQL images for testing:
# Build all versions for a module
./devops/docker/schema-versions/build-schema-images.sh scanner
# Build specific version
./devops/docker/schema-versions/build-schema-images.sh scanner v2.0.0
# Use in tests
docker run -d -p 5432:5432 ghcr.io/stellaops/schema-test:scanner-v2.0.0
Dead-Path Detection
Purpose
Dead-path detection identifies uncovered code branches. This helps:
- Find untested edge cases
- Identify potentially dead code
- Prevent coverage regression
How It Works
- Tests run with branch coverage collection (Coverlet)
- Cobertura XML report is parsed
- Uncovered branches are identified
- New dead paths are compared against baseline
- CI fails if new dead paths are introduced
Baseline Management
The baseline file (dead-paths-baseline.json) tracks known dead paths:
{
"version": "1.0.0",
"activeDeadPaths": 42,
"totalDeadPaths": 50,
"exemptedPaths": 8,
"entries": [
{
"file": "src/Scanner/Services/AnalyzerService.cs",
"line": 128,
"coverage": "1/2",
"isExempt": false
}
]
}
Exemptions
Add exemptions for intentionally untested code in coverage-exemptions.yaml:
exemptions:
- path: "src/Authority/Emergency/BreakGlassHandler.cs:42"
category: emergency
justification: "Emergency access bypass - tested in incident drills"
added: "2026-01-06"
owner: "security-team"
- path: "src/Scanner/Platform/WindowsRegistryScanner.cs:*"
category: platform
justification: "Windows-only code - CI runs on Linux"
added: "2026-01-06"
owner: "scanner-team"
ignore_patterns:
- "*.Generated.cs"
- "**/Migrations/*.cs"
Using BranchCoverageEnforcer
using StellaOps.Testing.Coverage;
var enforcer = new BranchCoverageEnforcer(new BranchCoverageConfig
{
MinimumBranchCoverage = 80,
FailOnNewDeadPaths = true,
ExemptionFiles = ["coverage-exemptions.yaml"]
});
// Parse coverage report
var parser = new CoberturaParser();
var coverage = await parser.ParseFileAsync("coverage.cobertura.xml");
// Validate
var result = enforcer.Validate(coverage);
if (!result.IsValid)
{
foreach (var violation in result.Violations)
{
Console.WriteLine($"Violation: {violation.File}:{violation.Line}");
}
}
// Generate dead-path report
var report = enforcer.GenerateDeadPathReport(coverage);
Console.WriteLine($"Active dead paths: {report.ActiveDeadPaths}");
Config-Diff Testing
Purpose
Config-diff tests verify that configuration changes produce only expected behavioral deltas. This prevents:
- Unintended side effects from config changes
- Config options affecting unrelated behaviors
- Regressions in config handling
Using ConfigDiffTestBase
using StellaOps.Testing.ConfigDiff;
using Xunit;
[Trait("Category", TestCategories.ConfigDiff)]
public class ConcelierConfigDiffTests : ConfigDiffTestBase
{
[Fact]
public async Task ChangingCacheTimeout_OnlyAffectsCacheBehavior()
{
var baselineConfig = new ConcelierOptions
{
CacheTimeoutMinutes = 30,
MaxConcurrentDownloads = 10
};
var changedConfig = baselineConfig with
{
CacheTimeoutMinutes = 60
};
var result = await TestConfigIsolationAsync(
baselineConfig,
changedConfig,
changedSetting: "CacheTimeoutMinutes",
unrelatedBehaviors:
[
async config => await GetDownloadBehavior(config),
async config => await GetParseBehavior(config),
async config => await GetMergeBehavior(config)
]);
Assert.True(result.IsSuccess,
$"Unexpected changes: {string.Join(", ", result.UnexpectedChanges)}");
}
[Fact]
public async Task ChangingRetryPolicy_ProducesExpectedDelta()
{
var baseline = new ConcelierOptions { MaxRetries = 3 };
var changed = new ConcelierOptions { MaxRetries = 5 };
var expectedDelta = new ConfigDelta(
ChangedBehaviors: ["RetryCount", "TotalRequestTime"],
BehaviorDeltas:
[
new BehaviorDelta("RetryCount", "3", "5", null),
new BehaviorDelta("TotalRequestTime", "increase", null,
"More retries = longer total time")
]);
var result = await TestConfigBehavioralDeltaAsync(
baseline,
changed,
getBehavior: async config => await CaptureRetryBehavior(config),
computeDelta: ComputeBehaviorSnapshotDelta,
expectedDelta: expectedDelta);
Assert.True(result.IsSuccess);
}
}
Behavior Snapshots
Capture behavior at specific configuration states:
var snapshot = CreateSnapshotBuilder("baseline-config")
.AddBehavior("CacheHitRate", cacheMetrics.HitRate)
.AddBehavior("ResponseTime", responseMetrics.P99)
.AddBehavior("ErrorRate", errorMetrics.Rate)
.WithCapturedAt(DateTimeOffset.UtcNow)
.Build();
CI Workflows
Available Workflows
| Workflow | File | Trigger |
|---|---|---|
| Blast-Radius Validation | test-blast-radius.yml |
PRs with test changes |
| Dead-Path Detection | dead-path-detection.yml |
Push to main, PRs |
| Schema Evolution | schema-evolution.yml |
Schema/migration changes |
| Rollback Lag | rollback-lag.yml |
Manual trigger, weekly |
| Test Infrastructure | test-infrastructure.yml |
All changes, nightly |
Workflow Outputs
Each workflow posts results as PR comments:
## Test Infrastructure :white_check_mark: All checks passed
| Check | Status | Details |
|-------|--------|---------|
| Blast-Radius | :white_check_mark: | 0 violations |
| Dead-Path Detection | :white_check_mark: | Coverage: 82.5% |
| Schema Evolution | :white_check_mark: | Compatible: N-1,N-2 |
| Config-Diff | :white_check_mark: | Tested: Concelier,Authority,Scanner |
Running Locally
# Blast-radius validation
dotnet test --filter "Category=Integration" | grep BlastRadius
# Dead-path detection
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura
# Schema evolution (requires Docker)
docker-compose -f devops/compose/schema-test.yml up -d
dotnet test --filter "Category=SchemaEvolution"
# Config-diff
dotnet test --filter "Category=ConfigDiff"
Best Practices
General Guidelines
-
Test categories: Always categorize tests correctly
- Unit tests: Pure logic, no I/O
- Integration tests: Database, network, external systems
- Contract tests: API contracts, schemas
- Security tests: Authentication, authorization, injection
-
Blast-radius: Choose the narrowest applicable category
- If a test affects Auth only, use
BlastRadius.Auth - If it affects Auth and Api, use both
- If a test affects Auth only, use
-
Schema evolution: Test both read and write paths
- Read compatibility: Old data readable by new code
- Write compatibility: New code writes valid old-schema data
-
Dead-path exemptions: Document thoroughly
- Include justification
- Set owner and review date
- Remove when no longer applicable
-
Config-diff: Focus on high-impact options
- Security-related configs
- Performance-related configs
- Feature flags
Code Review Checklist
- Integration/Contract/Security tests have BlastRadius annotations
- Schema changes include evolution tests
- New branches have test coverage
- Config option tests verify isolation
- Exemptions have justifications
Troubleshooting
Blast-radius validation fails:
# Find tests missing BlastRadius
dotnet test --filter "Category=Integration" --list-tests | \
xargs -I {} grep -L "BlastRadius" {}
Dead-path baseline drift:
# Regenerate baseline
dotnet test /p:CollectCoverage=true
python extract-dead-paths.py coverage.cobertura.xml
cp dead-paths-report.json dead-paths-baseline.json
Schema evolution test fails:
# Check schema version compatibility
docker run -it ghcr.io/stellaops/schema-test:scanner-v2.0.0 \
psql -U stellaops_test -d stellaops_schema_test \
-c "SELECT * FROM _schema_metadata;"