Step sets and the .saffron dialect
The two pains this solves
If you've run a Cucumber suite for a few years, you know both of these:
The same step exists under two wordings. One author writes "I navigate to the home page", another writes "I visit the home page". Same behavior, two entries in the suite's vocabulary. In Saffron this is worse than cosmetic: exact step text is the cache identity, so the second wording is a cache miss, the agent re-records it at real token cost. Wording drift has a price tag.
The same group of steps is pasted into every scenario. Every scenario that tests the checkout page first has to get through the guest information page: the same five or six steps, copied into every scenario in the file. Gherkin's
Background:only covers steps at the very start of every scenario in one file; it can't help mid-scenario.
Saffron attacks the second pain with step sets, and the first with a step vocabulary that is surfaced everywhere you write, in the CLI, in your IDE, and to AI assistants.
Step sets: write the flow once
A step set is a named, reusable sequence of steps. It lives in a
.saffron file: Saffron's own file type, which is a superset of
Gherkin: everything a .feature file can contain, plus the StepSet:
keyword. (Think TypeScript and JavaScript: .ts is .js plus keywords.
Renaming a .feature file to .saffron changes nothing, it just
unlocks the extra syntax.)
Feature: Checkout Flow
StepSet: Complete guest information
Given I am on guest information page
When I enter guest name "Chathuranga Jayasinghe"
And I click continue button
Then I am not on guest information page
Scenario: Verify checkout happy path
Given I am on calendar page
When I select an available date
And I click "search" button
Then I am on package selection page
When I select an available package
And I click choose button
StepSet Complete guest information
Then I am on checkout page
When I click continue without payment
Then I see booking success page
The syntax rule is the same one Gherkin already uses: block keywords carry a colon, step keywords don't.
StepSet: <name>defines a set (likeScenario:defines a scenario)StepSet <name>: invokes a set inside a scenario (likeGivenstarts a step)
If you accidentally type the colon at an invocation site, the project-wide unique-name rule catches it: the accidental "definition" duplicates an existing set name and fails parsing with a "remove the colon to invoke" hint. Invoking a set that doesn't exist is also a parse error: silence is never an option.
What actually happens: expansion
Saffron resolves a StepSet invocation at parse time, exactly the
way it already merges Background: steps, the set's steps are inlined
into the scenario before anything runs:
This one design choice is what makes step sets more than sugar:
- The expanded steps keep their exact text. To the cache, the seeding engine, healing, and propagation, they are ordinary steps, recorded once, seeded into every scenario that invokes the set. No new machinery, no new failure modes.
- Editing a set is honest. Change a step inside the set and every invoking scenario's cache is stale: Saffron re-records them, but every unchanged step seeds from the index, so you pay AI cost for the changed steps only.
- Assertions inside sets follow the assertion policy by position.
A set's
Thensteps become regular assertions of the calling scenario: mid-scenario they are checkpoints; if a set is invoked as the last thing in a scenario, its trailingThens join the final assertion block: strict under every policy, like any other final assertion.
The recommended set shape (visible in the example): make the first step a guard ("Given I am on guest information page") and the last step an exit assertion ("Then I am not on guest information page"). Every invocation then verifies its own precondition and its own success, wherever it's dropped into a flow.
A few boundaries in v1: set names are unique across the project
(same-file definitions win lookup), sets can contain tables, doc strings
and <param> placeholders, sets cannot invoke other sets, and a set
never runs on its own: only through invocations.
Global step sets: the shared library file
Step sets are project-wide by design. The registry is built from
every .saffron file under your features directory before anything
parses, so a set defined in one file is invocable from all of them,
and running a single file still resolves sets defined elsewhere. There
is no import statement and no separate file format: where a set lives
is an organizational choice, not a semantic one.
That gives you a natural home for steps common to the whole
application: cookie banners, login, navigation. The convention
(TypeScript's .d.ts move: a naming convention, not a new grammar):
a sets-only library file with a .steps.saffron suffix:
# features/shared.steps.saffron
Feature: Shared step sets
StepSet: Accept cookies if prompted
When I accept the cookie banner if it is shown
StepSet: Complete guest information
Given I am on guest information page
When I enter guest name "Chathuranga Jayasinghe"
And I click continue button
Then I am not on guest information page
Any scenario in any .saffron file can now write
StepSet Complete guest information. A library file yields zero
runnable scenarios, so the runner simply skips it at execution time
while its sets feed the registry.
Two things to know:
- The
Feature:header is required even in a sets-only file, the underlying Gherkin parser needs a Feature root. Name it whatever reads well (Feature: Shared step sets). - Keep local sets local: a set used by one flow belongs in that flow's file, next to its scenarios. Promote a set to the library file when a second feature starts invoking it, the project-wide unique-name rule guarantees the move is safe (a forgotten duplicate fails the parse instead of silently shadowing).