moonrockzmoonspec
v0.11.0next
You are reading the docs for main (unreleased). Go to the v0.11.0 docs.
Docs / Step Definitions
On this pageWorld StructBasic World with #derive(Default)Custom Default ImplementationReference Type SemanticsStep RegistrationTyped Step Registration (Recommended)Context-Based Registration (Advanced)Cucumber ExpressionsCtx and StepArgAccessing ArgumentsPattern Matching a Single ArgumentPattern Matching Multiple ArgumentsOther Ctx MethodsCustom Parameter TypesSimple Custom Type (String Matching)Custom Type with RegexPatternCustom Type with TransformerTyped Domain ValuesExpression toolsCreate and match expressionsGenerate expression candidatesStepLibrary TraitDefining a Step LibraryStepDef ConstructorsComposing Libraries in the WorldData TablesFeature FileStep HandlerDataTable APIDoc StringsFeature FileStep HandlerDocString FieldsError Handling in StepsAssertions in Then StepsWhat Happens When a Step FailsFailing ExplicitlyAttribute-Based Step Registration (Experimental)Marking the World StructWriting Step FunctionsMultiple ParametersGenerating the Configure ImplementationStep Function ShapesWhen to Use Each Approach

Step Definitions

Step definitions connect Gherkin steps to MoonBit code. Each step in a .feature file is matched against registered patterns, and the corresponding handler function is executed. The same synchronous handlers and World definitions work with root run_sync / run_or_fail_sync and the optional async facade. See execution and migration for package imports and supported targets.

This guide covers the World struct, step registration (both typed and context-based), Cucumber Expressions, expression tools, context and arguments, custom parameter types, step libraries, data tables, doc strings, error handling, and the experimental attribute-based approach.


World Struct

The World is a struct that holds per-scenario state. moonspec creates a fresh instance for each scenario, so scenarios are isolated from one another.

Basic World with #derive(Default)

#derive(Default)
struct CalcWorld {
  mut result : Int
}

pub extend CalcWorld with Default::{default}

The #derive(Default) annotation auto-generates a constructor that zero-initializes all fields. For Int that means 0, for String it means "", for Array it means [], and so on.

The pub extend line makes CalcWorld::default a regular method, so you can pass CalcWorld::default as the World factory. Without it, current MoonBit versions warn that the method is "implicitly promoted" from the Default impl.

Custom Default Implementation

When you need non-trivial initial state, implement Default manually:

struct AppWorld {
  mut balance : Int
  mut currency : String
}

impl Default for AppWorld with default() {
  { balance: 1000, currency: "USD" }
}

pub extend AppWorld with Default::{default}

Reference Type Semantics

MoonBit structs are reference types. When closures capture self inside configure, all step handlers share the same instance. Mutations made in one step are immediately visible in subsequent steps within the same scenario:

impl @moonspec.World for CalcWorld with configure(self, setup) {
  // Given step mutates self.result
  setup.given1("a starting value of {int}", fn(n : Int) {
    self.result = n
  })
  // Then step reads the mutation from the Given step
  setup.then1("the value should be {int}", fn(expected : Int) {
    assert_eq!(self.result, expected)
  })
}

There is no need for shared pointers or explicit synchronization -- all handlers for a single scenario operate on the same struct instance.


Step Registration

Steps are registered inside the World::configure method, which receives a Setup instance. moonspec offers two approaches: typed step registration (recommended) and context-based registration (for advanced use cases).

Typed methods use an arity suffix to indicate how many parameters the handler takes. Parameters are extracted and converted automatically via the FromStepArg trait -- no manual pattern matching needed.

The naming convention is keyword + arity, e.g. given0, when2, then1. Arities 0--22 are supported for all four keywords.

Zero-Argument Steps

setup.given0("an empty shopping cart", fn() {
  self.cart.clear()
})

Single-Argument Steps

setup.given1("a balance of {int}", fn(n : Int) {
  self.balance = n
})

Multi-Argument Steps

setup.when2("I add {int} and {int}", fn(a : Int, b : Int) {
  self.result = a + b
})

setup.when3("I add {string} with quantity {int} at price {int}", fn(
  name : String, qty : Int, price : Int,
) {
  self.cart.push({ name, quantity: qty, price })
})

Assertions in Then Steps

Handlers can raise errors for assertions. The raise Error is part of the handler signature:

setup.then1("the result should be {int}", fn(expected : Int) {
  assert_eq!(self.result, expected)
})

setup.then0("the cart should be empty", fn() {
  assert_eq!(self.cart.length(), 0)
})

Accessing Ctx with _ctx Variants

When you need the full Ctx (for scenario metadata, step info, or attachments), use the _ctx variant. Every arity from 0 to 22 has one (given0_ctx .. given22_ctx, and the same for when, then and step). Ctx is always the last parameter:

setup.given1_ctx("a user named {string}", fn(name : String, ctx : Ctx) {
  self.user = name
  self.feature = ctx.scenario().feature_name
})

setup.given0_ctx("an empty cart", fn(ctx : Ctx) {
  self.cart.clear()
  println("Starting: " + ctx.scenario().scenario_name)
})

Supported Types

The FromStepArg trait maps StepValue variants to MoonBit types:

Handler TypeStepValue VariantsExpression Types
IntIntVal{int}
DoubleFloatVal, DoubleVal{float}, {double}
Int64LongVal{long}
ByteByteVal{byte}
StringStringVal, WordVal, AnonymousVal{string}, {word}, {}
BigIntBigIntegerVal{biginteger}
@decimal.DecimalBigDecimalVal{bigdecimal}
DataTableDataTableValdata tables
DocStringDocStringValdoc strings

Type mismatches raise a descriptive error at runtime.

Keyword Summary

All four keywords follow the same pattern:

setup.given0("...", fn() { ... })        // Given
setup.when1("...", fn(a : Int) { ... })  // When
setup.then2("...", fn(a : String, b : Int) { ... })  // Then
setup.step0("...", fn() { ... })         // Step (matches any keyword)

Context-Based Registration (Advanced)

The original setup.given(pattern, fn(ctx) { ... }) form gives full access to the Ctx object. Use this when you need manual control over argument extraction or when the typed API doesn't fit your use case:

setup.given("a balance of {int}", fn(ctx) {
  match ctx[0] {
    { value: IntVal(n), .. } => self.balance = n
    _ => ()
  }
})
setup.when("they log in", fn(_ctx) { self.logged_in = true })
setup.then("the result should be {int}", fn(ctx) raise {
  match ctx[0] {
    { value: IntVal(expected), .. } => assert_eq(self.result, expected)
    _ => ()
  }
})
setup.step("the system is ready", fn(_ctx) { () }) // matches any keyword

Context-based handlers have the signature (Ctx) -> Unit raise Error.


Cucumber Expressions

moonspec uses Cucumber Expressions to match step text and extract typed parameters. The following built-in parameter types are supported:

ParameterStepValue VariantExample Pattern
{int}IntVal(Int)"I have {int} items"
{float}FloatVal(Double)"priced at {float}"
{double}DoubleVal(Double)"ratio is {double}"
{long}LongVal(Int64)"id is {long}"
{byte}ByteVal(Byte)"byte {byte}"
{short}ShortVal(Int)"port {short}"
{bigdecimal}BigDecimalVal(@decimal.Decimal)"amount {bigdecimal}"
{biginteger}BigIntegerVal(BigInt)"value {biginteger}"
{string}StringVal(String)"named {string}"
{word}WordVal(String)"as {word}"
customTypedVal(TypedValue)handle-based domain values

The difference between {string} and {word}:

  • {string} matches text enclosed in double quotes (e.g., "Alice")

  • {word} matches a single unquoted word with no spaces (e.g., active)


Ctx and StepArg

When a step handler is called, it receives a Ctx object that provides access to the matched arguments and metadata about the running scenario and step.

Accessing Arguments

Each captured parameter is a StepArg with two fields:

pub struct StepArg {
  value : StepValue   // The typed, pattern-matchable value
  raw : String        // The original text from the feature file
}

Access arguments by index using bracket syntax or the arg method:

let first = ctx[0]     // bracket syntax
let second = ctx.arg(1) // explicit method

Pattern Matching a Single Argument

setup.given("a balance of {int}", fn(ctx) {
  match ctx[0] {
    { value: IntVal(n), .. } => self.balance = n
    _ => ()
  }
})

The .. in the pattern ignores the raw field.

Pattern Matching Multiple Arguments

Use a tuple match for steps with multiple parameters:

setup.when("I transfer {int} to {string}", fn(ctx) {
  match (ctx[0], ctx[1]) {
    (
      { value: IntVal(amount), .. },
      { value: StringVal(recipient), .. },
    ) => {
      self.balance = self.balance - amount
      self.last_transfer_to = recipient
    }
    _ => ()
  }
})

Other Ctx Methods

MethodReturnsDescription
ctx.args()ArrayView[StepArg]View of all matched arguments
ctx.value(i)StepValueShorthand for ctx[i].value
ctx.scenario()ScenarioInfoFeature name, scenario name, and tags
ctx.step()StepInfoCurrent step keyword and text

ScenarioInfo contains:

pub struct ScenarioInfo {
  feature_name : String
  scenario_name : String
  tags : Array[String]
}

StepInfo contains:

pub struct StepInfo {
  keyword : String
  text : String
}

Custom Parameter Types

You can define custom parameter types that match specific patterns in step text.

add_param_type and add_param_type_strings raise ParameterTypeError for duplicate names, invalid names, or an empty pattern list. World::configure can raise errors, so registration failures propagate to the caller of run.

Simple Custom Type (String Matching)

Use add_param_type_strings to register a custom type with string regex patterns. This is the most common approach since it does not require importing the cucumber-expressions package directly:

impl @moonspec.World for MyWorld with configure(self, setup) {
  setup.add_param_type_strings("color", ["red|blue|green"])
  setup.given1("I pick a {color} cucumber", fn(color : String) {
    self.selected_color = color
  })
}

Without a transformer, custom parameter values arrive as StringVal. They work with the built-in FromStepArg implementation for String.

Custom Type with RegexPattern

If you need to use @cucumber_expressions.RegexPattern directly:

setup.add_param_type("color", [
  @cucumber_expressions.RegexPattern("red|blue|green"),
])

Custom Type with Transformer

A transformer converts the matched text into a specific value. This is useful when you want automatic type conversion:

setup.add_param_type_strings(
  "upper",
  ["\\w+"],
  transformer=@cucumber_expressions.Transformer::new(fn(groups) {
    @cucumber_expressions.ParamValue::StringVal(
      groups[0][:].to_upper().to_owned(),
    )
  }),
)

setup.given("I say {upper}", fn(ctx) {
  match ctx[0] {
    { value: StringVal(value), raw } => {
      // value is "HELLO" (transformed); raw is "hello" (original text).
    }
    _ => ()
  }
})

The transformer receives its parameter's capture groups, or the whole match when the pattern has no capture groups, and must return a @cucumber_expressions.ParamValue, such as StringVal or IntVal. Use typed registration for custom domain values. Transformer errors report as failed steps, including in dry-run mode. Direct calls to StepRegistry::find_match can also raise these errors.

StepValue::NullVal preserves an unmatched optional capture from a parameter returned by cucumber-expressions. Typed handlers reject it when a concrete value is required.

Typed Domain Values

define_param_type1 wraps a single-capture transformer and returns a @moonspec.ParameterType[T] handle. Keep the handle in your step closure and use ctx[index].get(handle) to retrieve the value without a cast:

enum Color {
  Red
  Blue
}

impl @moonspec.World for MyWorld with fn configure(self, setup) {
  let color = setup.define_param_type1("color", ["red|blue"], fn(text) {
    match text {
      "red" => Color::Red
      _ => Color::Blue
    }
  })
  setup.given("I pick a {color} cucumber", fn(ctx) raise {
    self.selected_color = ctx[0].get(color)
  })
}

A handle accepts only values produced by its own registration. Another handle with the same name still raises WrongParameterType. Extracting a built-in value with a custom handle raises Failure. TypedVal equality uses value identity: separate matches are distinct even when their decoded values are structurally equal. Extract the domain value to compare it structurally.

Use define_param_type_with for multiple or optional captures. The upstream Captures decoders compose with zip and map:

struct Shade {
  color : String
  tone : String
}

let shade = setup.define_param_type_with(
  "shade",
  ["(red|blue)-(light|dark)"],
  @moonspec.Captures::string().zip(
    @moonspec.Captures::string(),
  ).map(fn(color, tone) { Shade::{ color, tone } }),
)
setup.given("I pick {shade}", fn(ctx) raise {
  let selected : Shade = ctx[0].get(shade)
  // ctx[0].raw still contains the original matched text.
})

Both typed helpers raise ParameterTypeError for invalid registration, including capture arity mismatches, and accept use_for_snippets and prefer_for_regexp_match options. Decoder errors propagate through matching and are reported by the runner as failed steps. For trait-based registration or fixed-arity helpers with two through eight captures, use setup.param_registry().define_type1() or .define2() through .define8().


Expression tools

moonspec exposes the cucumber-expressions tools through its main import. Each Setup supplies a factory and generator backed by its own parameter registry, so they see the custom types you register, including typed handles.

CapabilityPublic APIUse
Typed domain valuesdefine_param_type1, define_param_type_with, StepArg::getDecode custom parameters and retrieve them with their typed handles
Expression creationsetup.expression_factory()Create Cucumber or regex expression objects for validation and matching
Candidate generationsetup.expression_generator()Suggest Cucumber Expressions from concrete step text

ParameterType, Captures, Captures1, ExpressionFactory, CucumberExpressionGenerator, StepExpression, GeneratedExpression, and Match are available under @moonspec. The error types ExpressionError, ParameterTypeError, and AmbiguousParameterTypeError are also re-exported. These are the upstream types, so handles and results remain compatible with code that imports cucumber-expressions directly.

Create and match expressions

The factory selects the expression kind from the pattern:

  • Patterns starting with ^ or ending with $ become regular expressions.

  • Patterns wrapped in /.../ become regular expressions with the slashes removed.

  • Other patterns become Cucumber Expressions.

let setup = @moonspec.Setup::new()
let color : @moonspec.ParameterType[String] = setup.define_param_type1(
  "color",
  ["red|blue"],
  fn(text) { text },
  prefer_for_regexp_match=true,
)
let factory = setup.expression_factory()
let expression = factory.create_expression("I pick {color}")
match expression.match_("I pick red") {
  Some(matched) => assert_eq(matched.get(color), "red")
  None => fail("expected a match")
}

let regex = factory.create_expression("^(red|blue)$")
match regex.match_("red") {
  Some(matched) => assert_eq(matched.get(color), "red")
  None => fail("expected a regex match")
}

For regex matches, custom types are selected by their capture patterns; prefer_for_regexp_match helps resolve types sharing a pattern. Invalid expressions raise ExpressionError. Matching can raise conversion or ambiguity errors, and a handle rejects a value from another registration.

The factory creates objects for explicit matching and tooling. Register scenario handlers with setup.given, when, then, or step, which currently accept Cucumber Expression patterns. Creating a factory expression does not register a handler or add regex support to those registration methods.

Generate expression candidates

The generator recognizes parameter types marked use_for_snippets=true, escapes literal Cucumber Expression syntax, and returns the best candidate first. Each candidate exposes source(), parameter_names(), and parameter_infos() for tools that build step definitions.

let setup = @moonspec.Setup::new()
let color = setup.define_param_type1("color", ["red|blue"], fn(text) { text })
let candidates = setup.expression_generator().generate_expressions("I pick red")
// candidates[0].source() is "I pick {color}".
// candidates[0].parameter_names() is ["color"].
setup.given(candidates[0].source(), fn(ctx) raise {
  assert_eq(ctx[0].get(color), "red")
})

Set use_for_snippets=false on a typed registration to keep that type out of these candidates. source() returns Cucumber Expression text; tools emitting MoonBit code must also escape it as a MoonBit string literal.

This generator is available for your own tools and workflows. The runner's automatic undefined-step snippets currently use the built-in integer, decimal, and quoted-string inference.

StepLibrary Trait

For larger projects, you can organize step definitions into composable groups using the StepLibrary trait. This keeps step definitions modular and reusable.

Defining a Step Library

struct CartSteps {
  world : EcomWorld
}

fn CartSteps::new(world : EcomWorld) -> CartSteps {
  { world, }
}

impl @moonspec.StepLibrary for CartSteps with steps(self) {
  let defs : Array[@moonspec.StepDef] = [
    @moonspec.StepDef::given0("an empty shopping cart", fn() {
      self.world.cart.clear()
    }),
    @moonspec.StepDef::when3(
      "I add {string} with quantity {int} at price {int}",
      fn(name : String, qty : Int, price : Int) {
        self.world.cart.push({ name, quantity: qty, price })
      },
    ),
    @moonspec.StepDef::then0("the cart should be empty", fn() {
      assert_eq!(self.world.cart.length(), 0)
    }),
  ]
  defs[:]
}

StepDef Constructors

StepDef provides typed arity-suffixed constructors that mirror the Setup methods, plus the original context-based constructors:

Typed (recommended):

  • StepDef::given0(pattern, handler) through StepDef::given22(pattern, handler)

  • Same for when, then, step

  • _ctx variants: StepDef::given0_ctx(pattern, handler) etc.

Context-based (advanced):

  • StepDef::given(pattern, handler) -- Given keyword

  • StepDef::when(pattern, handler) -- When keyword

  • StepDef::then(pattern, handler) -- Then keyword

  • StepDef::step(pattern, handler) -- Any keyword

All constructors accept an optional source? : StepSource parameter for source location tracking.

Each returns a StepDef value. The steps method must return an ArrayView[StepDef], so end with defs[:] to create a view from the array.

Composing Libraries in the World

#derive(Default)
struct EcomWorld {
  cart : Array[CartItem]
  inventory : Map[String, Int]
  mut order_total : Int
}

pub extend EcomWorld with Default::{default}

impl @moonspec.World for EcomWorld with configure(self, setup) {
  setup.use_library(CartSteps::new(self))
  setup.use_library(InventorySteps::new(self))
  setup.use_library(CheckoutSteps::new(self))
}

Each library struct holds a reference to the world, so all libraries share the same state through MoonBit's reference semantics.


Data Tables

Gherkin data tables are passed to step handlers as the last argument with type DataTableVal(DataTable).

Feature File

Scenario: Users
  Given the following users
    | name  | age |
    | Alice | 30  |
    | Bob   | 25  |
  Then there should be 2 users

Step Handler

Using the typed API, declare the parameter as DataTable:

setup.given1("the following users", fn(table : DataTable) {
  self.users = table.as_maps()
})

Or with the context-based API:

setup.given("the following users", fn(ctx) {
  match ctx[0] {
    { value: DataTableVal(table), .. } =>
      self.users = table.as_maps()
    _ => ()
  }
})

DataTable API

MethodReturnsDescription
rows()RowsAll rows including the header row
columns()ColumnsColumn metadata derived from the header row
as_maps()Array[Map[String, String]]Data rows (excluding header) as column-keyed maps
row_count()IntTotal number of rows (including header)
col_count()IntNumber of columns

Row access:

let first_data_row = table.rows().get(1)        // index 0 is the header
let cell_value = first_data_row.cells.get(0)     // first cell as String

Column access:

let cols = table.columns()
let name_col = cols.find("name")  // returns Column?
match name_col {
  Some(col) => {
    let idx = col.index  // column index for cell lookup
  }
  None => ()
}

Map-based access (most common):

let maps = table.as_maps()
// maps = [{"name": "Alice", "age": "30"}, {"name": "Bob", "age": "25"}]
for row in maps {
  let name = row["name"]
  let age = row["age"]
}

Note that as_maps() skips the header row and returns only data rows. All cell values are strings.


Doc Strings

Gherkin doc strings are passed to step handlers as the last argument with type DocStringVal(DocString).

Feature File

Scenario: JSON payload
  Given a JSON payload
    """json
    {"key": "value"}
    """
  Then the payload should contain "key"
  Then the media type should be "json"

Step Handler

Using the typed API, declare the parameter as DocString:

setup.given1("a JSON payload", fn(doc : DocString) {
  self.payload = doc.content        // The text content
  self.media = doc.media_type       // Optional media type (String?)
})

Or with the context-based API:

setup.given("a JSON payload", fn(ctx) {
  match ctx[0] {
    { value: DocStringVal(doc), .. } => {
      self.payload = doc.content
      self.media = doc.media_type
    }
    _ => ()
  }
})

DocString Fields

FieldTypeDescription
contentStringThe text between the triple-quote delimiters
media_typeString?The optional media type annotation (e.g., json)

Error Handling in Steps

Assertions in Then Steps

Then-step handlers typically use assertions that can raise errors:

setup.then1("the balance should be {int}", fn(expected : Int) {
  assert_eq!(self.balance, expected)
})

MoonBit provides several built-in assertion functions:

  • assert_eq!(actual, expected) -- asserts equality

  • assert_true!(condition) -- asserts a boolean is true

  • fail!("message") -- unconditionally fails with a message

What Happens When a Step Fails

When a step handler raises an error:

  1. The current step is marked as failed.

  2. All remaining steps in the scenario are marked as skipped.

  3. The scenario is marked as failed.

  4. After-hooks still run (receiving the failure information via HookResult).

  5. Execution continues with the next scenario.

Failing Explicitly

Use fail!() to signal a failure with a descriptive message:

setup.then0("the item should be in stock", fn() {
  match self.inventory.get(self.last_checked_item) {
    Some(stock) => assert_true!(stock > 0)
    None => fail!("Item not found in inventory")
  }
})

Attribute-Based Step Registration (Experimental)

moonspec provides an alternative, attribute-based approach to step registration. Instead of writing closures inside configure, you annotate standalone functions with #moonspec.given, #moonspec.when, #moonspec.then and #moonspec.step attributes. moonspec gen steps then generates the configure implementation.

Marking the World Struct

Annotate your world struct with #moonspec.world:

#moonspec.world
#derive(Default)
struct TodoWorld {
  todos : Array[TodoItem]
}

pub extend TodoWorld with Default::{default}

Writing Step Functions

Each step function takes the world struct as its first parameter, followed by typed parameters extracted from the Cucumber Expression. The parameter types are inferred from the function signature:

#moonspec.given("a todo list")
fn given_todo_list(world : TodoWorld) -> Unit {
  world.todos.clear()
}

#moonspec.when("I add a todo {string}")
fn when_add_todo(world : TodoWorld, title : String) -> Unit {
  world.todos.push(TodoItem::{ title, completed: false })
}

#moonspec.then("I should have {int} todos")
fn then_todo_count(world : TodoWorld, count : Int) -> Unit raise Error {
  assert_eq(world.todos.length(), count)
}

Note the differences from the closure-based approach:

  • Parameters are typed MoonBit values (e.g., String, Int) instead of StepArg objects. The code generator handles the pattern matching.

  • The world struct is the first explicit parameter instead of a captured self.

  • Functions that need assertions must declare raise Error in their signature.

Multiple Parameters

Functions can accept multiple extracted parameters:

#moonspec.given("I have {int} completed and {int} pending todos")
fn given_mixed_todos(world : TodoWorld, completed : Int, pending : Int) -> Unit {
  world.todos.clear()
  for i = 0; i < completed; i = i + 1 {
    world.todos.push(TodoItem::{ title: "completed-" + i.to_string(), completed: true })
  }
  for i = 0; i < pending; i = i + 1 {
    world.todos.push(TodoItem::{ title: "pending-" + i.to_string(), completed: false })
  }
}

Generating the Configure Implementation

Run the code generator to produce the World::configure implementation:

moonspec gen steps
# or: moonx moonrockz/moonspec/cmd/moonspec@latest gen steps

It scans the .mbt files in src (-d sets another directory; test files are skipped) for #moonspec.world and step attributes. For each world type it writes one file, by default next to the step functions as <world>_steps_gen.mbt (here src/todoworld_steps_gen.mbt; steps.output in moonspec.json5 selects another place). The file holds one impl with one registration per annotated function:

// Generated by moonspec gen steps — DO NOT EDIT
// moonspec:hash:2311918516

#warnings("-unused_value")
impl @moonspec.World for TodoWorld with configure(self, setup) {
  setup.given0("a todo list", fn() {
    given_todo_list(self)
  })
  setup.when1("I add a todo {string}", fn(_arg0 : String) {
    when_add_todo(self, _arg0)
  })
  setup.then1("I should have {int} todos", fn(_arg0 : Int) raise {
    then_todo_count(self, _arg0)
  })
  setup.given("these todos", fn(ctx) raise {
    given_these_todos(self, ctx)
  })
  setup.when1_ctx("I add a todo {string} for this scenario", fn(_arg0 : String, ctx : @moonspec.Ctx) {
    when_add_scenario_todo(self, _arg0, ctx)
  })
}
  • Each registration calls your function with the world (self) and the decoded arguments. You do not edit the generated file.

  • A lambda gets raise when your function raises.

  • #warnings("-unused_value") is there because only tests use the impl.

  • The hash records the scanned step functions.

  • Do not also write a configure impl by hand for the same world type; gen steps warns when it finds one.

Run gen steps again after you add, remove or change a step function, or add it as a build rule so that moon runs it for you:

rule(name: "moonspec-steps", command: "moonspec gen steps -d src")

dev_build(
  rule: "moonspec-steps",
  input: "steps.mbt",
  output: "todoworld_steps_gen.mbt",
)

Step Function Shapes

The first parameter is always the world (any name, or self in a method). The parameters after it select how the step is registered:

Parameters after the worldGenerated registration
Nonesetup.given0("…", fn() { f(self) })
Typed values, up to 22setup.given2("…", fn(_arg0 : A, _arg1 : B) { f(self, _arg0, _arg1) })
Typed values (up to 22), then ctx : @moonspec.Ctxsetup.given1_ctx("…", fn(_arg0 : A, ctx : @moonspec.Ctx) { f(self, _arg0, ctx) })
Only ctx : @moonspec.Ctxsetup.given("…", fn(ctx) { f(self, ctx) }) -- a handler
Typed values (and an optional Ctx) with handler=true, or more than 22 typed valuessetup.given("…", fn(ctx) raise { let _arg0 : A = @moonspec.FromStepArg::from_step_arg(ctx[0]) ... })

The keyword (given, when, then or step) comes from the attribute.

Typed values are decoded with the FromStepArg trait, like the typed setup.given1 API: Int, Double, Int64, String, @moonspec.DataTable, @moonspec.DocString and the other types in Supported Types, or your own type with a FromStepArg impl. The typed forms check that the step text has exactly that many arguments.

Ctx must be the last parameter. Use it for scenario and step metadata (ctx.scenario(), ctx.step()) and for attachments.

A handler is registered with the plain setup.given form, so no arity is checked. A step function that takes only Ctx is always a handler: it reads its arguments itself, for any number of arguments in the step. Use it when a step needs a StepArg directly:

#moonspec.given("these todos")
fn given_these_todos(world : TodoWorld, ctx : @moonspec.Ctx) -> Unit raise Error {
  let table = match ctx[0] {
    { value: DataTableVal(table), .. } => table
    _ => fail("expected a data table")
  }
  world.todos.clear()
  for row in table.as_maps() {
    world.todos.push(TodoItem::{
      title: row.get_or_default("title", ""),
      completed: row.get_or_default("done", "no") == "yes",
    })
  }
}

#moonspec.when("I add a todo {string} for this scenario")
fn when_add_scenario_todo(
  world : TodoWorld,
  title : String,
  ctx : @moonspec.Ctx,
) -> Unit {
  world.todos.push(TodoItem::{
    title: title + " (" + ctx.scenario().scenario_name + ")",
    completed: false,
  })
}

handler=true gives the same plain handler form to a step function with typed parameters. The generated handler decodes each typed parameter with FromStepArg and does not check the arity. A parameter of option type T? is None when the step has no argument at its position, so a data table or doc string can be optional:

#moonspec.given("a todo list named {string}", handler=true)
fn given_named_list(
  world : TodoWorld,
  name : String,
  todos : @moonspec.DataTable?,
) -> Unit {
  world.todos.clear()
  guard todos is Some(table) else { return }
  for row in table.as_maps() {
    world.todos.push(TodoItem::{
      title: name + ": " + row.get_or_default("title", ""),
      completed: false,
    })
  }
}

generates:

setup.given("a todo list named {string}", fn(ctx) raise {
  let _arg0 : String = @moonspec.FromStepArg::from_step_arg(ctx[0])
  let _arg1 : @moonspec.DataTable? = if ctx.args().length() > 1 {
    Some(@moonspec.FromStepArg::from_step_arg(ctx[1]))
  } else {
    None
  }
  given_named_list(self, _arg0, _arg1)
})

Option parameters work only in this handler form; the typed given1 .. given22 methods need a FromStepArg type for every parameter.

When to Use Each Approach

Typed step registration (setup.given1, StepDef::when2, etc.) is the recommended default. It handles argument extraction automatically and produces clean, readable handlers.

Context-based registration (setup.given("...", fn(ctx) { ... })) is useful when you need manual control over argument extraction or want to access Ctx without using the _ctx variants.

Attribute-based registration (#moonspec.given, etc.) works well when you prefer standalone functions over closures and a flatter file structure. The code generator handles all the wiring.

For a complete working example, see the todolist example.

Edit this page on GitHub ↗