Executable specifications using a lightweight Given-When-Then pattern without the complexity of step definition bindings.
This project implements Specification by Example - tests that serve as living documentation. Each test file is both:
- A runnable test that validates behavior
- A readable specification that documents how the system works
src/test/
├── Scenario.cs # Shell-specific test base class
├── SPEC2050.MainLayout/ # Specification ID
│ ├── US01.SidebarCollapse/ # User Story
│ │ ├── US01.SidebarCollapse.feature # Gherkin specification
│ │ ├── SC01.ToggleFromExpanded.cs # Scenario test
│ │ ├── SC02.ToggleFromCollapsed.cs
│ │ └── ...
│ ├── US02.SidebarResize/
│ ├── US03.StateHydration/
│ └── US04.StateImmutability/
| Element | Format | Example |
|---|---|---|
| Specification | SPECXXXX.{Component} |
SPEC2050.MainLayout |
| User Story | USXX.{StoryName} |
US01.SidebarCollapse |
| Scenario | SCXX.{ScenarioName}.cs |
SC01.ToggleFromExpanded.cs |
| Feature File | USXX.{StoryName}.feature |
US01.SidebarCollapse.feature |
Every test is tagged with traits for full traceability:
[Trait(Spec.SPEC, "0022")] // Specification ID
[Trait(Spec.US, "01")] // User Story ID
[Trait(Spec.SC, "01")] // Scenario ID
[Trait(Spec.UAC, "01")] // User Acceptance Criterion IDThis enables filtering tests by specification, story, scenario, or acceptance criterion:
# Run all tests for a specific specification
dotnet test --filter "SPEC=0022"
# Run all tests for User Story 01 in SPEC0022
dotnet test --filter "SPEC=0022&US=01"
# Run specific scenario
dotnet test --filter "SPEC=0022&US=01&SC=03"
# Run specific acceptance criterion
dotnet test --filter "SPEC=0022&US=01&SC=03&UAC=01"Full traceability path: SPEC0022.US01.SC03.UAC01 uniquely identifies every test.
The .feature file documents the behavior in human-readable format:
@US01
Feature: US01 - Sidebar Collapse
@SC01
Scenario: Toggle sidebar from expanded to collapsed
Given the sidebar is expanded
When I dispatch a ToggleSidebar action
#UAC01
Then the sidebar should be collapsed
#UAC02
And the width should remain unchangedCRITICAL: Each Then and And statement gets its own UAC tag:
#UACtags are placed immediately before theThenorAndline- Each
#UACrepresents one assertion = one fact = one test method - UAC numbers must be unique within the User Story (not just the scenario)
Whenstatements never get UAC tags (they are actions, not assertions)
# ✅ CORRECT: Each assertion has its own UAC
@SC01
Scenario: Terminal displays output
Given the terminal is mounted
When the process outputs text
#UAC01
Then the terminal should display the text
#UAC02
And the text should be colored correctly
# ❌ WRONG: Missing UAC on And statement
@SC01
Scenario: Terminal displays output
Given the terminal is mounted
When the process outputs text
#UAC01
Then the terminal should display the text
And the text should be colored correctly # ❌ Missing #UAC02
# ❌ WRONG: UAC on When statement
@SC01
Scenario: Terminal displays output
Given the terminal is mounted
#UAC01 # ❌ When is an action, not a fact
When the process outputs text
Then the terminal should display the textWithin a User Story:
- Scenarios are numbered sequentially:
@SC01,@SC02,@SC03, etc. - UACs are numbered sequentially across all scenarios:
#UAC01,#UAC02,#UAC03, etc. - Each scenario can have multiple UACs
- UAC numbers never reset or duplicate within the User Story
@US01
Feature: US01 - Example
@SC01
Scenario: First scenario
Given setup
When action
#UAC01
Then first assertion
#UAC02
And second assertion
@SC02
Scenario: Second scenario
Given setup
When action
#UAC03 # ← Continues from previous scenario
Then first assertion
#UAC04
And second assertionEach scenario gets its own .cs file with one Fact per acceptance criterion:
[Trait(Spec.SPEC, "2050")]
[Trait(Spec.US, "01")]
[Trait(Spec.SC, "01")]
public class SC01_ToggleFromExpanded : Scenario
{
// Given: the sidebar is expanded
protected override void Given()
{
Store.State.IsSidebarCollapsed.ShouldBeFalse();
}
// When: I dispatch a ToggleSidebar action
protected override async Task WhenAsync()
{
await Store.DispatchAsync(new ToggleSidebarAction());
}
[Fact(DisplayName = "Then the sidebar should be collapsed")]
[Trait(Spec.UAC, "01")]
public async Task UAC01_SidebarShouldBeCollapsed()
{
await ArrangeAndActAsync();
Store.State.IsSidebarCollapsed.ShouldBeTrue();
}
}Each [Fact] tests exactly one acceptance criterion. This provides:
- Clear failure messages
- Precise traceability
- Independent test execution
// Good: One assertion per fact
[Fact(DisplayName = "Then the sidebar should be collapsed")]
[Trait(Spec.UAC, "01")]
public async Task UAC01_SidebarShouldBeCollapsed()
{
await ArrangeAndActAsync();
Store.State.IsSidebarCollapsed.ShouldBeTrue();
}
[Fact(DisplayName = "And the sidebar width should be 300px")]
[Trait(Spec.UAC, "02")]
public async Task UAC02_SidebarWidthShouldBe300()
{
await ArrangeAndActAsync();
Store.State.SidebarWidth.ShouldBe(300);
}The Scenario base class provides the structure:
public class MyScenario : Scenario
{
// Setup preconditions
protected override void Given() { }
protected override async Task GivenAsync() { }
// Execute action under test
protected override void When() { }
protected override async Task WhenAsync() { }
// Assert outcomes (one per Fact)
[Fact]
public async Task ThenSomething()
{
await ArrangeAndActAsync(); // Runs Given + When
// Assert...
}
}The DisplayName should match the Gherkin step exactly:
[Fact(DisplayName = "Then the sidebar should be collapsed")]This creates readable test output that mirrors the specification.
Critical: When implementing tests for a User Story:
- ✅ Implement ALL scenarios defined in the feature file, or none at all
- ✅ One test file per scenario (SCXX.{Name}.cs)
- ✅ UAC numbers in tests must match feature file exactly
- ❌ Never skip scenarios - partial implementation breaks traceability
Example: If your feature file has SC01-SC12, you must create all 12 test files.
| Mistake | Why It's Wrong | Fix |
|---|---|---|
UAC tag before When |
Marks action, not assertion | Move after When, before Then |
Missing UAC on And |
Can't trace individual assertions | Add #UAC to every And |
| Duplicate SC tags | Multiple scenarios with same ID | Use unique SC01, SC02, SC03... |
| Missing SPEC trait | Can't filter by specification | Always add all 4 traits |
| Skipping scenarios | Incomplete test coverage | Implement all or defer entire US |
Missing When step |
Invalid GWT structure | Every scenario needs Given→When→Then |
Use [Theory] with [InlineData] for Gherkin Scenario Outlines:
Scenario Outline: Setting sidebar width directly
When I dispatch a SetSidebarWidth action with width <inputWidth>px
Then the sidebar width should be <expectedWidth>px
Examples:
| inputWidth | expectedWidth |
| 250 | 250 |
| 100 | 200 |[Theory(DisplayName = "Then the sidebar width should be clamped")]
[Trait(Spec.UAC, "01")]
[InlineData(250, 250)]
[InlineData(100, 200)] // Below min -> clamps
public async Task UAC01_WidthShouldBeClamped(int input, int expected)
{
await WhenSetWidth(input);
Store.State.SidebarWidth.ShouldBe(expected);
}Generic base class for any test project:
public abstract class Scenario : IDisposable
{
protected virtual void Given() { }
protected virtual Task GivenAsync() => Task.CompletedTask;
protected virtual void When() { }
protected virtual Task WhenAsync() => Task.CompletedTask;
protected async Task ArrangeAndActAsync();
protected void ArrangeAndAct();
}Shell-specific base with AppStore and Reducer:
public abstract class Scenario : Core.Testing.Scenario
{
protected readonly AppStore Store;
protected readonly AppReducer Reducer;
}# Run all tests
dotnet test
# Run with detailed output
dotnet test --verbosity normal
# Filter by trait
dotnet test --filter "US=01"
dotnet test --filter "US=02&SC=06"
# Run specific test class
dotnet test --filter "FullyQualifiedName~SC01_ToggleFromExpanded"| Benefit | Description |
|---|---|
| Living Documentation | Tests document behavior in human-readable format |
| Full Traceability | Every test links to US, SC, and UAC |
| No Step Bindings | Simpler than SpecFlow/Reqnroll without regex matching |
| IDE Support | Standard xUnit - works with all test runners |
| Fast Execution | No runtime step discovery overhead |
| Easy Debugging | Step into any Given/When/Then directly |
| Aspect | This Approach | SpecFlow/Reqnroll |
|---|---|---|
| Feature files | ✅ Gherkin syntax | ✅ Gherkin syntax |
| Step definitions | ❌ Not needed | ✅ Required |
| Regex matching | ❌ Not needed | ✅ Required |
| Runtime overhead | ❌ None | ✅ Step discovery |
| Learning curve | Low | Medium |
| Flexibility | High | Medium |
| IDE integration | Native xUnit | Plugin required |
- Create scenario file:
SCXX.{Name}.cs - Add to feature file: Document the scenario in Gherkin
- Inherit from Scenario: Use the base class
- Add traits:
[Trait(Spec.US, "XX")],[Trait(Spec.SC, "XX")] - Override Given/When: Setup and action
- Write Facts: One per acceptance criterion with
[Trait(Spec.UAC, "XX")]
[Trait(Spec.SPEC, "0022")]
[Trait(Spec.US, "01")]
[Trait(Spec.SC, "08")]
public class SC08_NewScenario : Scenario
{
protected override void Given() { /* setup */ }
protected override async Task WhenAsync() { /* action */ }
[Fact(DisplayName = "Then something should happen")]
[Trait(Spec.UAC, "01")]
public async Task UAC01_SomethingShouldHappen()
{
await ArrangeAndActAsync();
// assert
}
}