On this page
PrerequisitesStep 1: Install moonspecStep 2: Write a Feature FileStep 3: Create a World StructStep 4: Implement the World TraitStep 5: Understanding StepArg and StepValueStep 6: Write the TestOption A: Inline Feature TextOption B: Feature File on DiskStep 7: Run the TestCodegen ModeGet the CLIImports for Generated CodeGenerating Step Definitions with AnnotationsBuild RulesGenerated Test FilesRunning Scenarios with moon testConfiguration with moonspec.json5Where to Put FilesState Isolation with derive(Default)Next StepsGetting Started with moonspec
moonspec is a BDD (Behavior-Driven Development) test framework for MoonBit. It brings Gherkin feature files and Cucumber Expressions to MoonBit, letting you describe application behavior in plain English and verify it with executable step definitions.
This guide walks you through writing your first moonspec test from scratch.
Prerequisites
MoonBit toolchain installed (
moonCLI available)An existing MoonBit project (create one with
moon new my-projectif needed)Node.js runtime (moonspec tests require the
--target jsflag)
Step 1: Install moonspec
Add moonspec as a dependency to your project:
moon add moonrockz/moonspecYou will also need the moonbitlang/async package (required for async tests):
moon add moonbitlang/asyncThen add moonrockz/moonspec to the import list in the moon.pkg file for the package where your tests will live:
import {
"moonrockz/moonspec",
}Step 2: Write a Feature File
Feature files describe the behavior you want to test using Gherkin syntax. Create a features/ directory in your project and add a file called calculator.feature:
Feature: Calculator
Background:
Given a calculator
Scenario: Addition
When I add 5 and 3
Then the result should be 8
Scenario: Subtraction
When I subtract 3 from 10
Then the result should be 7Each Feature groups related scenarios. A Background runs before every scenario. Each Scenario describes a concrete example with Given (setup), When (action), and Then (assertion) steps.
Step 3: Create a World Struct
The World struct holds per-scenario state. moonspec creates a fresh instance for each scenario, so every scenario starts with a clean slate. Define it with derive(Default):
struct CalcWorld {
mut result : Int
} derive(Default)
pub extend CalcWorld with Default::{default}derive(Default) is required -- moonspec calls CalcWorld::default() to create a new instance before each scenario runs. The pub extend line makes CalcWorld::default a regular method, so you can pass it as the World factory. Without it, current MoonBit versions warn that the method is "implicitly promoted" from the Default impl. Because MoonBit structs are reference types, mutations made by step closures are visible to all subsequent steps within the same scenario.
Step 4: Implement the World Trait
The @moonspec.World trait has a single method, configure, where you register your step definitions. Each step maps a Cucumber Expression pattern to a handler function:
impl @moonspec.World for CalcWorld with configure(self, setup) {
// Given steps set up initial state
setup.given("a calculator", fn(_ctx) { self.result = 0 })
// When steps perform actions
setup.when("I add {int} and {int}", fn(ctx) {
match (ctx[0], ctx[1]) {
(
{ value: @moonspec.StepValue::IntVal(a), .. },
{ value: @moonspec.StepValue::IntVal(b), .. },
) => self.result = a + b
_ => ()
}
})
setup.when("I subtract {int} from {int}", fn(ctx) {
match (ctx[0], ctx[1]) {
(
{ value: @moonspec.StepValue::IntVal(a), .. },
{ value: @moonspec.StepValue::IntVal(b), .. },
) => self.result = b - a
_ => ()
}
})
// Then steps verify results (note `raise` for assertions)
setup.then("the result should be {int}", fn(ctx) raise {
match ctx[0] {
{ value: @moonspec.StepValue::IntVal(expected), .. } =>
assert_eq(self.result, expected)
_ => ()
}
})
}Key things to notice:
The
selfparameter is the World instance. Closures capture it, sharing state between steps.setup.given(...),setup.when(...), andsetup.then(...)register step handlers.{int},{string},{float}, and{word}are built-in Cucumber Expression parameter types.Step handlers that perform assertions use
fn(ctx) raise { ... }so they can raise errors.
Step 5: Understanding StepArg and StepValue
When a step pattern contains parameters like {int} or {string}, moonspec extracts them and passes them to your handler through the Ctx object. Access arguments by index:
ctx[0] // First captured parameter (a StepArg)
ctx[1] // Second captured parameterEach StepArg is a struct with two fields:
value-- aStepValueenum variant with the typed valueraw-- the original matched text as aString
Use pattern matching to destructure the value:
// Single argument
match ctx[0] {
{ value: @moonspec.StepValue::IntVal(n), .. } => {
// n is an Int
}
_ => ()
}
// String argument
match ctx[0] {
{ value: @moonspec.StepValue::StringVal(s), .. } => {
// s is a String (matched with quotes, e.g., "hello")
}
_ => ()
}
// Multiple arguments at once
match (ctx[0], ctx[1]) {
(
{ value: @moonspec.StepValue::IntVal(a), .. },
{ value: @moonspec.StepValue::IntVal(b), .. },
) => {
// a and b are both Int
}
_ => ()
}The .. in the pattern match ignores the raw field. The common StepValue variants are:
| Cucumber Expression | StepValue variant | MoonBit type |
|---|---|---|
{int} | IntVal(n) | Int |
{float} | FloatVal(f) | Double |
{string} | StringVal(s) | String |
{word} | WordVal(s) | String |
Step 6: Write the Test
Now create a test file (the filename must end in _test.mbt or _wbtest.mbt). There are two ways to provide feature content.
Option A: Inline Feature Text
Use multi-line string literals to embed the feature directly in your test file:
async test "calculator" {
let feature =
#|Feature: Calculator
#|
#| Background:
#| Given a calculator
#|
#| Scenario: Addition
#| When I add 5 and 3
#| Then the result should be 8
#|
#| Scenario: Subtraction
#| When I subtract 3 from 10
#| Then the result should be 7
@moonspec.run_or_fail(
CalcWorld::default,
@moonspec.RunOptions::new(
[@moonspec.FeatureSource::Text("test://calculator", feature)],
),
)
|> ignore
}The FeatureSource::Text(uri, content) variant takes:
A URI string (can be any identifier, used in error messages)
The feature content as a string
Option B: Feature File on Disk
Point to the .feature file you created earlier:
async test "calculator" {
@moonspec.run_or_fail(
CalcWorld::default,
@moonspec.RunOptions::new(
[@moonspec.FeatureSource::File("features/calculator.feature")],
),
)
|> ignore
}The path is relative to the package directory at runtime.
In both cases:
Tests must be
async testblocks.run_or_failruns the feature and raises a structured error if any scenario fails, is undefined, or is pending.CalcWorld::defaultis the factory function -- moonspec calls it to create a fresh World for each scenario.
Step 7: Run the Test
moonspec tests must be run with the JavaScript target:
moon test --target jsIf everything is wired up correctly, you will see output like:
Total tests: 1, passed: 1, failed: 0.If a step is undefined (no matching step definition), moonspec will raise an error with a helpful message showing the unmatched step text and a suggested code snippet.
If an assertion fails, you will see a detailed error showing which scenario and step failed, along with the assertion message:
[moon/test] test ... ("calculator") failed: 1 failed, 2 total
1) Feature: Calculator / Scenario: Subtraction # features/calculator.feature:9
✔ Given a calculator
✔ When I subtract 3 from 10
✖ Then the result should be 7
Step 'the result should be 7' (Then): ...The report lists every step of each failed scenario, in the style of other cucumber implementations: ✔ passed (green), ✖ failed (red), ? undefined or pending (yellow), - skipped (cyan). Feature files that do not parse are listed too, with their line and the parser message. See Colors to turn colors off.
Codegen Mode
For larger projects, manually writing async test blocks and configure methods for every scenario gets tedious. The moonspec CLI generates this code from your feature files and step annotations.
Get the CLI
You can use the CLI in two ways. Both run the same commands.
Installed CLI. Install it once; moon install puts moonspec in ~/.moon/bin:
moon install moonrockz/moonspec/cmd/moonspec
moonspec versionmoonx. Run it from the registry without an install. moonx comes with the MoonBit toolchain:
moonx moonrockz/moonspec/cmd/moonspec@latest versionThe commands below use moonspec. With moonx, replace moonspec with moonx moonrockz/moonspec/cmd/moonspec@latest.
Imports for Generated Code
Generated tests use @format.PrettyFormatter and async test. In the moon.pkg of the package that gets the tests, add these test imports next to moonrockz/moonspec:
import {
"moonrockz/moonspec",
}
import {
"moonrockz/moonspec/format",
"moonbitlang/async",
} for "wbtest"Generating Step Definitions with Annotations
Instead of writing the configure method by hand, annotate standalone step functions with #moonspec.* attributes:
#moonspec.world
struct CalcWorld {
mut result : Int
} derive(Default)
pub extend CalcWorld with Default::{default}
///|
#moonspec.given("a calculator")
fn given_calculator(world : CalcWorld) -> Unit {
world.result = 0
}
///|
#moonspec.when("I add {int} and {int}")
fn when_add(world : CalcWorld, a : Int, b : Int) -> Unit {
world.result = a + b
}
///|
#moonspec.then("the result should be {int}")
fn then_result(world : CalcWorld, expected : Int) -> Unit raise Error {
assert_eq(world.result, expected)
}Then run:
moonspec gen stepsThis scans src (-d sets another directory) for #moonspec.* attributes and writes src/calcworld_steps_gen.mbt, which implements configure for CalcWorld:
// Generated by moonspec gen steps — DO NOT EDIT
#warnings("-unused_value")
impl @moonspec.World for CalcWorld with configure(self, setup) {
setup.given0("a calculator", fn() {
given_calculator(self)
})
setup.when2("I add {int} and {int}", fn(_arg0 : Int, _arg1 : Int) {
when_add(self, _arg0, _arg1)
})
setup.then1("the result should be {int}", fn(_arg0 : Int) raise {
then_result(self, _arg0)
})
}Run the command again when you add or change a step function. Step functions can also take ctx : @moonspec.Ctx as the last parameter, or only Ctx to read their own arguments; see Step Function Shapes.
Build Rules
Add rule and dev_build entries to moon.pkg. moon runs them before moon check, moon build and moon test, so the test files stay in step with the feature files. To write the entries for you, run:
moonspec gen build features/calculator.feature -w CalcWorld --applyIt adds only the missing entries to src/moon.pkg (use -p for another package) and generates test files that do not exist yet:
rule(
name: "moonspec-tests",
command: "moonspec gen tests $input -w CalcWorld -o $output",
)
dev_build(
rule: "moonspec-tests",
input: "../features/calculator.feature",
output: "calculator_feature_wbtest.mbt",
)With moonx, add --moonx. The rule then runs a pinned CLI version, so nobody who builds the project needs an installed CLI:
moonx moonrockz/moonspec/cmd/moonspec@latest gen build \
features/calculator.feature -w CalcWorld --apply --moonxrule(
name: "moonspec-tests",
command: "moonx moonrockz/moonspec/cmd/moonspec@<version> gen tests $input -w CalcWorld -o $output",
)Add one dev_build entry per feature file. To share one rule between packages, declare it in moon.mod. Commit the generated files: moon does not run dev_build steps when the package is used as a dependency. The older options("pre-build": [...]) form is deprecated.
Generated Test Files
The build rule runs moonspec gen tests, which generates one async test block per scenario. src/calculator_feature_wbtest.mbt looks like this:
// Generated by moonspec codegen — DO NOT EDIT
// Source: features/calculator.feature
// moonspec:hash:6a7ef305
async test "Feature: Calculator / Scenario: Addition" {
let options = @moonspec.RunOptions::new([@moonspec.FeatureSource::File("features/calculator.feature")])
options.scenario_name("Addition")
options.add_formatter(@format.PrettyFormatter::new(), @moonspec.OutputDest::Stdout)
@moonspec.run_or_fail(
CalcWorld::default, options,
)
}Each scenario becomes its own test, so moon test reports pass/fail at the scenario level.
You can also run gen tests by hand, without build rules:
moonspec gen tests features/calculator.feature -w CalcWorld -o srcIt writes white-box tests (*_wbtest.mbt) by default, so they can use the private World impl that gen steps generates. --test-kind test (or test_kind: "test" in moonspec.json5) writes black-box *_test.mbt files, which see only the public API.
Running Scenarios with moon test
Because each scenario is a normal moon test test, the native moon test options work on scenarios:
# Run the scenarios whose test name matches a glob pattern
moon test --target js -f "Feature: Calculator / Scenario: Add*"
# List the scenarios that will run, without running them
moon test --target js --outline
# Run one scenario by its 0-based index in a generated file (see --outline)
moon test --target js src/calculator_feature_wbtest.mbt -i 1
# Also run scenarios that have a skip tag (@skip, @ignore, ...)
moon test --target js --include-skippedEach row of a Scenario Outline is its own test, named Feature: <feature> / Scenario: <outline> (<header>=<value>, ...).
Scenarios with a skip tag get a #skip("reason") attribute, so moon test reports them as skipped. In per-feature mode the whole feature is one test, so these options work only at the feature level.
Configuration with moonspec.json5
You can create a moonspec.json5 file in your project root to avoid repeating CLI flags:
{
"world": "CalcWorld",
"mode": "per-scenario"
}Options:
world-- the World type name (required forgen testsandgen build)mode--"per-scenario"(default) generates one test per scenario;"per-feature"generates one test per feature filesteps.output--"alongside"(default) writes<world>_steps_gen.mbtnext to the step functions; see the configuration reference for the other strategies
Where to Put Files
A typical project layout looks like this:
my-project/
moon.mod
moonspec.json5 # optional: codegen configuration
features/
calculator.feature # Gherkin feature files
src/
moon.pkg # imports moonrockz/moonspec
world.mbt # World struct + configure implementation
calculator_wbtest.mbt # async test blocks (manual or generated)Feature files live in features/ by convention. Step definitions and World structs live in your source directory alongside the test files.
State Isolation with derive(Default)
Every scenario gets a completely fresh World instance created by calling the default() constructor. This means:
Scenarios are fully isolated from each other -- no shared mutable state leaks between them.
You can safely use
mutfields in your World struct without worrying about test ordering.Background steps run on the fresh instance before each scenario's own steps.
struct CalcWorld {
mut result : Int // Reset to 0 (default) for every scenario
} derive(Default)
pub extend CalcWorld with Default::{default}This design follows the same principle as Cucumber's World pattern: each scenario is an independent test that sets up, acts, and asserts without depending on any other scenario's side effects.
Next Steps
Now that you have a working moonspec test, here are some things to explore:
Scenario Outlines -- parameterize scenarios with example tables
Data Tables -- pass tabular data to steps using
DataTableValDoc Strings -- pass multi-line text to steps using
DocStringValTags -- annotate scenarios with
@tagnameand filter withoptions.tag_expr("@tagname")Hooks -- run setup/teardown code with
setup.before_test_case(...)andsetup.after_test_case(...)Custom Parameter Types -- register your own types with
setup.add_param_type_strings(...)Parallel Execution -- run scenarios concurrently with
options.parallel(true)
For a complete working example, see the examples/calculator/ directory in the moonspec repository.