Skip to content

Step Tracking

aryehcitron@gmail.com edited this page Nov 5, 2026 · 47 revisions

Added in v2.30.29

Step tracking lets you define BDD-style steps (Given/When/Then/But) as attributed methods. At build time, the Kronikol.StepTracking package uses IL weaving to automatically instrument these methods — recording their execution, parameters, timing, and pass/fail status in your test reports.

This gives you the expressive step structure of BDD frameworks without any framework dependency.


Quick Start

  1. Install the package in your test project:
<PackageReference Include="Kronikol.StepTracking" Version="5.0.0" />
  1. Add the assembly-level opt-in attribute anywhere in your test project:
using Kronikol.Tracking;

[assembly: TrackSteps]
  1. Decorate your step methods with BDD attributes:
public class OrderTests : TestBase
{
    [GivenStep]
    public void A_customer_exists() { /* setup logic */ }

    [GivenStep]
    public void The_customer_has_a_basket_with_items(int itemCount)
    {
        // setup logic with parameters
    }

    [WhenStep]
    public void The_customer_places_an_order() { /* action */ }

    [ThenStep]
    public void An_order_confirmation_is_sent() { /* assertion */ }

    [Fact]
    public async Task Customer_can_place_order()
    {
        A_customer_exists();
        The_customer_has_a_basket_with_items(3);
        The_customer_places_an_order();
        An_order_confirmation_is_sent();
    }
}

The test report will show structured steps:

Keyword Step Status Duration
Given A customer exists ✓ Passed 2ms
And The customer has a basket with items ✓ Passed 1ms
When The customer places an order ✓ Passed 45ms
Then An order confirmation is sent ✓ Passed 3ms

Note: The TrackStepsAttribute, GivenStepAttribute, WhenStepAttribute, ThenStepAttribute, ButStepAttribute, ButWhenStepAttribute, and StepAttribute types are auto-generated into your project at build time — no manual type definitions needed.


Available Attributes

Attribute Keyword Phase Transition Purpose
[GivenStep] Given → Setup Preconditions, test data setup
[WhenStep] When → Action The action under test
[ThenStep] Then → Action Assertions and verification
[ButStep] But → Setup Negative preconditions ("But the user is not an admin")
[ButWhenStep] But → Action Negative continuations in the action phase ("But the retry fails")
[Step] (none) (none) Generic step with no keyword

Description Override

All step attributes accept a Description property to override the humanized method name:

[GivenStep(Description = "A user with admin privileges")]
public void SetupAdminUser() { /* ... */ }

Without Description, the method name is humanized automatically: SetupAdminUser → "Setup admin user".


Keyword Sequencing

Consecutive methods with the same keyword are automatically sequenced using And:

[GivenStep] public void A_user_exists() { }           // → "Given"
[GivenStep] public void The_user_is_active() { }      // → "And"  (auto-sequenced)
[GivenStep] public void The_user_has_a_session() { }   // → "And"  (auto-sequenced)
[WhenStep]  public void The_user_logs_out() { }        // → "When" (new keyword resets)
[ThenStep]  public void The_session_is_destroyed() { } // → "Then" (new keyword resets)
[ThenStep]  public void A_logout_event_is_raised() { } // → "And"  (auto-sequenced)

The same sequencing applies to [ButStep] and [ButWhenStep]:

[ButStep] public void The_user_is_not_admin() { }     // → "But"
[ButStep] public void The_user_has_no_mfa() { }       // → "And"  (auto-sequenced)

[ButWhenStep] also displays "But" and sequences with [ButStep]:

[WhenStep]    public void The_api_is_called() { }     // → "When"
[ButWhenStep] public void The_retry_fails() { }       // → "But"  (transitions to Action phase)
[ButWhenStep] public void The_fallback_is_used() { }  // → "And"  (auto-sequenced)

You never need to explicitly write "And" — it is always inferred from repetition.


Parameter Capture

Method parameters are automatically captured and displayed in the step report:

[GivenStep]
public void A_user_with_name_and_age(string name, int age)
{
    // parameters "name" and "age" are captured at runtime
}

// Call:
A_user_with_name_and_age("Alice", 30);

The report shows:

Keyword Step Parameters
Given A user with name and age name="Alice", age="30"

Both value types and reference types are captured. Value types are boxed for display. A ref, in or ref readonly parameter reads as its value as the step starts, and an out parameter is not captured, as it has no value yet. What cannot be boxed (a span, a ref struct, a pointer) reads as its type, such as [Span<Int32>]. A parameter whose ToString throws, or a table whose rows throw, reads as its type too, and the step goes on.

Captured parameters — inline values, data tables with their columns and rows, and tree values — are in TestRunReport.json as well as the HTML from 3.0.47, so a parameterised failure shows the inputs that produced it in the file tooling reads. Previously the richer step mapping ran only for the mergeable file, which is off by default, so a data-file reader saw a step's text and status and none of its inputs. Turn it off with TestRunReportFullStepDetail = false if the file size matters more.

A failing step also carries its failureMessage there — CollectedStep.ErrorMessage was collected and then dropped in the conversion to ScenarioStep, so with nested sub-steps you could see that the test failed without seeing which step carried which message.


Method Name Humanization

A method's name is turned into a sentence by the rule a scenario's title takes when it is made from a method's name:

Method Name Step Text
TheUserLogsIn The user logs in
A_User_Exists A user exists
GetHTTPResponse Get HTTP response
Setup_Admin_User Setup admin user
ParsesURLs Parses URLs
WhenURLIsInvalid When URL is invalid
Returns404WhenMissing Returns404 when missing
GIVEN_VALID_INPUT Given valid input

The rule:

  1. Underscores and whitespace part words, and so does a capital after a lower-case letter.
  2. A run of capitals is one word, up to the capital that starts the next word: GetHTTPResponse is Get, HTTP and Response. A plural of capitals stays whole (URLs), and a following Is or As is a word of its own.
  3. A digit parts from the capitals after it only where they start a word: Returns404WhenMissing reads "Returns404 when missing", and Default_64MB_cache reads "Default 64 MB cache".
  4. A word written in capitals keeps them: HTTP, UK, ID, and a single capital such as X, but not the article A. Every other word is lower-cased, and the first word starts with a capital. A name with no lower-case letter at all is a sentence written in capitals, so GIVEN_VALID_INPUT reads "Given valid input".
  5. A word written in Pascal case cannot say it is an acronym, so WhenMcaFundingActivates reads "When mca funding activates". Neither can two words in capitals written together: TheUKVATRate reads "The UKVAT rate". A suite spells such words with ReportConfigurationOptions.TitleWords (since 5.18.0): with MCA listed, the title reads "When MCA funding activates". The list spells scenario and feature titles, not woven step text.

Until 5.16.5 every word but the first was lower-cased, so GetHTTPResponse read "Get http response".

Keyword Deduplication

When a method name starts with the same keyword as its attribute, the keyword prefix is automatically stripped to avoid duplication in reports:

Attribute Method Name Step Text
[GivenStep] GivenTheyGo They go
[WhenStep] When_they_go They go
[ThenStep] ThenItWorks It works
[WhenStep] WheneverTheyGo Whenever they go
[ButWhenStep] ButTheRetryFails The retry fails

The match is whole-word only — WheneverTheyGo does not match because "Whenever" ≠ "When ". Similarly, ThenceTheyGo is not stripped because "Thence" ≠ "Then ".

[ButWhenStep] strips the "But" prefix (its display keyword), not "ButWhen".

This allows natural method naming conventions like GivenAUserExists() or When_the_api_is_called() without keyword repetition in the report output.


Phase Transitions

Step attributes automatically set the ambient Phase-Aware Tracking context:

  • [GivenStep] and [ButStep] → TestPhase.Setup
  • [WhenStep], [ThenStep], and [ButWhenStep] → TestPhase.Action

This means your tracking extensions (SQL, HTTP, Redis, etc.) automatically adjust their verbosity based on which step is currently executing. Setup noise is reduced, while action-phase calls get full detail.

The outermost step open in a flow that says a phase decides, and a keyword under it is a label. Since 5.30.3, a [GivenStep] inside a When step leaves the rest of the When reading Action, and its own calls too. A [Step] says nothing, so the keywords under it still decide. See What moves a phase.

Since 5.31.0 the first step that goes from Setup to Action also draws the test's Setup/Action boundary, so with SeparateSetup = true a [GivenStep] then a [WhenStep] draws a Setup partition, and a suite's per-phase verbosity applies to a service-side call before it. A [ThenStep] draws none, as the act can sit in the test's body between a Given and a Then. See Diagram Customisation › Setup Separation.

Phase transitions are enabled by default. To disable:

StepCollector.Options = new StepTrackingOptions { WhenTriggersAction = false };

See Phase-Aware Tracking for details on how extensions respond to phase changes.


Assertion Sub-Steps

When Assertion Tracking is also enabled, assertions that execute within an active step are automatically recorded as sub-steps of that step:

[ThenStep]
public void The_order_total_is_correct()
{
    _order.Total.Should().Be(99.99m);      // ✓ recorded as sub-step
    _order.Currency.Should().Be("GBP");    // ✓ recorded as sub-step
}

The report shows:

Keyword Step Sub-Steps
Then The order total is correct ✓ _order.Total.Should().Be(99.99m)
✓ _order.Currency.Should().Be("GBP")

This requires both Kronikol.StepTracking and Kronikol.AssertionTracking packages to be installed.

From 3.0.47 a failing assertion sub-step also carries failureMessage, sourceFile and sourceLine in the data file — see Assertion-Tracking.

Other adapters: Assertion sub-steps also work inside ReqNRoll step definitions (v2.33.22+), LightBDD steps (v2.33.39+), and BDDfy steps (v2.33.23+) — any framework that brackets steps with StepCollector.StartStep/CompleteStep. In ReqNRoll, the step methods a step definition calls are drawn as its Gherkin step's sub-steps as well: see Sub-Steps in Reqnroll Steps.


Nested Steps

Steps can be nested — calling a step method from within another step method creates a sub-step hierarchy:

[GivenStep]
public void A_complete_test_environment()
{
    Setup_database();
    Seed_test_data();
}

[Step]
public void Setup_database() { /* ... */ }

[Step]
public void Seed_test_data() { /* ... */ }

The inner steps become sub-steps of the outer step in the report, and a keyword on one is a label: it leaves the outer step's phase alone (Phase Transitions). A Reqnroll step definition's step methods nest under its Gherkin step the same way: see Sub-Steps in Reqnroll Steps.

In the HTML report a step with sub-steps is drawn closed, and opens to show them. From 5.0.0, where its sub-steps come to 2 KB of HTML or more, the page holds them compressed and draws them the first time the step is opened, so a suite of deeply nested steps keeps a small report. A step on the way to a failure is drawn and open as before. The report's search matches a word in a step nobody opened; the browser's own find (Ctrl+F) does not. Set RenderCollapsedSubStepsOnExpand = false on ReportConfigurationOptions to draw every step. See Report Configuration. From 5.6.0 the report file is compressed (Generated Reports › The compressed page): such a step also opens in a browser without DecompressionStream, where until then it said its steps could not be drawn, and with the option false the steps' markup is inside the compressed file, so a script that reads the file's text finds it only in the plain page kronikol decode writes.


Sub-Steps in Reqnroll Steps

Reqnroll has no composite steps of its own, as LightBDD has, but step tracking gives it the same: a [Step] method that a step definition calls is drawn as a sub-step of that Gherkin step. The Reqnroll adapter opens each Gherkin step on StepCollector before its step definition runs and closes it afterwards (since 2.33.22), and a woven step that starts while one is open nests under it, as it does under any other step. The adapter also points Track.TestIdResolver, which tells a woven step its scenario, at the running Reqnroll scenario, unless the suite has set a resolver of its own.

Setup

  1. Install Kronikol.StepTracking beside the Reqnroll adapter (Kronikol.ReqNRoll.xUnit2, Kronikol.ReqNRoll.xUnit3 or Kronikol.ReqNRoll.TUnit):
<PackageReference Include="Kronikol.StepTracking" Version="5.0.0" PrivateAssets="all" />
  1. Opt the test project in, anywhere in it:
using Kronikol.Tracking;

[assembly: TrackSteps]
  1. Put [Step] on the methods your step definitions call. Here they are on a driver class, which Reqnroll's container builds for each scenario with the tracked HttpClient that your setup hooks register:
[Binding]
public class OrderSteps(ShopDriver shop)
{
    [Given("a registered customer with a full basket")]
    public async Task GivenARegisteredCustomerWithAFullBasket()
    {
        await shop.A_customer_is_registered("Alice");
        await shop.AddItems(3);
    }

    [When("the customer pays with a declined card")]
    public Task WhenTheCustomerPaysWithADeclinedCard() => shop.The_payment_is_authorised(99.99m, "declined");
}

public class ShopDriver(HttpClient client)
{
    [Step]
    public async Task A_customer_is_registered(string name)
    {
        (await client.PostAsJsonAsync("customers", new { name })).EnsureSuccessStatusCode();
        await Their_address_is_verified(name);
    }

    [Step]
    public async Task Their_address_is_verified(string name)
    {
        var response = await client.GetAsync($"customers/{name}/address-check");
        response.IsSuccessStatusCode.ShouldBeTrue();
    }

    [Step(Description = "Items are added to the basket")]
    public async Task AddItems(int count)
    {
        for (var i = 0; i < count; i++)
            (await client.PutAsJsonAsync($"basket/items/{i}", new { sku = $"SKU-{i}" })).EnsureSuccessStatusCode();
    }

    [Step]
    public async Task The_payment_is_authorised(decimal amount, string card)
    {
        var response = await client.PostAsJsonAsync("payments", new { amount, card });
        response.StatusCode.ShouldBe(HttpStatusCode.OK);
    }
}

What the Report Shows

For a scenario of these two steps, kronikol query steps gives this, with Assertion Tracking installed as well for the assertion lines:

  0     Given a registered customer with a full basket  0.03s  [i0-i8] 5 calls
    0.0   A customer is registered  0.02s
        · name
        · Alice
      0.0.0 Their address is verified  0.01s
          · name
          · Alice
      · 0.0.0.0 Response is success status code should be true
    0.1   Items are added to the basket
        · count
        · 3
✗ 1     When the customer pays with a declined card  0.01s  [i10]
  ✗ 1.0   The payment is authorised  0.01s
        response.StatusCode should be HttpStatusCode.OK but was HttpStatusCode.PaymentRequired
        · amount
        · 99.99
        · card
        · declined
    ✗ 1.0.0 Response status code should be OK
          response.StatusCode should be HttpStatusCode.OK but was HttpStatusCode.PaymentRequired   at ShopDriver.cs:35
  • Nesting — a [Step] method called from another is drawn one level further in, to any depth.
  • Parameters, durations and results — each sub-step carries its method's parameters, its own duration and its result. A failed one carries its exception's message, and its Gherkin step fails with it.
  • Assertions: with Assertion Tracking, an assertion is drawn under the step it ran in, so a failed one shows the sub-step it failed in, and the line. One from a flow whose step has ended is drawn under the step open then.
  • The HTML reports — a Gherkin step with sub-steps is drawn closed and opens to show them, and it is drawn open when something under it failed (see Nested Steps). Specifications numbers them 1.1, 1.2, and so does the test run report with TestRunReportShowStepNumbers. LightBDD's composite steps are drawn the same way.
  • Calls: a call made in a sub-step reads that sub-step: its stepPath is 0.1, and kronikol query flow s4/0.1 returns it (since 5.30.0). The diagram still draws a bar for each Gherkin step and none for a sub-step. A call from kronikol ingest reads the top-level step, as a capture has no flow to read.

Rules

  • [Step] goes on the methods a step definition calls, not on the step definition. On a step definition it opens a step inside the Gherkin step that the adapter has already opened, so the step is drawn twice: Given a registered customer, and A registered customer under it.
  • A keyword on a sub-step is a label. A [GivenStep] inside a When step leaves the When reading Action, as the outermost step that says a phase decides (Phase Transitions). So a sub-step cannot mark a part of a When as setup: a TrackDuringSetup = false client still records its calls. TrackingDiagramOverride.StartSetup() still does that, within its flow.
  • A [Step] method can return what it likes. A void or a value closes its step when it returns, and a Task, Task<T>, ValueTask or ValueTask<T> when the task ends. An iterator, an async void and any other type a caller can await run without a step, and the build says so (Methods Left as Written). Before 5.29.0 a value and a ValueTask threw InvalidProgramException when called (#226).
  • A [Step] method in a [BeforeScenario] hook is a step of the scenario's own, not a sub-step. It is listed where it ran, and nothing else moves but the addresses after it: see Step Methods Outside a Framework Step.
  • Sub-steps can run at once. Each flow of execution knows its own step, so two sub-steps started together (Task.WhenAll) are listed side by side in the order they began, each with its own result, duration, assertions and attachments (since 5.29.4). A step still running when the report is drawn is listed with no result. CompleteStep(testId, ...) closes the step last opened by hand on that test, so steps opened by id on two branches at once pair last in, first out.

Scenarios That Share Their First Steps

Scenarios that share their first steps each keep their own: their sub-steps and parameters, tables, doc strings and results. A Reqnroll run draws them as Background Steps only where the feature file wrote them in a Background: (5.28.6). What is left of the older reading is a guess for a producer with no source to read, under How It Works.


Step Methods Outside a Framework Step

A [Step] method that runs with no framework step open is a step of the scenario's own. In Reqnroll that is a [BeforeScenario] or [AfterScenario] hook, a block hook or a step argument transformation. In BDDfy it is code before or after .BDDfy() or in the class's constructor, and in LightBDD a composite step's own method, before it returns its steps. It is listed in the order it ran, with no keyword unless it declares one, and it draws its own bar:

  0     Reference data is seeded  [i0]
  1     Given a registered customer  [i2]
    1.0   A customer is registered
  2     When the customer places the order  [i4]
    2.0   An order is submitted
  • Every other step keeps its own. Each framework step pairs with the step it opened, so its sub-steps, attachments and calls stay with it whatever ran around it. Its address moves by the steps before it, in that scenario alone: the Given above is s1/1.
  • A call names the step its flow was in when it was logged, so it reads that step whether or not bars are drawn. A call made with no step open reads the step before it, as it always has: a plain call in an [AfterScenario] hook reads the last step (#147).
  • In a [BeforeStep] or [AfterStep] hook a step method finds the Gherkin step open, and is its sub-step.
  • A plain call in a hook lists nothing, as Kronikol shows the steps the author named. Before the first step it reads no step.
  • With a Background, such a row reads after the Background's rows, though it ran before them.

Steps That Did Not Run

A Reqnroll scenario lists every step it has, as LightBDD and the Cucumber lane do. A step skipped after a failure, or undefined, reads as skipped where the scenario lists it, with no bar, and a skipped step a Gherkin step was running stays under that step. So a scenario that fails in its first step still shows the rest of what it would have done. An ambiguous or badly bound step that is the scenario's first failure reads failed.

A Step Method Left Running

A step method that is not awaited can still be running when its framework step ends. It keeps its place under that step, is listed with no result until it finishes, and every later step keeps its own row. A call it makes reads the step it is in, and two sub-steps that overlap keep their own end times and outcomes (since 5.29.4, #225).

A Gherkin Step That Runs Another

A Gherkin step that runs another through ITestRunner lists it as a sub-step, with its table, doc string and inline values, and reads failed over a failed one.

Limits

  • A [BeforeScenario] hook at Order = int.MinValue ties with Kronikol's own and ran first, before the scenario has an identity, so a step method in it records nothing and its call is dropped.
  • A throw that leaves the scenario's generated method leaves the steps after it unlisted, as nothing runs them and no event names them: a throwing block hook, a throwing AfterStep hook, or StopAtFirstError.
  • An undefined or pending step fails the run and reads as skipped in the report, as it always has.
  • A Gherkin step that another scenario's step left to start later through ITestRunner, and that starts during a scenario that has already failed, reads as a row of that scenario, as Reqnroll reports it there.
  • A BDDfy test run more than once under one test id (an example table, a retry, a second .BDDfy()) lists its first run, and the calls of the later runs read no step.
  • A LightBDD retry written as a scenario decorator runs the steps again with no step decorators, so a step method in the second run reads as a row of its own.
  • A call a LightBDD scenario decorator makes after the scenario's steps can read as a background call, as a scenario ends at the later of LightBDD's end and its last step's. A call made inside a step stays in the scenario.
  • A hook that runs a Gherkin step through ITestRunner ahead of a written Background leaves that scenario's Background steps among its own, as a row that disagrees with the document ends the Background.
  • A Gherkin step that starts another through ITestRunner without awaiting it leaves the next step under it, as the inner step's hooks close first.
  • A [Step] method that skips a TUnit test reads failed, on a scenario that reads skipped, as all a step method hands on is the exception's message. Under xUnit v3 it reads skipped.
  • A BDDfy step that is async void, or any async step with Configurator.AsyncVoidSupportEnabled off, closes when BDDfy's call returns, at its first await, as there is no task to wait on. What it opens after that reads as a row of its own.

Rows and Files

  • A file attached in a BDDfy step the report does not list (a tear down, a hidden Setup, a later row of an example table) goes on the scenario. Where no step of the scenario ran, as when a hidden Setup throws, the rows pair by position and the first holds it, as they always did.
  • A consumer that rebuilds a row with new before writing the reports leaves the scenario's rows paired by position and its calls read against the bars, as they always were. Rebuilding one with with keeps its identity. A row removed from the list stays removed.

Background Steps

Added in v2.33.46 | Read from the feature file since v5.28.6

A step is a Background step when its source says so, and it is always the step of the scenario that ran it. A Reqnroll run reads the Gherkin document, so the steps of a feature's Background:, then those of the scenario's Rule, go into Scenario.BackgroundSteps. The Cucumber lane reads its messages the same way, and a background: true record from an external capture says so itself.

Since v3.0.48 they render inline, first, in the scenario's one Steps list rather than behind their own collapsible section. Set SeparateBackgroundSteps = true on ReportConfigurationOptions for the old two-section layout.

Note: Other frameworks (xUnit, TUnit, NUnit, LightBDD, BDDfy) have no Background: and do not use this feature.

How It Works

Reqnroll embeds each feature's Gherkin document in the test assembly. While a scenario runs, the adapter reads there the steps that the feature's Background: and the scenario's rule's give it. When the report is written, the scenario's leading rows that read the same as those steps become Scenario.BackgroundSteps, and the rest are its Steps. So a failure that ends a Background early leaves the rest unmarked, a step that disagrees is never called a Background step, and a scenario is laid out the same whoever else ran or failed. If the document cannot be read, every step is the scenario's own and only the mark is lost.

A step that another step runs through ITestRunner is a sub-step of that step, as the feature file reads, and the public lists (ReqNRollScenarioCollector.GetAll(), ScenarioContext[StepsCollectionKey]) still hold it.

BackgroundStepsDetector is a guess, kept for a producer with no source to read: kronikol ingest of keyworded steps where nothing says background: true, and a ReqNRollScenarioInfo built by hand. It:

  1. Groups scenarios by their Rule property (scenarios outside any Rule form their own group).
  2. Within each group containing ≥ 2 scenarios, finds the longest common prefix of steps (matching by keyword + text).
  3. Extracts those steps into Scenario.BackgroundSteps and trims them from Scenario.Steps, each scenario keeping its own.

If a group has only one scenario, or the scenarios share no common prefix, no background is extracted.

Report Rendering

Background steps are the first entries of the scenario's Steps list, each marked step-background (a muted left accent) so the seam is still visible. Numbering runs continuously across the whole list, and the list is open by default.

This is also the order everything else already used: interaction attribution and the stepPath addresses in the data files count background steps first (b0, b1, then 0, 1), so the rendered list and the data now read in the same order.

Two options control it, both on ReportConfigurationOptions (see Report Configuration):

Option Default Effect
SeparateBackgroundSteps false true restores the collapsible Background Steps section above Steps. Numbering stays continuous across the two sections (the Steps list carries on from where the background ended, in both layouts); only keyword collapsing is computed per section, so the Steps list opens with its own Given.
CollapseRepeatedStepKeywords true A step repeating the primary keyword in force displays as And, so a background Given followed by a scenario Given reads Given / And rather than Given / Given.

Both are rendering choices. The keyword every data output emits is the one the producer recorded — the And is a projection applied while writing HTML, never a change to the model. That matters mechanically: the HTML and the data writers run concurrently.

Localisation is a known limit. CollapseRepeatedStepKeywords recognises the English keywords (and the Cucumber keyword types Context/Action/Outcome/Conjunction). A localised keyword — Angenommen, Étant donné — passes through untouched and switches collapsing off until the next keyword the collapser understands, so a non-English feature file renders exactly as it did before. ReqNRoll supplies an English keyword enum, so this only affects the Cucumber-messages ingest path.

Step delimiter bars. A diagram's <<stepDelimiter>> bars come from the tracked step stream, and explicit background steps never had one (InteractionRecord.Background); a step the Reqnroll adapter tracked does, whatever it is marked. Combining the two lists into one makes that difference slightly more visible, but nothing about which steps get a bar changed. What did change in 3.0.48 is that a bar's label is now truncated at 1,400 characters — past ~1,458 the JS PlantUML engine overflows its own stack and the scenario loses every diagram it had. (Since 4.14.5 the cap is 600: the Web Worker BrowserJs renders in has half the page's stack, and a bar written as one unbroken token failed there from 1,040 characters in a worker that had just started.) (Since 3.0.78, that cap applies only to the plain coloured bar: a bar carrying a table, doc string or multi-line text takes the tag-free <<stepBody>> form and truncates at the 16,000-character note ceiling instead.) See Large Response and Diagram Handling › Statement-Length Limits (3.0.48+).

Both HTML reports go through the same renderer, so Specifications and TestRunReport always agree.

Rule-Scoped Backgrounds

A feature's Background: runs before each of its scenarios, and a Rule's after it, before that Rule's scenarios alone. For example, in a feature whose Background: writes Given the system is running:

  • Rule: Valid Orders writes Given the user is authenticated, so its scenarios have both
  • Rule: Invalid Orders writes none, so its scenarios have Given the system is running only

BDD Frameworks (ReqNRoll / Gherkin)

If you use Gherkin Background: blocks in your .feature files, the adapter reads them from the document Reqnroll embeds, with no configuration needed. As with any background, those steps render inline at the front of the Steps list by default (3.0.48+); a separate Background Steps section appears only with SeparateBackgroundSteps = true, and it starts open where it holds a failed step unless BackgroundStepsOpen says otherwise.

Explicit backgrounds from an external run

Added in v3.0.45

An ingested run can say so directly: a step event with "background": true goes straight into Scenario.BackgroundSteps and draws no delimiter bar (a background is not part of the run's timeline). When any scenario in a feature supplies an explicit background, the heuristic detector is not run at all — a producer that knows its Gherkin is never second-guessed.

The detector also stays out of the way when the steps carry no keywords: a common prefix of Open the overview across two Playwright-style scenarios is a coincidence, not a Background:, and extracting it would invent structure the run never had. Where the steps do carry keywords and nothing says background: true, it still guesses, so a shared Given and When read as a Background, each test keeping its own: a producer that knows should say.


File Attachments

Added in v2.33.48

You can attach files (screenshots, logs, trace files, etc.) to the current step or scenario. An image draws inline and opens in a lightbox, and anything else is a link. An attachment the report does not hold is drawn as its name and why, lost.png (not found when the report was drawn), with no link (4.29.0); the path it was given is the tooltip. An image whose file is missing when the page is opened (a report folder copied without its attachments/) says it could not be loaded.

Track.Attachment()

Call Track.Attachment() from within a step method or test body:

[WhenStep]
public void I_take_a_screenshot()
{
    var path = TakeScreenshot();
    Track.Attachment(path, "Page Screenshot");
}

[ThenStep]
public void I_save_the_response()
{
    File.WriteAllText("response.json", _responseBody);
    Track.Attachment("response.json");  // name defaults to "response.json"
}
Parameter Type Description
filePath string Path to the file to attach (absolute or relative). During report generation, the file is automatically copied into the Reports/attachments/ folder and the link is rewritten to a relative URL. A relative path is read when it is attached: against the working directory, where a test that wrote the file by that name wrote it, then against the binary folder, where a fixture shipped beside the binaries is (4.27.2). The two are one folder in every runner's default; under NUnit's WorkDirectory they differ, and the path used to be read against the binary folder alone when the report was drawn, so the file was lost.
name string? Display name for the attachment link. Defaults to the file name extracted from filePath. An image attached under a name with no image extension ("Page Screenshot") is drawn as an image: with no media type, the name's extension decides, then the file's (4.27.2; it was drawn as a link).

When called inside an active step, the attachment is associated with that step. When called outside any step (e.g. in test setup/teardown), it becomes a scenario-level attachment.

Automatic File Copying

During report generation (CreateStandardReportsWithDiagrams), all attachment source files are automatically copied into Reports/attachments/ and their href values are rewritten to relative paths (e.g. attachments/openapi.json). This ensures attachment links work when reports are:

  • Uploaded to GitHub Pages
  • Published as CI artifacts
  • Viewed from any location without the original source files

A path starting attachments/ is the report's own copy, made by an earlier report over the same results in the same process (LightBDD's formatter, then the host's own), and is linked where it is; so is a file already in Reports/attachments/ (4.27.2: it was copied onto itself, which fails).

An attachment the report cannot hold keeps its path: a file that is not there when the report is drawn, one that was there when it was attached and is gone (a teardown deleted it), one that cannot be copied, one whose path is not usable on this machine. From 4.27.2 every such attachment is said in one AttachmentFailure diagnostic per run, which leads with the count and names the first five paths, and an in-process run also prints it as a ⚠ WARNING: line:

3 attachment(s) are not in the report: 3 not found when the report was drawn (2 files): C:\run\shots\a.png, C:\run\shots\b.png.

The copy is made when the report is drawn, so a file rewritten after it was attached (one screenshot.png path reused by every test) is shown as its last version; the same entry says so (1 attachment(s) changed after it was attached, and the report holds the version found when it was drawn: …).

Duplicate file names from different source paths are automatically deduplicated (e.g. report.txt, report_2.txt).

A file that cannot be copied (a capturer still holds it, the disk is full) is left pointing at where it is and counted in that entry. One screenshot is never worth the whole report.

Attachments from external runs

Added in v3.0.45

A runner outside the .NET process attaches artefacts with an attachment event in the tests NDJSON:

{"event":"attachment","testId":"…","name":"screenshot-end.png","path":"/run/.logs/attachments/abc/screenshot-end.png","mediaType":"image/png","step":1}
{"event":"attachment","testId":"…","name":"Grafana trace","path":"http://localhost:3900/explore?traceId=…"}

step is the 0-based index of the top-level step it belongs to (absent → the scenario); mediaType decides how it renders — image/* inline with a lightbox, anything else as a link — instead of relying on the file extension; and a http/https path is a link, left untouched and never copied. kronikol ingest --attachments-base <dir> resolves relative paths, --clean-attachments empties the folder first so it holds exactly this run's files. Full contract: Ingesting External Captures.

FileAttachment carries the declared type as a third, optional positional value:

new FileAttachment("screenshot-end.png", path, "image/png");

ReqNRoll Automatic Capture

The Kronikol.ReqNRoll.Core package includes an AttachmentCapturingPlugin that automatically intercepts calls to IReqnrollOutputHelper.AddAttachment(filePath). Any attachment added via the Reqnroll output helper is automatically captured by Track.Attachment() — no manual Track.Attachment() call needed.

This plugin is registered automatically via [assembly: RuntimePlugin] and requires no configuration.


Conditional Step Bypass (SkipIf)

Added in v2.34.3

You can conditionally bypass a step at runtime based on a boolean property or field on the test class. When the condition is true, the step body is not executed — instead, the step is recorded as Bypassed with an optional reason.

Usage

public class PaymentTests : TestBase
{
    // Property evaluated at runtime to decide whether to skip
    protected bool PaymentGatewayUnavailable => !_gateway.IsHealthy;

    [WhenStep(SkipIf = nameof(PaymentGatewayUnavailable), SkipReason = "Gateway down")]
    private async Task WhenPaymentIsSubmitted()
    {
        // This body is NOT executed when PaymentGatewayUnavailable == true
        await _client.PostAsync("/payments", _paymentRequest);
    }
}

Properties

Property Type Description
SkipIf string Name of a bool property or field on the test class (or its base classes). Use nameof() for compile-time safety.
SkipReason string? Optional human-readable reason displayed in reports and diagrams when the step is bypassed.

Supported Member Types

  • Instance properties with a getter returning bool
  • Static properties with a getter returning bool
  • Instance fields of type bool
  • Static fields of type bool
  • Base class members — the weaver walks the inheritance chain to find the member

Behavior

  • When SkipIf evaluates to true: the step is recorded as Bypassed (not Passed or Failed), the method body does not execute, and the method hands back nothing for a void, Task.CompletedTask for a Task, and otherwise its type's default: a value, a completed Task<T>, or a completed ValueTask or ValueTask<T>. An out parameter is set to its default first.
  • When SkipIf evaluates to false: the step executes normally as if SkipIf were not set.
  • When the named member does not exist, is not bool, or the step cannot read it (an instance member on a static step, a member a derived type cannot reach, an indexer, a static abstract member), or when the step has a ref return, for which no default exists: the build warns KRONIKOL005 and the step executes normally (no bypass logic injected). <NoWarn>KRONIKOL005</NoWarn> silences it.
  • When the getter throws: the step fails with that exception, and it closes.

Report Rendering

Bypassed steps appear with a distinct status in the HTML report step list, and the BypassReason is drawn under the step text (4.6.2+; the report carried it in its data and drew it nowhere before). The scenario keeps the verdict its test framework gives it, Passed when nothing failed: only the LightBDD adapter and kronikol ingest make a scenario with a bypassed step Bypassed. In sequence diagrams, the step delimiter still appears (so the step's position in the flow is visible), but no HTTP calls or other tracked interactions are shown for it.

Examples

Feature flag pattern (base class):

public abstract class IntegrationTestBase
{
    protected bool ExternalServicesDisabled { get; set; }
}

public class OrderTests : IntegrationTestBase
{
    [WhenStep(SkipIf = nameof(ExternalServicesDisabled), SkipReason = "External services disabled in CI")]
    public void Send_notification_email() { /* ... */ }
}

Static property for environment-wide skip:

public class Tests
{
    public static bool IsCI => Environment.GetEnvironmentVariable("CI") == "true";

    [GivenStep(SkipIf = nameof(IsCI), SkipReason = "Skipped on CI")]
    public void Warm_up_local_cache() { /* ... */ }
}

How It Works

The Kronikol.StepTracking NuGet package contains:

  1. An MSBuild .targets file that auto-generates the step attribute source files into your project's intermediate output (BeforeTargets="CoreCompile")
  2. An IL weaving task (WeaveStepsTask) that runs after compilation (AfterTargets="CoreCompile") and modifies the compiled assembly

For each method decorated with a step attribute, the weaver:

  1. Builds string[] and object[] arrays from the method's parameters
  2. Injects a call to StepCollector.StartStep(keyword, text, paramNames, paramValues) at method entry
  3. Wraps the original method body in a try/catch:
    • Success path: a void or a value calls StepCollector.CompleteStep(true, null) as it returns, and a task goes through StepCollector.CompleteStepAsync, which closes the step when the task ends
    • Exception path: calls StepCollector.CompleteStep(false, message) then rethrows what the method threw, with no message if reading the exception's Message throws

The weaving is guarded against double-application via a sentinel module attribute (__StepTrackingWeaved__). A step needs no symbols, so from 5.29.0 a project with <DebugType>embedded</DebugType> or none, or a PdbFile elsewhere, is woven as well, keeping its line numbers where it has any.

Methods Left as Written

From 5.29.0, a step method the weave cannot bracket is left as the compiler wrote it. It runs, without a step, and the build warns once, with how many and the first:

warning KRONIKOL004: Kronikol.StepTracking: 2 step method(s) were left as written, so they run without a step. First: Program.cs, line 77, in ShopDriver.An_iterator: it is an iterator, whose body runs while its caller enumerates it, after the method has returned. The build lists each one at normal verbosity (-v:n).
A step method that is Why it is left
An iterator or an async iterator Its body runs while its caller enumerates it, after the method has returned
async void Nothing can wait for it, so its step would close at its first await. With a SkipIf, which a refusal would ignore, it is woven, and its step closes there
Any other type a caller can await The weaver cannot wait for it, so its step would close when the method returns

The warning also says how many of them carry a SkipIf that is not read, whose bodies run whatever it says. <NoWarn>KRONIKOL004</NoWarn> silences it, and dotnet build -warnaserror or <WarningsAsErrors>KRONIKOL004</WarningsAsErrors> makes it an error.

A method with a step attribute and no body (abstract, an interface's, extern) is not in that list, since nothing about it is wrong: an override that wants a step carries the attribute itself. -v:n prints a line for each. A tool that calls the weaver reads both lists from WeaveResult.UnwovenSteps and WeaveResult.StepsWithNoBody.


Configuration

StepTrackingOptions

StepCollector.Options = new StepTrackingOptions
{
    PrependKeyword = true,                       // Include keyword in step display text
    InlineParameters = true,                     // Show parameters inline with step text
    WhenTriggersAction = true,                   // a step attribute's keyword moves the phase: Given/But → Setup, When/Then → Action
    ShowStepDelimiters = true,                   // Inject step delimiter bars into sequence diagrams
    IncludeTrackedAssertionsInStepList = true,   // Track.That() assertions appear as sub-steps (v2.33.56)
};

// Background-step layout is a ReportConfigurationOptions concern, not StepTrackingOptions.
// Set it on the options passed to CreateStandardReportsWithDiagrams():
// new ReportConfigurationOptions { SeparateBackgroundSteps = true }
Option Default Description
PrependKeyword true Include the keyword (Given/When/Then) in step display text
InlineParameters true Show parameters inline with step text
WhenTriggersAction true A step attribute's keyword moves the phase (Given/But → Setup, When/Then → Action), for a step with no keyword above it that does so. Reqnroll's, LightBDD's and BDDfy's own steps move it whatever this says
ShowStepDelimiters true Inject step delimiter hnote bars into sequence diagrams at each top-level step
IncludeTrackedAssertionsInStepList true When false, Track.That() assertions still appear in diagrams but are not added as sub-steps in the step list

Background rendering options live elsewhere. SeparateBackgroundSteps and CollapseRepeatedStepKeywords are ReportConfigurationOptions properties, not StepTrackingOptions; set them on the options passed to CreateStandardReportsWithDiagrams(). See Report Configuration.

Disabling Step Tracking

Remove the [assembly: TrackSteps] attribute or uninstall the package. Without the attribute, the weaver skips the assembly entirely.


Step Delimiters in Sequence Diagrams

Added in v2.30.35

When step tracking is active, each top-level BDD step injects a visual delimiter bar into the sequence diagram — a black hnote spanning the full width showing Step: Given/When/Then/And/But {text}. This makes it easy to see which HTTP calls, database queries, and other tracked interactions belong to which step.

Step delimiters are emitted by all step-aware integrations:

  • StepTracking IL weaver — automatically for [GivenStep], [WhenStep], [ThenStep], etc.
  • BDDfy adapter (v2.33.23+) — automatically for each BDDfy step during execution
  • LightBDD adapter (v2.33.23+) — automatically via StepTrackingStepDecorator
  • Ingested captures (v3.0.44+) — kronikol ingest / IngestPipeline draws the same bar for every top-level step event in the tests NDJSON (e.g. a Playwright test.step, via the Playwright reporter) — see Ingesting External Captures

Delimiters are only injected for top-level steps; nested sub-steps do not produce additional delimiters. A call made in a sub-step still reads that sub-step's stepPath.

Capitalisation

Added in v3.0.45

A delimiter that prepends a keyword already starts with a capital (Given the mock is armed) and the text after it keeps the author's casing. A delimiter with no keyword — the Step: text form, and every keyword-less step in an ingested run — has its first letter upper-cased, so the bar reads as a sentence however the producer phrased it. Text whose first character is an opening quote or bracket is left exactly as it is.

Kronikol.Reports.StepText is the single implementation: ReportConfigurationOptions.CapitaliseStepText (default true) gates the step list, and the process-wide StepText.CapitaliseEnabled (also true) gates the diagram, so the picture and the list read alike. kronikol ingest --no-capitalise turns both off for an ingested run. The feature, rule and scenario titles go through the same helper (ReportConfigurationOptions.CapitaliseTitles, default true, off with the same flag), so headings read as sentences too. Full rule: Ingesting External Captures.

Tables and Doc Strings in the Bar

Added in v3.0.78

A step whose text only makes sense together with its argument — Given the following muffins exist followed by a Gherkin data table, or a doc-string payload — draws that argument inside the delimiter bar: the step text on the first line, the table as a real bordered table (white on the black bar) or the doc string as indented lines beneath it. This is on by default wherever the argument is available:

  • Reqnroll — the hooks pass the step's Table and doc string to StepCollector.StartStep. For a Scenario Outline the table Reqnroll hands the step already has its <placeholders> substituted, so each example row's diagram shows the values that row actually received.
  • StepTracking IL weaver — TabularInputs<T> / TabularOutputs<T> parameters draw as tables; when a step has more than one, each is labelled with its parameter name.
  • Ingested captures — the tests NDJSON step event's existing table and docString fields now also draw in the bar (they previously fed only the step list).
  • Cucumber Messages — the pickle step's argument (placeholders already substituted) is preferred over the authored Gherkin table, for the bar and the step list.

Mechanically the bar switches from the one-line coloured form to a styled form — hnote across <<stepDelimiter>><<stepBody>>: text\n\n|= … |\n on a single physical line, coloured by an injected .stepBody style rather than inline tags (an inline <color:white> styles only the first display line, and puts the statement under the ~1,400-character coloured-bar crash cap; the styled form rides the 16,000-character note ceiling instead). Cell content is neutralised against creole markup with PlantUML <U+00XX> escapes and zero-width-space pair-breaking, so pipes, **bold**, tags and backslashes in your data render literally. Since 3.30.2 a doc string's lines and a table's cells also escape a ~, a builtin call (%date()), a character reference (&#39;), a line that is a separator or a rule (..x.., ....) and a line that is only {{. Before, the engine ate the ~, painted the date or the ', drew the separator or the rule, and a {{ line swallowed the bar. Since 3.31.2 a backslash is written as its code point followed by a zero-width space: the Java engine (PlantUmlRendering.Local and Server) read the \<U+200B> written before as an escape and drew U+200B> after every backslash, so C:\temp came out as C:U+200B>temp. A captured <U+hhhh> is drawn as written instead of as the character it names, and NEL, LINE SEPARATOR and PARAGRAPH SEPARATOR, which ended the bar's one statement and lost the diagram on both engines, are written as code points. The Hide Steps toggle strips the styled bar exactly like the legacy one.

The step text itself reaches PlantUML as written, so creole in a step name styles it, as it always has, with one exception since 3.29.6: <&, <: and <$ are escaped. They are PlantUML's icon, emoji and sprite syntax. The first two make the engine load a bundle the report's renderers cannot, and the third drops the text. LightBDD writes a table parameter into the step name as <$name>, which before 3.29.6 the bar painted as ""; it now reads <$name>. Since 3.30.1 a builtin call, %name(, is escaped too: PlantUML's preprocessor evaluates one anywhere in a line, so a step named for %date() painted the date. Test names in the test delimiter bar and assertion text in assertion notes are escaped the same way. Since 3.31.2 a line break in a test name (a parameterised test whose argument holds one) is drawn as a space: it split the divider's statement, and the rest ran as a line of its own. An assertion note's lines also get the preprocessor's line-start escapes a captured body gets (a line opening with ' or !, or reading end note; see Content Formatting), but not creole's: assertion text keeps its markup, so a line opening with = is still a heading and one opening with | a table. Since 3.31.8 a backslash in a step name, a test name, a UI action's label or a span name is drawn as written: each is one PlantUML statement, where both engines read \n, \r and \l as line breaks, \t as a tab and \\ as one backslash, so a step naming C:\temp\new drew C:, a tab, emp and then ew on a line of its own. It is written as the bar writes a doc string's backslash, <U+005C><U+200B>. A literal \n in a step name therefore no longer breaks the line.

Since v3.0.81 each table or doc string is padded with one blank display line above and below it inside the bar (adjacent blocks share the line between them), so the argument doesn't sit cramped against the step text or the bar's border. Multi-line step text on its own gets no padding — it is the step text.

A very wide table widens the whole diagram (the bar spans every lifeline). The engine cannot be crashed by this — past its size ceiling it refuses with a legible "Diagram too large" message, and hiding steps re-renders the diagram without the bar — see Large Response and Diagram Handling.

Bars without a table or doc string are emitted byte-for-byte as before.

Report Toggle

When any test in the report contains step delimiters, a "Hide Steps" / "Show Steps" toggle button appears in the report toolbar (alongside the existing Headers and Assertions toggles).

  • Default: Shown — delimiters are visible in diagrams
  • Hide Steps: Re-renders all diagrams with delimiter bars stripped out
  • The toggle works at both report level (affects all diagrams) and scenario level (affects a single test)

No toggle when unused: If no tests in the report contain step delimiters, the Steps toggle is not shown.

Disabling Delimiters

Set ShowStepDelimiters = false to suppress delimiter injection while keeping step tracking active. A call keeps its step with the delimiters off, in a step or between two, where it stays with the step above it:

StepCollector.Options = new StepTrackingOptions
{
    ShowStepDelimiters = false
};

A call made inside a step keeps its stepPath with the bars hidden, as it names the step itself. A call made between steps reads the step before it, with the bars hidden or drawn, and since 5.29.4 whether or not that step made a call.


Framework Compatibility

Step tracking works with all supported test frameworks:

  • xUnit v3 — Steps are collected via StepCollector and populated on the Scenario object
  • TUnit — Same collection mechanism
  • NUnit 4 — Same collection mechanism
  • BDDfy — Each BDDfy step is a step, and a step method that runs outside one stands as itself: see Step Methods Outside a Framework Step. StepCollector steps are used alone when BDDfy's own Steps collection is empty (for inline tests)
  • ReqNRoll — Each Gherkin step is a step, and the step methods its step definition calls are drawn as its sub-steps: see Sub-Steps in Reqnroll Steps
  • LightBDD — LightBDD has its own step system; use that instead of this package

The step data flows into the Generated Reports HTML alongside the sequence diagrams.


Comparison with BDD Frameworks

Feature StepTracking LightBDD BDDfy
Step definition Attributes Attributes + runner Fluent API + reflection
Framework dependency None (IL weaving) LightBDD runtime BDDfy runtime
Parameter capture Automatic (IL weaving) Framework-provided Method name convention
Phase integration Automatic Requires Kronikol adapter Requires Kronikol adapter
Assertion sub-steps With AssertionTracking Not supported Not supported
Async support Full async Full async Full async

Limitations

  • Single test assembly: The [assembly: TrackSteps] attribute must be present in each test assembly that uses step attributes.
  • Coverage: a coverage tool reads a woven step as it reads the method with no weave. In an optimised build a step's first line reads one more hit, as the weave gives its first instruction a sequence point, so that a fault the JIT raises out of line, an array's bounds or an overflow, keeps its frame's file and line. No line changes between covered and not. A step with a SkipIf gets no such point, and neither does any step in a build that is not optimised.
  • Partial methods: the compiler writes no state-machine attribute on a partial method, so [Step] partial IEnumerable<int> M() reads as a value, whose step closes before its body runs, and async partial void M() reads as a void.
  • Runtime async (<Features>runtime-async=on</Features>): the step is drawn, with its failure, its bypass and its sub-steps, and it ends when its work does. A keyword step moves the phase inside itself and not for its caller, as nothing runs after its frame to carry the change.

See Also

Home · 🔍 Search this wiki Full-text search across every page. The “Pages” list above matches page and heading names, not page content.


Demo


Getting Started

Common Tasks

Integration Guides

Uninstrumentable / polyglot backends

Extensions

Configuration

Features

Reference

Clone this wiki locally