Skip to content

Latest commit

 

History

History
395 lines (312 loc) · 11.2 KB

File metadata and controls

395 lines (312 loc) · 11.2 KB

LowlandTech Unit Tests

Executable specifications using a lightweight Given-When-Then pattern without the complexity of step definition bindings.

Testing Philosophy

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

Project Structure

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/

Naming Conventions

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

Traceability

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 ID

This 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.

Anatomy of a Scenario

Feature File (Gherkin)

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 unchanged

UAC Tagging Rules

CRITICAL: Each Then and And statement gets its own UAC tag:

  • #UAC tags are placed immediately before the Then or And line
  • Each #UAC represents one assertion = one fact = one test method
  • UAC numbers must be unique within the User Story (not just the scenario)
  • When statements 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 text

Scenario and UAC Numbering

Within 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 assertion

Test File (C#)

Each 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();
    }
}

Key Principles

1. One Assertion Per Test

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);
}

2. Given-When-Then Structure

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...
    }
}

3. DisplayName Matches Gherkin

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.

4. Complete Implementation Required

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.

Common Pitfalls

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

5. Theory for Scenario Outlines

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);
}

Base Classes

Core: LowlandTech.Core.Testing.Scenario

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: LowlandTech.Unit.Tests.Scenario

Shell-specific base with AppStore and Reducer:

public abstract class Scenario : Core.Testing.Scenario
{
    protected readonly AppStore Store;
    protected readonly AppReducer Reducer;
}

Running Tests

# 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"

Benefits of This Approach

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

Comparison with SpecFlow/Reqnroll

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

Adding New Tests

  1. Create scenario file: SCXX.{Name}.cs
  2. Add to feature file: Document the scenario in Gherkin
  3. Inherit from Scenario: Use the base class
  4. Add traits: [Trait(Spec.US, "XX")], [Trait(Spec.SC, "XX")]
  5. Override Given/When: Setup and action
  6. 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
    }
}