StoryD

StoryD takes inspiration from Rob Fonseca‑Ensor’s original StoryQ project for .NET. It brings the same fluent, narrative‑styled testing approach to Dart, without the baggage of external files or tooling.

The API is deliberately simple and flexible. You can write stories and scenarios that read cleanly, customise the domain-specific language elements, and plug in your own callback logic. It supports complex stories, multiple scenarios, and structured step handling, while still feeling natural to read and maintain.

StoryD Fluent DSL Screenshot

Why create a new BDD API?

BDD has always suffered from awkward tooling. Traditional unit‑test frameworks force you to bend the code to fit the story, and tools like Gherkin or Cucumber introduce extra files, conversions, and execution layers. That overhead makes BDD harder to adopt than it should be.

StoryD avoids all of that. It lets you write BDD tests directly in Dart, using a fluent API that reads like prose and captures intent. It wraps the existing Dart group and test functions, so you can mix BDD with your existing tests without rewriting anything.

Why BDD at all?

Test‑first development gives developers confidence to change code. When every behaviour is covered by tests, you can refactor freely and move faster because you know defects will be caught early. That safety net is what makes genuine agility possible.

BDD strengthens this by anchoring the tests to the project’s specifications. Instead of simply checking code paths, the tests describe the behaviour the system must deliver. This keeps the implementation aligned with intent, even as the code evolves. It also prevents a common TDD trap where a failing test is rewritten to match the implementation rather than the other way around.

Writing the specification directly into the test code is the real advantage. It’s more verbose, but it means that months or years later you can read the tests and immediately understand what the system is supposed to do and what each test is verifying. That clarity speeds up onboarding, reduces hesitation to change code, and helps minimise technical debt.

How to use StoryD

The fluent syntax begins with the story() function. You can optionally chain DSL elements to describe a feature in natural prose, and while not every test needs a full narrative, it’s worth defining at least one story per test file so both developers and non‑developers can immediately see what the tests are intended to capture.

story('A BDD test framework using Dart')
  .asA(Software Developer')
  .iWant('A BDD test framework for Dart')
  .SoThat('I can create really awesome tests');
var story = 
  Story('A BDD test framework using Dart')
    .asA(Software Developer')
    .iWant('A BDD test framework for Dart')
    .SoThat('I can create really awesome tests');

Multiple story templates are supported in StoryD’s fluent DSL, so you can express stories in whatever format your team prefers. Just keep it consistent—mixing templates within a single story will compile, but it will definitely confuse anyone reading it later. As you build a story, the DSL naturally shifts from the narrative into scenario steps, returning a Scenario object. If you prefer, you can skip the narrative entirely and jump straight into scenarios. Both approaches behave the same, so choose whichever fits your workflow.

// With the feature template
story('A BDD test framework using Dart')
  .asA(Software Developer')
  .iWant('A BDD test framework for Dart')
  .SoThat('I can create really awesome tests')
  .WithScenario('Testing the framework works');

// Without the feature template
story('A BDD test framework using Dart')
  .withScenario('Testing the framework works');

You can’t chain withScenario() to itself, but you can chain multiple scenarios as long as at least one executable step has been defined. Executable steps are given(), when(), then(), and and(), and are applied with a fluent syntax in the familiar Given–When–Then formnat. Each scenario allows only one instance each of the given(), when(), and then() functions, while and() can be repeated to extend any of those sections. Every scenario block executes as a single standart Dart test in the background, removing the need to call Dart test functions directly.

// An example with scenario chaining
story('A BDD test framework using Dart')
.withScenario('Scenarios should work ')
  .given('An initial test setup'.step)
  .when('I call a function'.step)
  .then('expect the function will be called.'.step)
.withScenario('Making a coffee afterwards'.step)
  .given('some coffee'.step)
    .and('a tired developer'.step)
  .when('the developer drinks the coffee'.step)
    .and('the coffee is sipped'.step)
  .then('the developer is more alert'.step)
    .and('the coffee amount is decreased'.step)
    .and('the developer is tempted to eat a cookie'.step)
.withScenario('another scenario'.step)
  .when('something is triggered'.step)
  ...
  ...

The .step properties are a deliberate trade‑off to keep the syntax simple and readable without trying to mimic the method overloading used in the original StoryQ—something Dart doesn’t support. They’re implemented as extension methods on String and Function types, which allows both strings and callbacks to appear naturally in the test output and produce clear, step descriptions.

Executable steps require callback functions. If a scenario contains no callbacks, the test fails to prevent developers from assuming that validation has occurred. When using anonymous functions or lambdas, you must supply a string description so the output remains human‑readable. If you provide an empty or null description and don’t use a named callback function, StoryD will throw an exception.

// this will throw an exception
given((){}.step);

// as will this
given("".step);

when("this is safe".step);

// this may result in a false positive test if no other
// scenario steps do anything inside the callback
when("description".withCallback( (){} ))

// simple test invocation using an anonymous function
then("description".withCallback( (){ expect(some_value, equals(an_expected_value)); } ))

// sending arguements to the callback when it is a lambda
given("description".withCallback( (myArgType args) => {...} ).withParams(myArgTypeObject) )

Anonymous methods are useful for quick setup or triggering behaviour, but as the previous example shows, they can make tests harder to read—especially when writing expectations—and they tend to clutter files with repetitive code. Using a named function avoids that problem. You don’t need to supply a string description, and the function name is automatically parsed into the descriptive text used in the test output.

// The callbacks
void function anInitialTestSetup() {...}   // do your 'given' test setup here
void function iCallAFunction() {...}   // trigger your 'when' behaviours here
void function expectTheFunctionWillBeCalled() {...}  // define your 'then' test expectations here
// The test definition uses the callbacks to perform testing steps
.withScenario('Scenarios should work')
  .given(anInitialTestSetup.step)
  .when(iCallAFunction.step)
  .then(expectTheFunctionWillBeCalled.step)

On execution, test output is parsed, and the callback function names expended to produce readable test output.

With Scenario: 'Scenarios should work'
   Given an initial test setup
   When i call a function
   Then expect the function will be called

As with the anonymous and lambda options, function args can be passed to the callbacks.

// The callbacks
void function anInitialTestSetup() {...}  
void function iCallAFunction(myArgType args) {...}   
void function expectTheFunctionWillBeCalled(String text) {...} 
void function expectTheBoolFuctionAsWell(bool yesNo) {...} 
// The test definition uses the callbacks
.withScenario('Scenarios should work ')
  .given(anInitialTestSetup.step)
  .when(iCallAFunction.withParams(myArgTypeObject))
  .then(expectTheFunctionWillBeCalled.withParams("with a string"))
    .and(expectTheBoolFuctionAsWell.withParams(true))

The values passed into withParams() can be used anywhere inside your callbacks—whether for expectations, setup, or any other scenario‑specific logic. This provides a way to reuse callback functions when performing repetitive tests with variable test data.

Once you’ve defined your story and scenarios, you run the tests using the execute() function, which can be invoked in a couple of different ways, depending on how you prefer to organise your test code.

// Define first, trigger later
var tests = story('An awesome story')
              .asA('role')
              .iWant('a feature')
              .soThat('i benefit')
              .withScenario('a test case')
                .given('a preconfigured system to test'.step)
                .when(iTriggerSomeBehaviour.step)
                .then(iExpectACertainOutcome.step);
            .asStory;
  ...
  ...                  
tests.execute();                  
// as a variation of the previous example
Story anAwesomeTest() { /* return a fully defined BDD story */ };
  ...
  ...
void main() {
  // Execute the story here, keeping the main function uncluttered.
  anAwesomeTest.execute(); 
}

// trigger at definition
story('An awesome story')
  .asA('role')
  .iWant('a feature')
  .soThat('i benefit')
  .withScenario('a test case')
    .given('a preconfigured system to test'.step)
    .when(iTriggerSomeBehaviour.step)
    .then(iExpectACertainOutcome.step)
.execute();                  

Testing your code's exception handling

Exception testing has always been one of the messier parts of unit testing. Even with good frameworks and disciplined practices, the result is usually repetitive, brittle code that’s almost as unpleasant to maintain as the implementation it’s testing.

StoryD takes a more elegant approach. Exception handling is built directly into the test execution, so your tests keep running without collapsing the suite. You also get a simple extension method that lets you verify both the expected exception type and, optionally, its message. Use expectException<T>([String? message]), where T is the exception type you expect and message is the optional message to match.

story()
  .withScenario('Test for an expected exception')
    .given(aTestSetupPrimedToFail.step)
    .when(weInvokeFailureBehaviour.expectException<Exception>())
  .withScenario('verify an expected exception and its error message')
    .given(aTestSetupPrimedToFail.step)
    .when(weInvokeFailureBehaviour
          .expectException<Exception>('Exception: the message'))
.execute();

You can still use withParams() when testing code that you expect to fail. This lets you reuse the same callback functions for both normal edge‑case testing and exception checks. The same StepInput object flows through the entire step chain, so the order of withParams() and expectException() doesn’t matter.

callbackFunction.withParams(...).expectException<...>(...)
// works the same as
callbackFunction.expectException<...>(...).withParams(...)

Libraries

api
StoryD presents a State Machine to represent user stories in multiple software development paradigms, and helps to apply Behaviour-Driven Development as an extension to Test-Driven Development.
core/core_api
The 'back end' of the fluent API which handles the system behaviour when BDD tests are executed and test results are rendered.
core/model/narrative
core/model/scenario_data
core/model/step_data
Helper base classes to make it easier to group and distinguish between narrative and executing Story steps
core/model/step_input
core/model/step_result
core/runtime/story_runner
fluent/core/common_steps
fluent/core/scenario_step_registration
fluent/core/story
fluent/core/story_builder
fluent/fluent_api
A library representing the fluent DSL used for defining and executing BDD tests.
fluent/steps/and_scenario_step
fluent/steps/as_a_step
fluent/steps/because_step
fluent/steps/given_scenario_step
fluent/steps/i_can_step
fluent/steps/i_want_step
fluent/steps/in_order_to_step
fluent/steps/scenario_step
fluent/steps/so_that_step
fluent/steps/then_scenario_step
fluent/steps/when_scenario_step
fluent/steps/when_step