Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Testing and Requirements

"All 487 tests pass. Are we done?" -- "Which feature do they verify?" -- "...all of them?" -- "Which acceptance criteria?" -- "...yes?"


The Domain Where Convention Hurts Most

Testing is the domain where the Convention Tax is most painful, because the gap between "tests pass" and "features are verified" is invisible until someone asks.

Every team writes tests. Most teams organize them. Some teams link them to requirements. Almost no team maintains those links when requirements change. The result: a green CI badge that proves your code works in isolation but cannot answer the question every stakeholder asks: "Is Feature-456 fully tested?"

This is not a tooling failure. It is a structural one. Tests and requirements are maintained in separate systems (code vs. Jira, code vs. wiki, code vs. spreadsheet). The link between them is either nonexistent, string-based, or convention-based -- and every option except the type system eventually drifts.

The four eras of testing-to-requirement traceability show the same progression as every other domain in this series. But here the stakes are higher, because a missing validator is one bug. A missing test for a critical acceptance criterion is a shipped defect.


Era 1: Code -- Tests Without Context

In the beginning, tests existed. They had names. Those names sometimes hinted at what they tested. But there was no formal link between a test and the feature it verified.

// Era 1: Code — tests exist, but what do they verify?
public class UserServiceTests
{
    private readonly UserService _sut;
    private readonly Mock<IUserRepository> _repo;
    private readonly Mock<IRoleRepository> _roleRepo;

    public UserServiceTests()
    {
        _repo = new Mock<IUserRepository>();
        _roleRepo = new Mock<IRoleRepository>();
        _sut = new UserService(_repo.Object, _roleRepo.Object);
    }

    [Fact]
    public async Task AssignRole_ShouldWork()
    {
        // Arrange
        var admin = new User { Id = Guid.NewGuid(), Role = "Admin" };
        var target = new User { Id = Guid.NewGuid(), Role = "Viewer" };
        _repo.Setup(r => r.GetByIdAsync(admin.Id))
            .ReturnsAsync(admin);
        _repo.Setup(r => r.GetByIdAsync(target.Id))
            .ReturnsAsync(target);

        // Act
        var result = await _sut.AssignRoleAsync(
            admin.Id, target.Id, "Editor");

        // Assert
        result.IsSuccess.Should().BeTrue();
        _repo.Verify(r => r.UpdateAsync(
            It.Is<User>(u => u.Role == "Editor")), Times.Once);
    }

    [Fact]
    public async Task AssignRole_NonAdmin_ShouldFail()
    {
        var viewer = new User { Id = Guid.NewGuid(), Role = "Viewer" };
        var target = new User { Id = Guid.NewGuid(), Role = "Viewer" };
        _repo.Setup(r => r.GetByIdAsync(viewer.Id))
            .ReturnsAsync(viewer);
        _repo.Setup(r => r.GetByIdAsync(target.Id))
            .ReturnsAsync(target);

        var result = await _sut.AssignRoleAsync(
            viewer.Id, target.Id, "Editor");

        result.IsSuccess.Should().BeFalse();
        result.Error.Should().Be("Only admins can assign roles");
    }

    [Fact]
    public async Task GetUser_ShouldReturnUser()
    {
        var user = new User { Id = Guid.NewGuid(), Role = "Admin" };
        _repo.Setup(r => r.GetByIdAsync(user.Id))
            .ReturnsAsync(user);

        var result = await _sut.GetByIdAsync(user.Id);

        result.Should().NotBeNull();
        result!.Id.Should().Be(user.Id);
    }

    [Fact]
    public async Task DeleteUser_ShouldSoftDelete()
    {
        var user = new User { Id = Guid.NewGuid(), IsDeleted = false };
        _repo.Setup(r => r.GetByIdAsync(user.Id))
            .ReturnsAsync(user);

        await _sut.DeleteAsync(user.Id);

        _repo.Verify(r => r.UpdateAsync(
            It.Is<User>(u => u.IsDeleted)), Times.Once);
    }

    [Fact]
    public async Task ListUsers_ShouldFilterDeleted()
    {
        _repo.Setup(r => r.GetAllAsync())
            .ReturnsAsync(new[]
            {
                new User { Id = Guid.NewGuid(), IsDeleted = false },
                new User { Id = Guid.NewGuid(), IsDeleted = true },
            });

        var result = await _sut.ListActiveUsersAsync();

        result.Should().HaveCount(1);
    }
}

Five tests. All green. Now the PM asks: "Is the User Roles feature fully tested?"

The answer requires reading every test name, mentally mapping it to a requirement that lives in Jira, and hoping the mapping is correct. Which tests cover "Admin can assign roles"? Probably AssignRole_ShouldWork and AssignRole_NonAdmin_ShouldFail. Which tests cover "Role changes take effect immediately"? None of these. Is that a gap? Maybe. Maybe another test class covers it. Maybe not. Nobody knows without a manual audit.

What goes wrong:

  • 500 tests pass. Nobody can answer "which feature is 100% tested" without reading code.
  • A requirement is added in Jira. No corresponding test is created. Nobody notices for months.
  • A test is deleted during a refactor. The feature it covered is now untested. Nobody notices.
  • Two teams test the same feature differently. Neither knows the other's tests exist.
  • A feature is split into two features during a sprint planning. Half the tests now cover the wrong feature. Nobody knows which half.
  • The test suite grows to 2,000 tests. The mapping from test to feature exists only in developers' heads -- and they leave.
  • An auditor asks for a traceability matrix. The tech lead spends three days building one in a spreadsheet by reading test names and guessing which feature they cover.

The fundamental problem: tests are about code correctness, not feature completeness. A test named AssignRole_ShouldWork proves that the method behaves as coded. It does not prove that the feature "Admin can assign roles" is fully verified. Those are different claims, and the gap between them is exactly where defects hide.


Era 2: Configuration -- Trait-Based Categories

xUnit introduced [Trait] attributes. NUnit has [Category]. MSTest has [TestCategory]. The idea: attach metadata to tests so you can filter and report by category.

// Era 2: Configuration — string-based Trait metadata
public class UserServiceTests
{
    private readonly UserService _sut;
    private readonly Mock<IUserRepository> _repo;
    private readonly Mock<IRoleRepository> _roleRepo;

    public UserServiceTests()
    {
        _repo = new Mock<IUserRepository>();
        _roleRepo = new Mock<IRoleRepository>();
        _sut = new UserService(_repo.Object, _roleRepo.Object);
    }

    [Fact]
    [Trait("Feature", "FEATURE-456")]
    [Trait("AC", "AdminCanAssignRoles")]
    [Trait("Priority", "High")]
    public async Task AssignRole_AdminCanAssign_ShouldSucceed()
    {
        var admin = new User { Id = Guid.NewGuid(), Role = "Admin" };
        var target = new User { Id = Guid.NewGuid(), Role = "Viewer" };
        _repo.Setup(r => r.GetByIdAsync(admin.Id))
            .ReturnsAsync(admin);
        _repo.Setup(r => r.GetByIdAsync(target.Id))
            .ReturnsAsync(target);

        var result = await _sut.AssignRoleAsync(
            admin.Id, target.Id, "Editor");

        result.IsSuccess.Should().BeTrue();
    }

    [Fact]
    [Trait("Feature", "FEATURE-456")]
    [Trait("AC", "AdminCanAssignRoles")]
    public async Task AssignRole_NonAdmin_ShouldBeRejected()
    {
        var viewer = new User { Id = Guid.NewGuid(), Role = "Viewer" };
        var target = new User { Id = Guid.NewGuid(), Role = "Viewer" };
        _repo.Setup(r => r.GetByIdAsync(viewer.Id))
            .ReturnsAsync(viewer);
        _repo.Setup(r => r.GetByIdAsync(target.Id))
            .ReturnsAsync(target);

        var result = await _sut.AssignRoleAsync(
            viewer.Id, target.Id, "Editor");

        result.IsSuccess.Should().BeFalse();
    }

    [Fact]
    [Trait("Feature", "FEATURE-456")]
    [Trait("AC", "ViewerHasReadOnlyAccess")]
    public async Task Viewer_CannotModifyResources()
    {
        var viewer = new User { Id = Guid.NewGuid(), Role = "Viewer" };

        var result = await _sut.UpdateResourceAsync(
            viewer.Id, Guid.NewGuid(), "new value");

        result.IsSuccess.Should().BeFalse();
        result.Error.Should().Contain("read-only");
    }

    [Fact]
    [Trait("Feature", "FEAT-456")]  // Typo: FEAT vs FEATURE
    [Trait("AC", "RoleChangeImmediate")]
    public async Task RoleChange_TakesEffectWithoutRestart()
    {
        var user = new User { Id = Guid.NewGuid(), Role = "Viewer" };
        _repo.Setup(r => r.GetByIdAsync(user.Id))
            .ReturnsAsync(user);

        await _sut.AssignRoleAsync(
            Guid.NewGuid(), user.Id, "Editor");

        // Verify the change is immediately visible
        var updated = await _sut.GetByIdAsync(user.Id);
        updated!.Role.Should().Be("Editor");
    }
}

Now you can run dotnet test --filter "Feature=FEATURE-456" and get a feature-specific test report. Progress.

But look at the fourth test. [Trait("Feature", "FEAT-456")] -- a typo. It compiles. It runs. It passes. But it will never show up in the FEATURE-456 report. The test exists. The traceability link is silently broken.

What goes wrong:

  • "FEATURE-456" vs "Feature-456" vs "FEAT-456" vs "feature_456" -- all compile fine, all are different filter keys.
  • Someone renames the feature in Jira from FEATURE-456 to FEATURE-789. The Trait strings in 47 test methods still say FEATURE-456. Nobody updates them.
  • AC names in traits are free-form strings. "AdminCanAssignRoles" vs "Admin_Can_Assign_Roles" vs "AC1". No standard. No validation.
  • Completeness is uncheckable. You cannot ask the compiler "does every AC have at least one test?" because the compiler doesn't know what ACs exist -- they are in Jira.
  • The [Trait] attribute takes string, string. There is no type constraint, no enum, no compile-time check. It is Configuration-era thinking (key-value pairs) wearing an attribute hat.
  • Priority metadata is also a string: [Trait("Priority", "High")]. What does "High" mean? Is it the test priority or the feature priority? Nobody agrees. Nobody enforces consistency.
  • When a test fails, the developer sees FAIL: AssignRole_AdminCanAssign_ShouldSucceed. They must open the file, find the Trait, copy the string, search Jira for the feature, and then understand the business context. In a 500-test failure log, this process repeats 500 times.

The Configuration era improves on Code by adding metadata. But metadata made of strings is metadata the compiler cannot verify. It is better than nothing -- you can at least filter tests by feature. But filtering is not verification. A filter that returns 12 tests for FEATURE-456 does not tell you whether those 12 tests cover all 4 acceptance criteria. It tells you that 12 test methods happen to contain the string "FEATURE-456" in a Trait attribute.

There is a deeper issue: [Trait] is general-purpose metadata. It was designed for test filtering, not for requirement traceability. Using it for traceability is like using a screwdriver as a chisel -- it works in a pinch, but the tool was not designed for the job, and the results show.

A purpose-built attribute like [ForRequirement(typeof(Feature))] carries type information that the compiler can check. A general-purpose attribute like [Trait("Feature", "FEATURE-456")] carries strings that the compiler ignores. The difference is not cosmetic. It is structural. One approach creates a link that survives refactoring. The other creates a label that looks like a link.

Diagram

Three artifacts. Two of them disconnected. One string typo breaks the link. The compiler sees nothing wrong.


Era 3: Convention -- Folder Structure and Naming Rules

Convention says: organize tests by feature. Name test classes after acceptance criteria. Use a coverage gate. Write a compliance script. Document everything.

The Convention

MyApp.Tests/
├── Features/
│   ├── UserRoles/
│   │   ├── AdminCanAssignRoles_Tests.cs
│   │   ├── ViewerHasReadOnlyAccess_Tests.cs
│   │   └── RoleChangeTakesEffectImmediately_Tests.cs
│   ├── OrderProcessing/
│   │   ├── OrderCanBeCreated_Tests.cs
│   │   ├── OrderCanBeCancelled_Tests.cs
│   │   ├── OrderTotalIsCalculatedCorrectly_Tests.cs
│   │   └── PaymentIsProcessedOnSubmission_Tests.cs
│   ├── Notifications/
│   │   ├── UserReceivesEmailOnOrderConfirmation_Tests.cs
│   │   └── AdminReceivesAlertOnCriticalError_Tests.cs
│   └── Authentication/
│       ├── TokensExpireAfterOneHour_Tests.cs
│       ├── RefreshTokenExtendsSession_Tests.cs
│       └── InvalidCredentialsReturnUnauthorized_Tests.cs
├── Integration/
│   └── ...
└── Smoke/
    └── ...
// Era 3: Convention — folder = feature, class = AC, name = behavior
namespace MyApp.Tests.Features.UserRoles;

/// <summary>
/// Convention: class name = AC name + "_Tests"
/// Convention: folder = feature name
/// Convention: each method tests one scenario of the AC
/// </summary>
public class AdminCanAssignRoles_Tests : IClassFixture<TestFixture>
{
    private readonly TestFixture _fixture;

    public AdminCanAssignRoles_Tests(TestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task Admin_Assigns_Editor_Role_To_Viewer()
    {
        // Arrange
        var admin = await _fixture.CreateUserAsync("Admin");
        var viewer = await _fixture.CreateUserAsync("Viewer");

        // Act
        var result = await _fixture.UserService
            .AssignRoleAsync(admin.Id, viewer.Id, "Editor");

        // Assert
        result.IsSuccess.Should().BeTrue();
        var updated = await _fixture.UserService
            .GetByIdAsync(viewer.Id);
        updated!.Role.Should().Be("Editor");
    }

    [Fact]
    public async Task NonAdmin_Cannot_Assign_Roles()
    {
        var viewer = await _fixture.CreateUserAsync("Viewer");
        var target = await _fixture.CreateUserAsync("Viewer");

        var result = await _fixture.UserService
            .AssignRoleAsync(viewer.Id, target.Id, "Editor");

        result.IsSuccess.Should().BeFalse();
        result.Error.Should().Be("Only admins can assign roles");
    }

    [Fact]
    public async Task Admin_Cannot_Assign_NonExistent_Role()
    {
        var admin = await _fixture.CreateUserAsync("Admin");
        var target = await _fixture.CreateUserAsync("Viewer");

        var result = await _fixture.UserService
            .AssignRoleAsync(admin.Id, target.Id, "SuperGod");

        result.IsSuccess.Should().BeFalse();
        result.Error.Should().Contain("Role 'SuperGod' does not exist");
    }
}

The folder structure is the convention. The naming is the convention. Now it needs documentation and enforcement.

The Documentation (~50 lines of wiki)

<!-- wiki/test-conventions.md — the document every team writes -->

# Test Organization Conventions

## Folder Structure
- All feature tests live under `Features/{FeatureName}/`
- Feature folder names must match the Jira feature title (PascalCase, no spaces)
- Each acceptance criterion gets its own test class: `{ACName}_Tests.cs`
- Integration tests live under `Integration/{FeatureName}/`
- Smoke tests live under `Smoke/`

## Naming Conventions
- Test class: `{AcceptanceCriterionName}_Tests`
- Test method: `{Scenario}_Should_{ExpectedResult}` or `{Given}_{When}_{Then}`
- Fixture class: `{FeatureName}Fixture`

## Requirement Coverage
- Every feature in Jira MUST have a corresponding folder in `Features/`
- Every AC in Jira MUST have a corresponding test class
- Minimum 2 test methods per AC (happy path + at least one failure path)
- Coverage gate: 80% line coverage minimum per feature folder

## When Adding a New Feature
1. Create folder: `Features/{FeatureName}/`
2. Create one test class per AC
3. Write minimum 2 tests per AC class
4. Verify coverage meets 80% threshold
5. Update the requirements matrix spreadsheet (SharePoint)

## When Modifying an Existing Feature
1. Check if AC list in Jira has changed
2. Add/remove test classes to match
3. Update tests in affected AC classes
4. Re-verify coverage threshold
5. Update the requirements matrix spreadsheet (SharePoint)

## Exceptions
- Utility tests (helpers, extensions) go in `Utilities/`
- Performance tests go in `Performance/` with `[Trait("Type", "Perf")]`
- Tests that span multiple features go in `CrossCutting/` with Traits for each feature

Fifty lines of prose. Five sections. Two processes (new feature, modified feature). One spreadsheet to maintain. All of it exists outside the compiler.

The Enforcement Code (~60 lines)

// Convention enforcement: architecture tests that verify test organization
public class TestConventionTests
{
    private static readonly string[] KnownFeatures = new[]
    {
        "UserRoles", "OrderProcessing", "Notifications",
        "Authentication", "Payments", "Inventory",
        "Reporting", "AuditLog"
        // Manually maintained. Always out of date.
    };

    [Fact]
    public void Every_Feature_Has_A_Test_Folder()
    {
        var testAssembly = typeof(TestConventionTests).Assembly;
        var testNamespaces = testAssembly.GetTypes()
            .Where(t => t.Namespace?.Contains("Features") == true)
            .Select(t => t.Namespace!.Split('.').Last())
            .Distinct()
            .ToHashSet();

        foreach (var feature in KnownFeatures)
        {
            testNamespaces.Should().Contain(feature,
                $"Feature '{feature}' must have a test folder " +
                $"under Features/");
        }
    }

    [Fact]
    public void Test_Classes_Follow_Naming_Convention()
    {
        var testTypes = typeof(TestConventionTests).Assembly
            .GetTypes()
            .Where(t => t.Namespace?.Contains("Features") == true)
            .Where(t => t.IsClass && t.IsPublic);

        foreach (var type in testTypes)
        {
            type.Name.Should().EndWith("_Tests",
                $"Test class '{type.Name}' must follow the " +
                $"'{{ACName}}_Tests' naming convention");
        }
    }

    [Fact]
    public void Each_Test_Class_Has_Minimum_Two_Tests()
    {
        var testTypes = typeof(TestConventionTests).Assembly
            .GetTypes()
            .Where(t => t.Namespace?.Contains("Features") == true)
            .Where(t => t.IsClass && t.IsPublic);

        foreach (var type in testTypes)
        {
            var factCount = type.GetMethods()
                .Count(m => m.GetCustomAttribute<FactAttribute>() != null
                    || m.GetCustomAttribute<TheoryAttribute>() != null);

            factCount.Should().BeGreaterOrEqualTo(2,
                $"Test class '{type.Name}' must have at least 2 " +
                $"test methods (happy + failure path)");
        }
    }
}
#!/bin/bash
# ci/check-test-coverage.sh — coverage gate per feature folder

THRESHOLD=80

# Run tests with coverage
dotnet test --collect:"XPlat Code Coverage" \
    --results-directory ./coverage

# Parse coverage per feature folder
for feature_dir in test/MyApp.Tests/Features/*/; do
    feature=$(basename "$feature_dir")
    coverage=$(reportgenerator \
        -reports:./coverage/**/coverage.cobertura.xml \
        -targetdir:./coverage/report \
        -reporttypes:TextSummary \
        -classfilters:"+MyApp.*$feature*" \
        | grep "Line coverage" \
        | grep -oP '\d+\.\d+')

    if (( $(echo "$coverage < $THRESHOLD" | bc -l) )); then
        echo "FAIL: Feature '$feature' coverage is ${coverage}% " \
             "(threshold: ${THRESHOLD}%)"
        exit 1
    fi
    echo "PASS: Feature '$feature' coverage: ${coverage}%"
done

And the CI pipeline that ties it all together:

# .github/workflows/test-compliance.yml
name: Test Compliance Check

on: [push, pull_request]

jobs:
  test-compliance:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tests with coverage
        run: |
          dotnet test --collect:"XPlat Code Coverage" \
            --results-directory ./coverage

      - name: Check feature folder coverage
        run: bash ci/check-test-coverage.sh

      - name: Verify test naming conventions
        run: |
          dotnet test --filter "FullyQualifiedName~TestConventionTests" \
            --logger "console;verbosity=detailed"

      - name: Generate requirements matrix
        run: |
          # Parse test results and cross-reference with requirements list
          python3 scripts/generate-requirements-matrix.py \
            --requirements requirements.csv \
            --test-results ./coverage \
            --output matrix-report.html

      - name: Upload compliance report
        uses: actions/upload-artifact@v4
        with:
          name: compliance-report
          path: matrix-report.html

      - name: Comment on PR with coverage gaps
        if: github.event_name == 'pull_request'
        run: |
          # Parse the matrix and post gaps as PR comment
          python3 scripts/post-coverage-gaps.py \
            --matrix matrix-report.html \
            --pr ${{ github.event.pull_request.number }}

Count the overhead: 40 lines of CI pipeline. And it depends on two Python scripts (generate-requirements-matrix.py and post-coverage-gaps.py) that someone must write and maintain -- another 100-200 lines. The enforcement infrastructure for one convention now spans five files across three languages.

Count the Convention Tax:

Artifact Lines Purpose
Wiki: test-conventions.md ~50 Document the convention
Architecture tests ~45 Enforce folder/naming structure
CI coverage script ~20 Enforce coverage threshold
CI pipeline YAML ~40 Orchestrate compliance checks
Python matrix scripts ~150 Generate cross-reference reports
KnownFeatures array ~10 Manually maintained feature list
SharePoint matrix (external) Requirement-to-test mapping
Total overhead ~315 None of this ships to production

And the KnownFeatures array is the fatal flaw. It is a manually maintained list that must match Jira. When someone adds a feature in Jira and forgets to add it to the array, the architecture test does not flag the missing test folder -- because the architecture test does not know the feature exists. The enforcement code has the same drift problem as the convention it enforces.

Diagram

Seven artifacts. Six drift-prone links. One green CI badge that proves structure compliance but cannot prove feature coverage. The PM still cannot answer "is Feature-456 fully tested?" without opening the SharePoint matrix and hoping it was updated after the last sprint.

The Failure Modes

Convention-based test compliance fails in specific, predictable ways:

The stale array. The KnownFeatures array is the canonical list of features. When a new feature is added to Jira and the developer creates the implementation but forgets to add the feature name to the array, the architecture test does not flag the missing test folder. The convention enforcement has a hole exactly where it matters most: new features.

The renamed feature. A feature is renamed from "UserRoles" to "RoleBasedAccessControl" during a sprint. The developer renames the implementation namespace. The test folder is not renamed because the developer does not know it exists (they joined last month and did not read the wiki). The architecture test still passes because "UserRoles" is still in the KnownFeatures array. The SharePoint matrix still references "UserRoles." Now there are two names for the same feature and no enforcement catches the inconsistency.

The split feature. A large feature is split into two smaller features. The test folder now contains tests for both. Some tests are moved to a new folder; others stay. The architecture test checks that both folders exist but cannot check that the right tests are in the right folder. The coverage gate checks per-folder coverage but cannot check per-AC coverage.

The cross-cutting test. A test verifies behavior that spans two features (e.g., "when a user's role is changed, their cached permissions are invalidated"). Which feature folder does it live in? The convention says "put it in CrossCutting/ with Traits." But Traits are strings, and we are back to the Configuration era for cross-cutting tests. The convention has a carve-out for the cases where it does not work, and the carve-out uses the very mechanism (strings) that the convention was supposed to improve upon.

The abandoned test class. A feature is removed from the product. The test folder remains because nobody deletes test folders during a sprint. The architecture test does not flag orphaned folders because it only checks that known features have folders, not that all folders correspond to known features. The dead test class runs in CI, consuming time and producing results that verify a feature nobody cares about.

The coverage illusion. The coverage gate says 80% per folder. But coverage measures lines executed, not acceptance criteria verified. A test that calls AssignRole and does not assert anything achieves 100% line coverage of the method while verifying nothing. The convention confuses code coverage (a measure of execution) with requirement coverage (a measure of verification). These are fundamentally different metrics, and the convention conflates them because it has no way to express the difference.

Every one of these failure modes is a consequence of the same structural problem: the convention uses incidental relationships (folder names, file names, string arrays) to express essential relationships (requirement-to-test links). Incidental relationships can drift because the compiler does not enforce them. Essential relationships should be expressed in the type system, where drift is impossible.


Era 4: Contention -- Requirements ARE Types, Tests Reference Types

The Requirements as Code approach treats features as abstract classes and acceptance criteria as abstract methods. The Contention era extends this to testing: test classes reference requirement types, and test methods reference AC methods. The compiler verifies every link. The SG generates the compliance matrix.

The Requirement (Already Exists -- It IS the Type)

// Requirements are types. ACs are abstract methods.
// This IS the single source of truth.
namespace MyApp.Requirements.Features;

public abstract record UserRolesFeature : Feature<PlatformScalabilityEpic>
{
    public override string Title => "User roles and permissions";
    public override RequirementPriority Priority => RequirementPriority.High;
    public override string Owner => "auth-team";

    // AC1: Admin users can assign roles to other users
    public abstract AcceptanceCriterionResult AdminCanAssignRoles(
        UserId actingUser, UserId targetUser, RoleId role);

    // AC2: Viewer users have read-only access
    public abstract AcceptanceCriterionResult ViewerHasReadOnlyAccess(
        UserId viewer, ResourceId resource);

    // AC3: Role changes take effect immediately
    public abstract AcceptanceCriterionResult RoleChangeTakesEffectImmediately(
        UserId user, RoleId previousRole, RoleId newRole);
}

The Test Class -- Typed Reference, Not String

// Era 4: Contention — typed requirement and AC references
namespace MyApp.Tests.Features;

[ForRequirement(typeof(UserRolesFeature))]
public class UserRolesFeature_Tests : IClassFixture<TestFixture>
{
    private readonly TestFixture _fixture;

    public UserRolesFeature_Tests(TestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    [Verifies(nameof(UserRolesFeature.AdminCanAssignRoles))]
    public async Task Admin_Assigns_Editor_Role_To_Viewer()
    {
        var admin = await _fixture.CreateUserAsync("Admin");
        var viewer = await _fixture.CreateUserAsync("Viewer");

        var result = await _fixture.UserService
            .AssignRoleAsync(admin.Id, viewer.Id, "Editor");

        result.IsSuccess.Should().BeTrue();
        var updated = await _fixture.UserService
            .GetByIdAsync(viewer.Id);
        updated!.Role.Should().Be("Editor");
    }

    [Fact]
    [Verifies(nameof(UserRolesFeature.AdminCanAssignRoles))]
    public async Task NonAdmin_Cannot_Assign_Roles()
    {
        var viewer = await _fixture.CreateUserAsync("Viewer");
        var target = await _fixture.CreateUserAsync("Viewer");

        var result = await _fixture.UserService
            .AssignRoleAsync(viewer.Id, target.Id, "Editor");

        result.IsSuccess.Should().BeFalse();
        result.Error.Should().Be("Only admins can assign roles");
    }

    [Fact]
    [Verifies(nameof(UserRolesFeature.AdminCanAssignRoles))]
    public async Task Admin_Cannot_Assign_NonExistent_Role()
    {
        var admin = await _fixture.CreateUserAsync("Admin");
        var target = await _fixture.CreateUserAsync("Viewer");

        var result = await _fixture.UserService
            .AssignRoleAsync(admin.Id, target.Id, "SuperGod");

        result.IsSuccess.Should().BeFalse();
    }

    [Fact]
    [Verifies(nameof(UserRolesFeature.ViewerHasReadOnlyAccess))]
    public async Task Viewer_Cannot_Modify_Resources()
    {
        var viewer = await _fixture.CreateUserAsync("Viewer");

        var result = await _fixture.UserService
            .UpdateResourceAsync(viewer.Id, Guid.NewGuid(), "new value");

        result.IsSuccess.Should().BeFalse();
        result.Error.Should().Contain("read-only");
    }

    [Fact]
    [Verifies(nameof(UserRolesFeature.ViewerHasReadOnlyAccess))]
    public async Task Viewer_Can_Read_Resources()
    {
        var viewer = await _fixture.CreateUserAsync("Viewer");
        var resource = await _fixture.CreateResourceAsync();

        var result = await _fixture.UserService
            .GetResourceAsync(viewer.Id, resource.Id);

        result.IsSuccess.Should().BeTrue();
    }

    [Fact]
    [Verifies(nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
    public async Task RoleChange_Is_Visible_Without_Restart()
    {
        var admin = await _fixture.CreateUserAsync("Admin");
        var user = await _fixture.CreateUserAsync("Viewer");

        await _fixture.UserService
            .AssignRoleAsync(admin.Id, user.Id, "Editor");

        // No restart. No cache invalidation. Immediate.
        var permissions = await _fixture.UserService
            .GetPermissionsAsync(user.Id);
        permissions.Should().Contain("edit");
    }
}

Look at what changed:

  1. [ForRequirement(typeof(UserRolesFeature))] -- a type reference. Rename the feature? The compiler updates it. Delete the feature? The compiler breaks. Typo? Impossible -- typeof() only accepts types that exist.

  2. [Verifies(nameof(UserRolesFeature.AdminCanAssignRoles))] -- a member reference. Rename the AC method? nameof() follows. Delete the AC? The compiler breaks. Reference a nonexistent AC? nameof() does not compile.

  3. No folder convention. No naming convention. No wiki. The test class can live anywhere -- in any folder, in any project, in any namespace. The [ForRequirement] attribute IS the link. The compiler enforces it. You could put the test class in a folder called Miscellaneous/ and it would still appear in the compliance matrix for UserRolesFeature because the link is semantic (type reference), not structural (folder name).

The Attributes

namespace MyApp.Requirements;

/// <summary>
/// Links a test class to the requirement it verifies.
/// The type parameter must be a concrete requirement type.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class ForRequirementAttribute : Attribute
{
    public Type RequirementType { get; }

    public ForRequirementAttribute(Type requirementType)
    {
        RequirementType = requirementType;
    }
}

/// <summary>
/// Links a test method to the acceptance criterion it verifies.
/// The name must match an abstract method on the requirement type.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class VerifiesAttribute : Attribute
{
    public string AcceptanceCriterionName { get; }

    public VerifiesAttribute(string acceptanceCriterionName)
    {
        AcceptanceCriterionName = acceptanceCriterionName;
    }
}

ForRequirementAttribute takes a Type. VerifiesAttribute takes a string -- but the call site uses nameof(), so the compiler checks it. This is the Contention pattern: attributes whose arguments are compiler-verified references, not developer-supplied strings.

What the Source Generator Produces

The SG scans the test assembly for [ForRequirement] and [Verifies] attributes, cross-references them with the requirement types, and generates a compliance matrix.

// Generated: RequirementComplianceMatrix.g.cs
// <auto-generated>
//   Generated by MyApp.Requirements.Analyzers
//   Do not edit manually.
// </auto-generated>

namespace MyApp.Tests.Generated;

public static class RequirementComplianceMatrix
{
    public static readonly IReadOnlyList<FeatureCompliance> Features = new[]
    {
        new FeatureCompliance(
            FeatureType: typeof(UserRolesFeature),
            Title: "User roles and permissions",
            Priority: RequirementPriority.High,
            AcceptanceCriteria: new[]
            {
                new ACCompliance(
                    Name: "AdminCanAssignRoles",
                    TestCount: 3,
                    Tests: new[]
                    {
                        "UserRolesFeature_Tests.Admin_Assigns_Editor_Role_To_Viewer",
                        "UserRolesFeature_Tests.NonAdmin_Cannot_Assign_Roles",
                        "UserRolesFeature_Tests.Admin_Cannot_Assign_NonExistent_Role"
                    }),
                new ACCompliance(
                    Name: "ViewerHasReadOnlyAccess",
                    TestCount: 2,
                    Tests: new[]
                    {
                        "UserRolesFeature_Tests.Viewer_Cannot_Modify_Resources",
                        "UserRolesFeature_Tests.Viewer_Can_Read_Resources"
                    }),
                new ACCompliance(
                    Name: "RoleChangeTakesEffectImmediately",
                    TestCount: 1,
                    Tests: new[]
                    {
                        "UserRolesFeature_Tests.RoleChange_Is_Visible_Without_Restart"
                    })
            },
            OverallCoverage: 1.0,  // 3/3 ACs have tests
            TotalTests: 6
        ),

        new FeatureCompliance(
            FeatureType: typeof(OrderProcessingFeature),
            Title: "Order processing and fulfillment",
            Priority: RequirementPriority.Critical,
            AcceptanceCriteria: new[]
            {
                new ACCompliance(
                    Name: "OrderCanBeCreated",
                    TestCount: 4,
                    Tests: new[] { /* ... */ }),
                new ACCompliance(
                    Name: "OrderCanBeCancelled",
                    TestCount: 2,
                    Tests: new[] { /* ... */ }),
                new ACCompliance(
                    Name: "OrderTotalIsCalculatedCorrectly",
                    TestCount: 5,
                    Tests: new[] { /* ... */ }),
                new ACCompliance(
                    Name: "PaymentIsProcessedOnSubmission",
                    TestCount: 0,      // ← No tests! Flagged.
                    Tests: Array.Empty<string>())
            },
            OverallCoverage: 0.75,  // 3/4 ACs have tests
            TotalTests: 11
        )
    };

    // Summary statistics
    public const int TotalFeatures = 8;
    public const int TotalACs = 24;
    public const int TestedACs = 21;
    public const int UntestedACs = 3;
    public const double OverallCoverage = 0.875;  // 87.5%
}
// Generated: RequirementComplianceReport.g.cs
// Also generates a human-readable summary as a doc comment

/// <summary>
/// REQUIREMENT COMPLIANCE REPORT
/// Generated: 2026-03-29 14:32:07 UTC
///
/// ┌────────────────────────────────┬──────────┬─────────┬──────────┐
/// │ Feature                        │ Priority │ ACs     │ Coverage │
/// ├────────────────────────────────┼──────────┼─────────┼──────────┤
/// │ User roles and permissions     │ High     │ 3/3     │ 100.0%   │
/// │ Order processing               │ Critical │ 3/4     │  75.0%   │
/// │ Notifications                  │ Medium   │ 2/2     │ 100.0%   │
/// │ Authentication                 │ Critical │ 3/3     │ 100.0%   │
/// │ Payments                       │ Critical │ 2/3     │  66.7%   │
/// │ Inventory                      │ High     │ 4/4     │ 100.0%   │
/// │ Reporting                      │ Low      │ 2/2     │ 100.0%   │
/// │ Audit log                      │ Medium   │ 2/3     │  66.7%   │
/// ├────────────────────────────────┼──────────┼─────────┼──────────┤
/// │ TOTAL                          │          │ 21/24   │  87.5%   │
/// └────────────────────────────────┴──────────┴─────────┴──────────┘
///
/// UNTESTED ACCEPTANCE CRITERIA:
///   ⚠ OrderProcessingFeature.PaymentIsProcessedOnSubmission (Critical)
///   ⚠ PaymentsFeature.RefundCanBeIssued (Critical)
///   ⚠ AuditLogFeature.EntriesAreImmutable (Medium)
/// </summary>
public static class RequirementComplianceReport { }

The PM's question -- "is Feature-456 fully tested?" -- is now answered at compile time. Open the generated file. Read the table. Or call RequirementComplianceMatrix.Features.Single(f => f.FeatureType == typeof(UserRolesFeature)).OverallCoverage. The answer is 1.0. Done.

What the Analyzer Enforces

Four diagnostics, in order of severity:

// REQ001: Feature with no test class
// Severity: Warning (configurable to Error)
// Fires when: a type inheriting Feature<> has no [ForRequirement] test class anywhere in the test assembly

warning REQ001: Feature 'PaymentsFeature' has no test class with
    [ForRequirement(typeof(PaymentsFeature))]. No acceptance criteria
    are being verified.
    --> src/MyApp.Requirements/Features/PaymentsFeature.cs(8,1)

// REQ002: [Verifies] references non-existent AC
// Severity: Error
// Fires when: the string in [Verifies("X")] does not match any abstract method on the requirement type

error REQ002: [Verifies("ProcessPaymnet")] on method
    'PaymentsFeature_Tests.Payment_Is_Processed' does not match any
    acceptance criterion on 'PaymentsFeature'. Did you mean
    'ProcessPayment'?
    --> test/MyApp.Tests/Features/PaymentsFeature_Tests.cs(24,6)

// REQ003: Test method without [Verifies]
// Severity: Info
// Fires when: a test method in a [ForRequirement] class has [Fact] or [Theory] but no [Verifies]

info REQ003: Test method 'PaymentsFeature_Tests.SomeHelperTest' in a
    [ForRequirement] class has no [Verifies] attribute. It will not
    appear in the compliance matrix.
    --> test/MyApp.Tests/Features/PaymentsFeature_Tests.cs(47,5)

// REQ004: AC with no test
// Severity: Warning (configurable to Error for Critical features)
// Fires when: an abstract method on a Feature type has no [Verifies] test method anywhere

warning REQ004: Acceptance criterion
    'OrderProcessingFeature.PaymentIsProcessedOnSubmission' has no
    test method with [Verifies("PaymentIsProcessedOnSubmission")].
    Feature priority: Critical.
    --> src/MyApp.Requirements/Features/OrderProcessingFeature.cs(22,5)
Diagram

REQ001 catches missing test coverage at the feature level. REQ002 catches typos in AC references (impossible with nameof(), but possible if someone hardcodes a string). REQ003 is informational -- some helper tests legitimately don't verify an AC. REQ004 catches individual ACs that slipped through without a test.

Together, they form a closed loop. You cannot add a feature without the analyzer asking for tests. You cannot write a test that references a nonexistent AC. You cannot have an untested AC without a warning in the build output.

What Happens When a Requirement Changes?

This is where the eras diverge most dramatically.

Scenario: The PM adds a fourth AC to UserRolesFeature: "Roles can be temporarily elevated for emergency access."

// The PM (or the tech lead) adds this to the requirement type:
public abstract AcceptanceCriterionResult RoleCanBeTemporarilyElevated(
    UserId user, RoleId elevatedRole, TimeSpan duration);
Diagram

Era 1: 4 steps, no verification. Era 2: 4 steps, no completeness check. Era 3: 7 steps across 5 artifacts. Era 4: 5 steps, compiler-verified at each stage.

The critical difference: in Era 4, step 3 is automatic. The developer does not need to remember to check for gaps. The compiler tells them. In Era 3, step 3 through step 6 are manual processes that require the developer to know about the KnownFeatures array, the SharePoint matrix, and the wiki. A new developer who has not read the wiki will skip steps 3-5. The architecture test might catch the missing folder -- but only if someone remembered to add the feature to the KnownFeatures array first.


The Six-Project Architecture

The Requirements as Code approach uses six projects that form a compile-time chain. Testing is the final link in that chain:

Diagram

The analyzer (red) sits outside the main dependency chain. It consumes the requirement types and the test types, cross-references them, generates the compliance matrix, and emits diagnostics. It does not affect production code. It does not add runtime dependencies. It is a compile-time-only tool that replaces the wiki, the SharePoint matrix, the architecture tests, the coverage script, and the KnownFeatures array.

Every link in this graph uses typeof() or nameof(). Every link is checked by the compiler. Every link survives refactoring. No link depends on a folder structure, a naming convention, or a manually maintained list.


Configuring Strictness

The analyzer's severity levels are configurable per project via .editorconfig or MSBuild properties. This matters because different teams have different tolerance for gaps.

# .editorconfig — per-project analyzer configuration

# Strict mode: treat untested features as build errors
[test/MyApp.Tests/**]
dotnet_diagnostic.REQ001.severity = error   # Feature with no test class
dotnet_diagnostic.REQ002.severity = error   # Bad AC reference (always error)
dotnet_diagnostic.REQ003.severity = suggestion  # Test without [Verifies]
dotnet_diagnostic.REQ004.severity = error   # AC with no test
<!-- MyApp.Tests.csproj — MSBuild configuration for compliance thresholds -->
<PropertyGroup>
  <RequirementComplianceMinimum>90</RequirementComplianceMinimum>
  <TreatUntestedCriticalFeaturesAsErrors>true</TreatUntestedCriticalFeaturesAsErrors>
  <GenerateComplianceReport>true</GenerateComplianceReport>
  <ComplianceReportFormat>json;markdown</ComplianceReportFormat>
</PropertyGroup>

A greenfield project starts strict: every feature must have tests, every AC must be verified, build fails otherwise. A brownfield migration starts lenient: REQ001 and REQ004 are warnings, not errors. The team adopts [ForRequirement] incrementally -- one feature at a time -- and tightens the thresholds as coverage grows.

The SG also generates a JSON report that CI pipelines can consume without custom Python scripts:

{
  "generated": "2026-03-29T14:32:07Z",
  "summary": {
    "totalFeatures": 8,
    "testedFeatures": 7,
    "totalACs": 24,
    "testedACs": 21,
    "overallCoverage": 0.875,
    "passingThreshold": true
  },
  "features": [
    {
      "type": "MyApp.Requirements.Features.UserRolesFeature",
      "title": "User roles and permissions",
      "priority": "High",
      "coverage": 1.0,
      "acceptanceCriteria": [
        {
          "name": "AdminCanAssignRoles",
          "testCount": 3,
          "tested": true
        },
        {
          "name": "ViewerHasReadOnlyAccess",
          "testCount": 2,
          "tested": true
        },
        {
          "name": "RoleChangeTakesEffectImmediately",
          "testCount": 1,
          "tested": true
        }
      ]
    },
    {
      "type": "MyApp.Requirements.Features.OrderProcessingFeature",
      "title": "Order processing and fulfillment",
      "priority": "Critical",
      "coverage": 0.75,
      "acceptanceCriteria": [
        {
          "name": "OrderCanBeCreated",
          "testCount": 4,
          "tested": true
        },
        {
          "name": "OrderCanBeCancelled",
          "testCount": 2,
          "tested": true
        },
        {
          "name": "OrderTotalIsCalculatedCorrectly",
          "testCount": 5,
          "tested": true
        },
        {
          "name": "PaymentIsProcessedOnSubmission",
          "testCount": 0,
          "tested": false
        }
      ]
    }
  ],
  "violations": [
    {
      "code": "REQ004",
      "feature": "OrderProcessingFeature",
      "ac": "PaymentIsProcessedOnSubmission",
      "priority": "Critical",
      "message": "Critical AC has no test coverage"
    }
  ]
}

This JSON replaces the SharePoint matrix, the Python scripts, and the PR comment generator. CI reads it directly. Dashboards consume it. The PM opens it in a browser. One generated artifact replaces an entire convention ecosystem.


Runtime Validation (Optional)

For teams that want belt-and-suspenders verification, the compliance matrix can also be checked at application startup:

// Optional: runtime compliance check via IHostedService
public class ComplianceValidationService : IHostedService
{
    private readonly ILogger<ComplianceValidationService> _logger;

    public ComplianceValidationService(
        ILogger<ComplianceValidationService> logger)
    {
        _logger = logger;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        var matrix = RequirementComplianceMatrix.Features;

        var untestedCritical = matrix
            .Where(f => f.Priority == RequirementPriority.Critical)
            .Where(f => f.OverallCoverage < 1.0)
            .ToList();

        if (untestedCritical.Count > 0)
        {
            foreach (var feature in untestedCritical)
            {
                var gaps = feature.AcceptanceCriteria
                    .Where(ac => ac.TestCount == 0)
                    .Select(ac => ac.Name);

                _logger.LogWarning(
                    "Critical feature '{Feature}' has untested ACs: {ACs}",
                    feature.Title,
                    string.Join(", ", gaps));
            }

            // In production: log and alert
            // In staging: throw to prevent deployment
            if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
                == "Staging")
            {
                throw new InvalidOperationException(
                    $"{untestedCritical.Count} critical features have " +
                    $"untested acceptance criteria. " +
                    $"Deployment blocked in Staging.");
            }
        }

        _logger.LogInformation(
            "Compliance check passed. {Tested}/{Total} ACs verified " +
            "({Coverage:P1})",
            RequirementComplianceMatrix.TestedACs,
            RequirementComplianceMatrix.TotalACs,
            RequirementComplianceMatrix.OverallCoverage);

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
        => Task.CompletedTask;
}

// Registration — one line in Program.cs
services.AddHostedService<ComplianceValidationService>();

The output in staging logs looks like this:

warn: ComplianceValidationService[0]
      Critical feature 'Order processing and fulfillment' has untested ACs:
      PaymentIsProcessedOnSubmission
fail: ComplianceValidationService[0]
      1 critical features have untested acceptance criteria.
      Deployment blocked in Staging.
Application startup exception: System.InvalidOperationException:
      1 critical features have untested acceptance criteria.
      Deployment blocked in Staging.

In production, the same check logs a warning but does not throw -- you do not want to prevent a hotfix deployment because of a test gap. The strictness is environment-dependent, configurable, and explicit.

This is not the primary enforcement mechanism -- the analyzer and SG handle that at compile time. But it provides a runtime safety net: if a staging deployment includes untested critical features, the application refuses to start. The compliance matrix that the SG generates is available at runtime, which means the same data that drives compile-time diagnostics can also drive deployment gates.


The Convention Tax Table

Dimension Era 1: Code Era 2: Config Era 3: Convention Era 4: Contention
Requirement-to-test link None [Trait] strings Folder structure typeof() / nameof()
Link verified by Nobody Nobody Architecture test Compiler
"Is Feature X tested?" Manual audit Filter by Trait (if correct) Check folder exists + matrix ComplianceMatrix[X].Coverage
Completeness guarantee None None Partial (needs KnownFeatures) Full (SG scans all types)
Typo in link N/A Compiles fine Wrong folder name compiles Does not compile
Requirement added Nothing happens Nothing happens Must update KnownFeatures + matrix REQ001/REQ004 fires
Requirement removed Orphan tests remain Orphan Traits remain Folder remains, no cleanup signal typeof() breaks -- compiler error
AC renamed Nothing changes String not updated File not renamed (drift) nameof() auto-updates
New developer onboarding Read 2,000 tests Learn Trait conventions Read wiki (50+ lines) Read the attribute
Documentation lines 0 0 ~50 (wiki) 0
Enforcement code lines 0 0 ~265 (arch tests + CI + scripts) 0
Generated artifacts None None None Compliance matrix + report
Maintenance burden None (but no value) Low (strings drift) High (5 artifacts to sync) Zero (compiler maintains links)
Can PM answer "what's tested?" No Sort of With spreadsheet lag Yes, at compile time

Convention costs ~315 lines of overhead (documentation + enforcement). Contention costs zero lines of overhead -- the attributes that replace the convention are also the metadata the SG uses to generate the compliance matrix. There is no separate documentation layer because the code IS the documentation. There is no separate enforcement layer because the compiler IS the enforcement.

The Numbers at Scale

A real system with 80 features and 240 acceptance criteria:

Metric Convention (Era 3) Contention (Era 4)
Convention documentation (wiki) ~50 lines 0
Architecture enforcement tests ~45 lines 0
CI scripts and pipeline ~60 lines 0
Python/bash matrix scripts ~150 lines 0
KnownFeatures array ~80 entries (manually maintained) 0 (SG discovers types)
SharePoint matrix rows ~240 rows (manually maintained) 0 (SG generates JSON)
Developer time per feature (non-test overhead) ~43 minutes ~5 minutes
Developer time across 80 features ~57 hours ~7 hours
Confidence in compliance matrix Low (manual process) High (compiler-generated)
Time to answer "is Feature X tested?" 5-30 minutes 0 (read generated report)
Drift risk High (7 artifacts) Zero (type system)

The 50-hour difference is not a one-time cost. It recurs every time a feature is added, modified, split, renamed, or removed. Over the lifetime of a product with active development, the cumulative cost of convention maintenance dwarfs the one-time cost of building the source generator.


The Deeper Point

The Convention Tax for testing is especially painful because the domain is inherently cross-cutting. A test for "Admin can assign roles" touches the auth service, the user repository, the role repository, and the permission cache. The requirement lives in one place (Jira, or the wiki, or the PM's head). The implementation lives in another (multiple services). The tests live in a third (the test project). The convention's job is to maintain the links between these three locations.

Convention maintains those links through prose (the wiki), through structure (the folder hierarchy), and through enforcement code (the architecture tests). All three are maintained by developers. All three drift.

Contention maintains those links through the type system. typeof(UserRolesFeature) is the same reference whether it appears in an [Implements] attribute on the service class, a [ForRequirement] attribute on the test class, or a [Verifies] attribute on the test method. Rename the feature? One rename operation. Delete the feature? Every reference breaks. Add an AC? The analyzer flags missing tests. Remove an AC? Every [Verifies] reference breaks.

The link is not maintained. It is compiled.

Consider what happens at scale. A system with 80 features, 240 acceptance criteria, and 1,500 tests has 1,500 requirement-to-test links. In the Convention era, maintaining those links is a full-time job -- except nobody is assigned to it, so it becomes everybody's job, which means it is nobody's job. In the Contention era, maintaining those links is the compiler's job. The compiler does not forget. The compiler does not deprioritize traceability in favor of the next sprint.

This is the deepest argument for Contention in the testing domain: the cost of the Convention Tax is not measured in lines of code. It is measured in confidence. A team that maintains its requirements matrix manually has low confidence in the matrix. A team whose compliance matrix is generated at compile time from type references has high confidence. And confidence determines whether the PM trusts the test results, whether the auditor accepts the traceability report, and whether the team ships with conviction or with anxiety.


Convention Era: Adding Tests for a New Feature

The PM creates "Inventory Reservation" in Jira with 3 ACs. The developer's workflow:

  1. Read the wiki to understand the test convention (5 minutes -- or 30 if it is their first time)
  2. Create folder Features/InventoryReservation/ (1 minute)
  3. Create ItemCanBeReserved_Tests.cs (name must match AC name from Jira -- copy carefully)
  4. Create ReservationExpiresAfterTimeout_Tests.cs
  5. Create ConcurrentReservationsAreHandled_Tests.cs
  6. Write the tests (actual productive work)
  7. Open the KnownFeatures array. Add "InventoryReservation". (2 minutes -- if you remember)
  8. Run the architecture tests locally to check. Fix the class name that does not end in _Tests. (5 minutes)
  9. Open the SharePoint matrix. Add a row for "Inventory Reservation." Fill in the AC columns. (10 minutes)
  10. Push. Wait for CI. The coverage gate fails because one test was not wired correctly and the folder shows 72% coverage. Fix the test. Push again. (15 minutes)
  11. During code review, a reviewer asks: "Did you update the requirements matrix?" Open SharePoint again. Verify. (5 minutes)

Total overhead beyond writing tests: ~43 minutes. Across 80 features: ~57 hours of convention maintenance.

Contention Era: Adding Tests for a New Feature

The developer's workflow:

  1. Add the abstract methods to InventoryReservationFeature (the requirement type already exists because it was created when the feature was scoped)
  2. Create InventoryReservationFeature_Tests.cs anywhere in the test project
  3. Add [ForRequirement(typeof(InventoryReservationFeature))] to the class
  4. Add [Verifies(nameof(InventoryReservationFeature.ItemCanBeReserved))] to each test method
  5. Write the tests (actual productive work)
  6. Build. The SG generates the updated compliance matrix. If any AC is missing a test, REQ004 fires. Fix it. Build again.
  7. Push. CI reads the generated JSON report. No scripts, no SharePoint, no manual steps.

Total overhead beyond writing tests: ~5 minutes (typing the attributes). Across 80 features: ~7 hours. And the attributes are not overhead -- they are the metadata that drives the compliance matrix. You would want to add them even if there were no convention to follow, because they make the tests self-documenting.


What the Convention Era Gets Right

Convention is not wrong. Organizing tests by feature is a good idea. Naming test classes after acceptance criteria is a good idea. Running coverage gates in CI is a good idea. The problem is not the practices -- it is the enforcement mechanism.

When a team says "tests must be organized by feature folder," they are expressing a real architectural constraint. The constraint matters. Tests that are scattered randomly across a flat directory are harder to reason about than tests grouped by the feature they verify.

But the constraint can be encoded in two ways:

  1. Convention: Write the rule in a wiki. Write a test that scans folder names. Write a CI script that checks coverage per folder. Maintain a feature list. Update it when features change.

  2. Contention: Express the rule as [ForRequirement(typeof(Feature))]. Let the compiler check it. Let the SG generate the report.

Both approaches encode the same constraint. One requires ~315 lines of infrastructure across five files in three languages. The other requires one attribute and a source generator that was already built for the requirements chain.

Convention gets the intent right. Contention gets the mechanism right. The gap between intent and mechanism is where the Convention Tax lives -- and where drift enters.

The honest answer is: if your team has 3 features and 20 tests, the Convention approach works fine. The wiki is short, the enforcement is simple, and the maintenance is manageable. But if your team has 80 features, 240 acceptance criteria, and 1,500 tests across 12 projects in a mono-repo, the Convention approach does not scale. The wiki is stale. The KnownFeatures array is incomplete. The SharePoint matrix was last updated two sprints ago. The CI scripts break when someone renames a folder. And the PM still cannot answer "is Feature-456 fully tested?" without a 30-minute investigation.

Contention scales because the enforcement mechanism is the same regardless of size: typeof() and nameof(). One feature or one thousand features -- the compiler checks them all.


Cross-References

  • Requirements as Code -- the full C# chain from Feature types with AC methods through Specifications, Implementations, and Tests. This part covers the testing slice; that post covers the entire six-project architecture.

  • Requirements as Code (TypeScript) -- the TypeScript equivalent, showing how the same pattern works with TypeScript's structural type system using interfaces, branded types, and build-time validation.

  • Onboarding Typed Specifications -- the seven-part series showing how to incrementally adopt typed specifications in a brownfield codebase, from the first [ForRequirement] attribute to a full compliance matrix.

  • Quality to Its Finest -- multi-layer quality assurance: compile-time (analyzers), build-time (SG reports), test-time (typed verification), and runtime (hosted service validation). This part focuses on the test-time layer.


Closing

Testing is the domain where Convention's cost is most visible because every requirement-to-test link must be maintained, and the volume is enormous. A system with 8 features, 24 acceptance criteria, and 150 tests has 150 links that must be correct. In the Convention era, those links are folder names, file names, and spreadsheet rows -- all maintained by hand. In the Contention era, those links are typeof() and nameof() -- all maintained by the compiler.

The Convention Tax for testing is not just the 315 lines of documentation and enforcement code. It is the ongoing cost of synchronization: every sprint, someone must update the spreadsheet, verify the folder structure, maintain the KnownFeatures array, and check that the architecture tests still pass. That work is invisible, unglamorous, and always deprioritized -- which is why every team eventually discovers a critical feature with zero tests.

The [ForRequirement] attribute eliminates the documentation (the attribute IS the documentation), eliminates the enforcement code (the analyzer IS the enforcement), eliminates the spreadsheet (the SG generates the compliance matrix), and eliminates the synchronization burden (the compiler maintains the links). What remains is the tests themselves -- which is the only artifact that should require human effort.

The convention "every feature must have tests, organized by feature folder, with coverage tracked in a spreadsheet" costs 315 lines of overhead plus infinite synchronization. The attribute [ForRequirement(typeof(Feature))] costs one line -- and the compiler does the rest.


Next: Part VII: Architecture Enforcement -- where the convention "domain layer must not reference infrastructure" becomes a NetArchTest suite, a wiki page, and a code review checklist. And where [Layer] attributes and a Roslyn analyzer replace all three with zero-overhead compile-time enforcement.