On this page
World 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 ApproachStep 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.
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)
struct CalcWorld {
mut result : Int
} derive(Default)
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 Step Registration (Recommended)
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 Type | StepValue Variants | Expression Types |
|---|---|---|
Int | IntVal | {int} |
Double | FloatVal, DoubleVal | {float}, {double} |
Int64 | LongVal | {long} |
Byte | ByteVal | {byte} |
String | StringVal, WordVal, AnonymousVal | {string}, {word}, {} |
BigInt | BigIntegerVal | {biginteger} |
@decimal.Decimal | BigDecimalVal | {bigdecimal} |
DataTable | DataTableVal | data tables |
DocString | DocStringVal | doc 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 keywordContext-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:
| Parameter | StepValue Variant | Example 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}" |
| custom | TypedVal(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 methodPattern 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
| Method | Returns | Description |
|---|---|---|
ctx.args() | ArrayView[StepArg] | View of all matched arguments |
ctx.value(i) | StepValue | Shorthand for ctx[i].value |
ctx.scenario() | ScenarioInfo | Feature name, scenario name, and tags |
ctx.step() | StepInfo | Current 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.
| Capability | Public API | Use |
|---|---|---|
| Typed domain values | define_param_type1, define_param_type_with, StepArg::get | Decode custom parameters and retrieve them with their typed handles |
| Expression creation | setup.expression_factory() | Create Cucumber or regex expression objects for validation and matching |
| Candidate generation | setup.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)throughStepDef::given22(pattern, handler)Same for
when,then,step_ctxvariants:StepDef::given0_ctx(pattern, handler)etc.
Context-based (advanced):
StepDef::given(pattern, handler)-- Given keywordStepDef::when(pattern, handler)-- When keywordStepDef::then(pattern, handler)-- Then keywordStepDef::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
struct EcomWorld {
cart : Array[CartItem]
inventory : Map[String, Int]
mut order_total : Int
} derive(Default)
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 usersStep 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
| Method | Returns | Description |
|---|---|---|
rows() | Rows | All rows including the header row |
columns() | Columns | Column metadata derived from the header row |
as_maps() | Array[Map[String, String]] | Data rows (excluding header) as column-keyed maps |
row_count() | Int | Total number of rows (including header) |
col_count() | Int | Number 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 StringColumn 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
| Field | Type | Description |
|---|---|---|
content | String | The text between the triple-quote delimiters |
media_type | String? | 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 equalityassert_true!(condition)-- asserts a boolean is truefail!("message")-- unconditionally fails with a message
What Happens When a Step Fails
When a step handler raises an error:
The current step is marked as failed.
All remaining steps in the scenario are marked as skipped.
The scenario is marked as failed.
After-hooks still run (receiving the failure information via
HookResult).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
struct TodoWorld {
todos : Array[TodoItem]
} derive(Default)
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 ofStepArgobjects. 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 Errorin 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 stepsIt 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
raisewhen 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
configureimpl by hand for the same world type;gen stepswarns 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 world | Generated registration |
|---|---|
| None | setup.given0("…", fn() { f(self) }) |
| Typed values, up to 22 | setup.given2("…", fn(_arg0 : A, _arg1 : B) { f(self, _arg0, _arg1) }) |
Typed values (up to 22), then ctx : @moonspec.Ctx | setup.given1_ctx("…", fn(_arg0 : A, ctx : @moonspec.Ctx) { f(self, _arg0, ctx) }) |
Only ctx : @moonspec.Ctx | setup.given("…", fn(ctx) { f(self, ctx) }) -- a handler |
Typed values (and an optional Ctx) with handler=true, or more than 22 typed values | setup.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.