Mert Özen Further With Every Line
Project Management Requirements Analysis — #08

BDD in .NET: Binding Gherkin Scenarios to the Bookstore API with Reqnroll 3

Mert Özen Nov 14, 2026 15 min 5 views
BDD in .NET: Binding Gherkin Scenarios to the Bookstore API with Reqnroll 3

SpecFlow reached end of life on December 31, 2024; the current way to run Gherkin scenarios in .NET is Reqnroll 3, with the Reqnroll.xunit.v3 package for xUnit v3. We bind Bookstore's institutional order scenarios to the API on .NET 10: project setup, step definitions, context injection, WebApplicationFactory, a diagnostic hook for failures, living documentation with the HTML formatter and filtering by tag, plus a pitfall observed under Turkish culture.

Version note: The code in this post was built and run with .NET 10, Reqnroll 3.3.4 (Reqnroll.xunit.v3), xUnit v3 4.0.3 and Microsoft.AspNetCore.Mvc.Testing 10.0.15.

Guess First

The feature file from the previous post is ready; now it is time to write the code that binds it to the Bookstore API. Before starting, Ayşe searches the web for "SpecFlow xUnit WebApplicationFactory" and finds a fairly popular blog post from 2021. It installs the SpecFlow.xUnit package, its steps come from the TechTalk.SpecFlow namespace, and data is passed between steps with ScenarioContext.Current["order"].

Two questions. If Ayşe follows that post step by step, will she end up with a test project that runs on .NET 10? If not, is the problem the package name, the namespace, or the way data is passed between steps?

From SpecFlow to Reqnroll

The first half of the answer is history. For many years SpecFlow was the standard tool for running Gherkin in .NET. Its owner Tricentis announced in December 2024, at short notice, that SpecFlow would reach end of life on December 31, 2024. According to the Reqnroll team's announcement of January 9, 2025, the project's GitHub repositories were deleted and its support pages were shut down on January 1, 2025; the NuGet packages are still there because nuget.org does not allow deleting them, but they will receive no updates for new .NET versions. According to the same announcement, SpecFlow supported .NET 7 at most.

Reqnroll is a community fork started on the SpecFlow code base in early 2024. Because "SpecFlow" is a Tricentis trademark, the project continues under a new name. Since the code base is shared, migrating is relatively easy but not effortless: package names became Reqnroll.*, and the namespace is Reqnroll instead of TechTalk.SpecFlow. xUnit v3 support arrived with version 3.1, released on September 29, 2025, as the Reqnroll.xunit.v3 package; on the day of writing the latest version on NuGet is 3.3.4.

The second half of the answer is in how data is passed. When Ayşe copies the ScenarioContext.Current line from the blog post into a Reqnroll project, the compiler says:

warning CS0618: 'ScenarioContext.Current' is obsolete: 'Please get the ScenarioContext via Context Injection - https://go.reqnroll.net/doc-migrate-sc-current'

A static notion of "the current scenario" loses its meaning once scenarios run in parallel; that is why Reqnroll recommends context injection. That is exactly the approach we use below.

Setting Up the Project

The solution has two projects: Bookstore.Api and Bookstore.Specs, which tests it. The feature file from post 07 lives in the Bookstore.Specs/Features folder. The test project file:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <OutputType>Exe</OutputType>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.15" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.12.0" />
    <PackageReference Include="Reqnroll.xunit.v3" Version="3.3.4" />
    <PackageReference Include="xunit.v3" Version="4.0.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="4.0.1" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
    <ProjectReference Include="..\..\src\Bookstore.Api\Bookstore.Api.csproj" />
  </ItemGroup>

</Project>

Reqnroll.xunit.v3 brings both Reqnroll and the MSBuild generator that produces test classes from feature files; there is no need to add a generator package separately. Because xUnit v3 test projects are built as executables, OutputType is Exe. Microsoft.NET.Test.Sdk and xunit.runner.visualstudio are the packages the Reqnroll documentation lists for xUnit v3; Visual Studio's Test Explorer finds the tests through them. The Using Include="Xunit" line lets step definitions use Assert without a using in every file.

The first dotnet test run, however, is red:

$ dotnet test
Microsoft.Testing.Platform.MSBuild.targets(395,5): error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error

The error comes from the Microsoft Testing Platform integration that xUnit v3 4.0.3 brings into the project: on the .NET 10 SDK, dotnet test no longer runs projects using that platform through the old VSTest path and asks you to opt in to the new mode explicitly. The choice is made with a global.json file at the root of the solution:

{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

The last piece is Reqnroll's own settings file. Here we define the language of the feature files and the HTML report we will discuss later in this post:

{
  "$schema": "https://schemas.reqnroll.net/reqnroll-config-latest.json",
  "language": {
    "feature": "en-US"
  },
  "formatters": {
    "html": {
      "outputFilePath": "reports/institutional-orders.html"
    }
  }
}

For the feature language, the Reqnroll documentation recommends a specific culture such as en-US over a neutral one such as en. If no separate language.binding setting is given, step arguments are converted with this culture too; we will come back to why that matters on a machine in Turkey in the In Production section.

Step Definitions and Bindings

A step definition binds the text of a Gherkin step to a C# method. Reqnroll scans classes marked with the [Binding] attribute; the text in the [Given], [When] and [Then] attributes is the pattern that matches the step. The catalog step looks like this:

using System.Globalization;
using Bookstore.Api.Catalog;
using Bookstore.Specs.Support;
using Reqnroll;

namespace Bookstore.Specs.StepDefinitions;

[Binding]
public sealed class CatalogSteps(BookstoreApi api)
{
    [Given("the catalog contains these books:")]
    public void GivenTheCatalogContainsTheseBooks(DataTable table)
    {
        foreach (DataTableRow row in table.Rows)
        {
            api.AddBook(new Book(
                row["ISBN"],
                row["Title"],
                decimal.Parse(row["List price"], CultureInfo.InvariantCulture),
                int.Parse(row["Stock"], CultureInfo.InvariantCulture),
                decimal.Parse(row["Campaign discount"].TrimEnd('%'), CultureInfo.InvariantCulture) / 100m));
        }
    }
}

Each row of the table becomes a Book record. Converting numbers explicitly with CultureInfo.InvariantCulture makes the table's "250.00" read the same regardless of the machine's culture. The order steps:

using System.Globalization;
using Bookstore.Api.InstitutionalOrders;
using Bookstore.Specs.Support;
using Reqnroll;

namespace Bookstore.Specs.StepDefinitions;

[Binding]
public sealed class InstitutionalOrderSteps(InstitutionalOrderDriver orders)
{
    [When("the institution orders {int} copies of {string}")]
    public Task WhenTheInstitutionOrdersCopiesOf(int copies, string title) =>
        orders.PlaceByTitleAsync([(title, copies)]);

    [When("the institution orders:")]
    public Task WhenTheInstitutionOrders(DataTable table) =>
        orders.PlaceByTitleAsync(table.Rows.Select(r =>
            (r["Title"], int.Parse(r["Copies"], CultureInfo.InvariantCulture))));

    [When("the institution submits this ISBN list:")]
    public Task WhenTheInstitutionSubmitsThisIsbnList(DataTable table) =>
        orders.PlaceAsync(table.Rows.Select(r =>
            new OrderLineRequest(r["ISBN"], int.Parse(r["Copies"], CultureInfo.InvariantCulture))));

    [Then("the order is accepted")]
    public void ThenTheOrderIsAccepted() => orders.AcceptedOrder();

    [Then("the order is rejected")]
    public void ThenTheOrderIsRejected() => Assert.NotEmpty(orders.RejectionReasons());

    [Then("the order is rejected because {string}")]
    public void ThenTheOrderIsRejectedBecause(string reason) =>
        Assert.Contains(orders.RejectionReasons(), r => r.Contains(reason, StringComparison.Ordinal));

    [Then("{string} is charged {decimal} with a {int}% discount")]
    public void ThenTheLineIsCharged(string title, decimal lineTotal, int discountPercent)
    {
        InstitutionalOrderResponse order = orders.AcceptedOrder();
        OrderLineResponse line = Assert.Single(order.Lines, l => l.Title == title);
        Assert.Equal(discountPercent / 100m, line.AppliedDiscountRate);
        Assert.Equal(lineTotal, line.LineTotal);
    }

    [Then("the order total is {decimal}")]
    public void ThenTheOrderTotalIs(decimal total) =>
        Assert.Equal(total, orders.AcceptedOrder().Total);

    [Then("the whole order waits until every title is in stock")]
    public void ThenTheWholeOrderWaitsForStock() =>
        Assert.Equal("WaitingForStock", orders.AcceptedOrder().Status);
}

The {int}, {string} and {decimal} in the attributes are Cucumber expression parameters. They read far better than regular expressions, and Reqnroll converts the matched text to the method's parameter type itself. There is no separate binding for the Scenario Outline step the order is <outcome>: once Reqnroll fills in the placeholder, the step becomes "the order is accepted" or "the order is rejected" and matches one of the two existing bindings.

Also notice what this class does not contain: the discount calculation, rounding, the minimum quantity check. The steps only turn the sentence in the scenario into a request or a comparison. If a step has no binding, Reqnroll fails the scenario and suggests a skeleton for the missing binding. Running the imperative scenario from the start of post 07 in this project gives output that starts like this:

$ dotnet test
failed Discount on a school order (273ms)
  Reqnroll.xUnit3.ReqnrollPlugin.XUnitPendingException : Test pending: No matching step definition found for one or more steps.
  using System;
  using Reqnroll;
  
  namespace MyNamespace
  {
      [Binding]
      public class StepDefinitions
      {
          private readonly IReqnrollOutputHelper _outputHelper;
  
          public StepDefinitions(IReqnrollOutputHelper outputHelper)
          {
              _outputHelper = outputHelper;
          }
          [Given("I open {string}")]
          public void GivenIOpen(string p0)
          {
              throw new PendingStepException();
          }
          
          ...

The suggested skeleton is a starting point, not a final version to copy. Turning parameter names like p0 and method names like GivenIOpen into business language, and merging the places where the same pattern is suggested twice into one binding, is your job. The right response in this example, though, is not to write bindings but to make the scenario declarative as in post 07.

Context Injection: One API per Scenario

The step classes ask for other classes in their constructors: InstitutionalOrderSteps needs an InstitutionalOrderDriver, which needs a BookstoreApi. Reqnroll's context injection builds this chain once per scenario: every step class in the same scenario gets the same driver and the same API instance, and objects that are IDisposable are disposed when the scenario ends. Data travels between steps through these shared objects, not through a static dictionary.

using Bookstore.Api.Catalog;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Specs.Support;

// One instance per scenario: Reqnroll creates it through context injection
// and disposes it when the scenario ends.
public sealed class BookstoreApi : IDisposable
{
    private readonly InMemoryCatalog _catalog = new();
    private readonly Dictionary<string, Book> _booksByTitle = [];
    private readonly WebApplicationFactory<Program> _factory;

    public BookstoreApi()
    {
        _factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
        {
            builder.UseSetting("Logging:LogLevel:Default", "Warning");
            builder.ConfigureTestServices(services => services.AddSingleton<ICatalog>(_catalog));
        });
        Client = _factory.CreateClient();
    }

    public HttpClient Client { get; }

    public void AddBook(Book book)
    {
        _catalog.Add(book);
        _booksByTitle[book.Title] = book;
    }

    public Book BookTitled(string title) => _booksByTitle[title];

    public void Dispose()
    {
        Client.Dispose();
        _factory.Dispose();
    }
}

BookstoreApi starts the API in memory with WebApplicationFactory and, through ConfigureTestServices, replaces the application's ICatalog registration with the scenario's own catalog. That way, the table in the Background becomes the catalog the API sees. In .NET 10 the Program class of minimal API projects is generated as public by default, so WebApplicationFactory<Program> compiles without any extra declaration. Details such as custom factories, test authentication and configuration are the subject of the WebApplicationFactory post in the Integration and E2E Testing series.

Building a new factory for every scenario may look expensive; in this project all ten scenarios finish in about a second. In return, every scenario runs with its own catalog, fully isolated from the others. When the number of scenarios reaches the hundreds, you can consider sharing the factory per feature and resetting state at the start of each scenario; but then you have to make sure scenarios running in parallel do not share that state.

There is one more layer between the steps and HTTP: the driver.

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Bookstore.Api.InstitutionalOrders;
using Microsoft.AspNetCore.Mvc;

namespace Bookstore.Specs.Support;

// Talks HTTP so that step definitions can talk business.
public sealed class InstitutionalOrderDriver(BookstoreApi api)
{
    public HttpResponseMessage? LastResponse { get; private set; }

    public string LastResponseBody { get; private set; } = "";

    public Task PlaceByTitleAsync(IEnumerable<(string Title, int Copies)> lines) =>
        PlaceAsync(lines.Select(l => new OrderLineRequest(api.BookTitled(l.Title).Isbn, l.Copies)));

    public async Task PlaceAsync(IEnumerable<OrderLineRequest> lines)
    {
        PlaceInstitutionalOrderRequest request = new("PO-2026-0412", lines.ToList());
        LastResponse = await api.Client.PostAsJsonAsync("/api/institutional-orders", request);
        LastResponseBody = await LastResponse.Content.ReadAsStringAsync();
    }

    public InstitutionalOrderResponse AcceptedOrder()
    {
        Assert.Equal(HttpStatusCode.Created, LastResponse?.StatusCode);
        return JsonSerializer.Deserialize<InstitutionalOrderResponse>(LastResponseBody, JsonSerializerOptions.Web)!;
    }

    public IReadOnlyList<string> RejectionReasons()
    {
        Assert.Equal(HttpStatusCode.UnprocessableEntity, LastResponse?.StatusCode);
        ValidationProblemDetails problem =
            JsonSerializer.Deserialize<ValidationProblemDetails>(LastResponseBody, JsonSerializerOptions.Web)!;
        return problem.Errors.SelectMany(e => e.Value).ToList();
    }
}

The driver is the only place that knows the shape of the request body, the endpoint address, HTTP status codes and the problem details response. Step definitions talk about "the order was accepted" or "it was rejected because of this"; if the endpoint address changes, one line changes, and neither the steps nor the feature file do. For projects that want Microsoft's DI container instead of Reqnroll's own, there is also the Reqnroll.Microsoft.Extensions.DependencyInjection plugin; in this small project the built-in context injection is enough.

Hooks and Diagnosing Failures

Hooks are methods that run at specific points in the test lifecycle: before and after the test run, a feature, a scenario or a step. The Bookstore team's only hook writes the API's last response to the output when a scenario fails:

using Reqnroll;

namespace Bookstore.Specs.Support;

[Binding]
public sealed class DiagnosticsHooks(
    ScenarioContext scenario,
    InstitutionalOrderDriver orders,
    IReqnrollOutputHelper output)
{
    [AfterScenario]
    public void WriteLastResponseWhenScenarioFails()
    {
        if (scenario.TestError is not null && orders.LastResponse is not null)
        {
            output.WriteLine($"Last response: {(int)orders.LastResponse.StatusCode} {orders.LastResponseBody}");
        }
    }
}

IReqnrollOutputHelper is Reqnroll's output interface that is independent of the test framework; it writes to the scenario's output without tying the code to xUnit's own output helper. To see the hook earn its keep, let's break rounding in the API on purpose and write .NET's default MidpointRounding.ToEven instead of MidpointRounding.AwayFromZero. That is exactly the code that would have been written had accounting's answer not arrived in post 05:

$ dotnet test
failed Each line total is rounded to the cent, half away from zero (38ms)
  Assert.Equal() Failure: Values differ
  Expected: 229.37
  Actual:   229.36
  Standard output
    Given the catalog contains these books:
      --- table step argument ---
      | ISBN          | Title                | List price | Stock | Campaign discount |
      | 9786050000016 | Grade 5 Mathematics  | 250.00     | 500   | 0%                |
      | 9786050000023 | Grade 5 Science      | 84.95      | 500   | 0%                |
      | 9786050000030 | Short Stories Reader | 120.00     | 15    | 25%               |
    -> done: CatalogSteps.GivenTheCatalogContainsTheseBooks(<table>) (0,0s)
    When the institution orders:
      --- table step argument ---
      | Title               | Copies |
      | Grade 5 Mathematics | 20     |
      | Grade 5 Science     | 3      |
    -> done: InstitutionalOrderSteps.WhenTheInstitutionOrders(<table>) (0,0s)
    Then "Grade 5 Science" is charged 229.37 with a 10% discount
    -> error: Assert.Equal() Failure: Values differ
    Expected: 229.37
    Actual:   229.36 (0,0s)
    And the order total is 4729.37
    -> skipped because of previous errors
    Last response: 201 {"orderId":"3a17164c-ac7a-48ad-8f8a-25243cc98090","status":"ReadyToShip","totalQuantity":23,"listTotal":5254.85,"total":4729.36,"lines":[{"isbn":"9786050000016","title":"Grade 5 Mathematics","quantity":20,"appliedDiscountRate":0.10,"lineTotal":4500.00},{"isbn":"9786050000023","title":"Grade 5 Science","quantity":3,"appliedDiscountRate":0.10,"lineTotal":229.36}]}

The output explains in business language why the red test is red: which scenario, which step, the expected and actual amounts, why the next step was skipped, and the API's full response. The rule behind the one-cent difference between 229.37 and 229.36 is written in the scenario's name.

Living Documentation: Formatters and Tags

As long as they run regularly, feature files are an up-to-date document of the system's current behaviour; this is called living documentation. Reqnroll 3 produces this document with two built-in formatters: html shows features, rules, scenarios and their results in a single HTML file, while message writes the same information in the Cucumber Messages format as NDJSON that other tools can read. With the reqnroll.json above, every dotnet test produces reports/institutional-orders.html in the test project's output folder; in this project the file is about 1 MB, contains no external script or stylesheet references and opens on its own. The Reqnroll documentation describes this formatter as the default replacement for SpecFlow+ LivingDoc. Without touching the settings file, for example to produce the report only in CI, you can also use the REQNROLL_FORMATTERS_HTML environment variable.

Keep the report as an artifact of every CI build and link it somewhere Selin can find it. If the Product Owner does not need to message a developer to ask how a rule works today, living documentation is doing its job.

The @REQ-BULK-05 tag we put on a rule in post 07 pays off here. Reqnroll passes tags to xUnit as Category traits, so you can run only the discount rule's scenarios:

$ dotnet test --filter-trait "Category=REQ-BULK-05"

Test run summary: Passed!
  total: 3
  failed: 0
  succeeded: 3
  skipped: 0
  duration: 663ms

The rule's three scenarios ran; the other seven did not. The ID we gave the requirement in the first post is now a filter that selects that requirement's tests. Instead of a heavy matrix, this is enough traceability for most teams: the same requirement ID in the story, in the tag and in the commit message.

The TypeScript Side and When BDD Isn't Worth It

The same feature files can run on the TypeScript side too. Cucumber's JavaScript implementation @cucumber/cucumber (13.3.0 on the day of writing) reads Gherkin, step definitions are written in TypeScript and Playwright drives the browser. For those who want Playwright Test's own runner, there is also the playwright-bdd package, which turns feature files into Playwright tests. This route makes sense when scenarios need to be verified through the UI; but as we saw in post 07, verifying most business rules at the API level is both faster and less brittle. Playwright itself is the subject of the Integration and E2E Testing series.

There are also cases where a BDD tool is not worth it, and honesty requires saying so. In a one-person team with nobody else to talk to, the Gherkin layer only adds a translation step. If stakeholders never read the scenarios and never will, feature files become a more expensive form of tests than ordinary ones; in that case, turning the rules and examples from the example map directly into xUnit tests is cheaper. The habit of Example Mapping and talking in examples keeps its value in these cases too: the most valuable part of BDD is discovery, not the tool.

In Production

Parallel execution: xUnit runs different test classes, that is, different feature files, in parallel. Objects built through context injection are scenario-specific and therefore safe; state held in static fields is not. If there is an expensive resource that must be shared (such as a database container), set it up in a test-run-level hook and decide up front how to isolate its state between scenarios. Reqnroll's generator.addNonParallelizableMarkerForTags setting keeps features with certain tags out of parallel execution.

Turkish culture: the code in this post was built on a machine whose operating system culture is tr-TR, and two things were observed. The first is expected and good: the {decimal} parameter read "229.37" with the feature language's culture, en-US, and the tests passed. The second is a surprise: in the test method names Reqnroll generates from scenario titles, the capital "I" became "İ" under Turkish casing rules, for example TheİnstitutionalDiscountİsAppliedToEveryLine. When the same project is built with the en-US culture, the method name is TheInstitutionalDiscountIsAppliedToEveryLine. Because the tests' display names come from the scenario titles, the output looks the same, but tools that filter by full method name or track test history in CI by method name may see a developer machine with Turkish culture and an en-US build agent as two different tests. Filtering with tags instead of method names is the easiest way to avoid this.

Duration: scenarios should run on every CI build, so they need to stay fast. In this project ten scenarios finish in about a second because the API is in memory, there is no browser and there are no external dependencies. Watch the duration as the number of scenarios grows; a slowing scenario suite is the first step towards the team no longer running it locally.

Common Pitfalls

The first pitfall is accumulating business logic in step definitions. "Calculating the expected total inside the step and comparing it with the API's result" looks tempting, but then the test tests a second implementation of the rule, and both implementations can make the same mistake. The rule lives in one place, in the application:

public static class InstitutionalOrderRules
{
    public const int MinimumTotalQuantity = 20;
    public const decimal InstitutionalDiscountRate = 0.10m;

    // Campaign and institutional discounts never stack; the larger one wins.
    public static decimal DiscountRateFor(Book book) =>
        Math.Max(InstitutionalDiscountRate, book.CampaignDiscountRate);

    // Accounting rounds each line total to the cent, half away from zero.
    public static decimal LineTotal(Book book, int quantity) =>
        Math.Round(book.ListPrice * quantity * (1 - DiscountRateFor(book)), 2, MidpointRounding.AwayFromZero);
}

The steps only compare the expected number in the scenario with the API's result. The expected number sits in the feature file as an example discussed in Example Mapping; it is not calculated.

The second pitfall is dozens of variations of the same step. Sentences written slightly differently in every scenario, like "the institution orders 19 copies of X", "the school orders 19 books of X", "19 copies of X are ordered by the institution", each produce a separate binding and turn the step library into a heap nobody can remember. This project has three bindings for ordering: a single book, a table with book titles and a table with ISBNs. When writing a new scenario, look at the existing steps first and use one if it already says the same thing.

The third is copying old SpecFlow blog examples. The post from Guess First is not the only one; a large share of .NET BDD examples on the web date from the SpecFlow era. Package names, namespaces, ScenarioContext.Current and tools that no longer exist, such as SpecFlow+ LivingDoc, show up in them often. Check the date of the example code and which package it uses; when in doubt, start from the example in Reqnroll's own documentation.

The fourth is writing the feature file to fit the steps. Distorting a scenario's sentence so that it matches existing bindings sacrifices the reader to the code. If the sentence is not right for Selin, change the binding, not the scenario.

A Small Experiment

★☆☆ Set up the project from this post and see the ten scenarios pass with dotnet test. Then change InstitutionalDiscountRate in InstitutionalOrderRules to 0.12 and examine which scenarios turn red and with which message.

★★☆ Write a Rule and two scenarios for the "ship the missing ones later" slice that moved to the second release in post 05. First run them without bindings and look at the skeleton Reqnroll suggests; then write the bindings by extending the existing driver, without putting business logic into the steps.

★★★ Show the report generated by the HTML formatter to a stakeholder and watch whether they can find a rule's current behaviour on their own. Where they cannot, decide whether the problem is in the report or in the Feature and Rule titles, and fix the feature file accordingly.

With this post the Requirements Analysis series is complete. We started from Hakan's "Excel upload screen" email; with elicitation we found the real need, we moved from a use case to stories and from a story map to the first release, clarified the rules with examples through Example Mapping, wrote the quality expectations in measurable form, and finally turned those examples into a specification that runs on every build and that Selin can read. There are two natural paths from here: the TDD series for writing the unit tests beneath the scenarios, and the Integration and E2E Testing series for verifying the same API end to end with a real database and a browser.