What is Gauge?
Gauge is an open-source test automation framework created by ThoughtWorks that takes a fundamentally different approach to test creation. Instead of forcing testers to write tests entirely in code, Gauge separates the test specification (written in Markdown) from the test implementation (written in a programming language of your choice). This separation allows business-readable specifications to coexist with robust, maintainable code.
The core philosophy behind Gauge is simple: tests should be readable by anyone on the team, not just developers. Product owners, business analysts, and QA engineers can read, understand, and even author test specifications in plain Markdown. Meanwhile, developers implement the underlying automation logic in languages like Java, C#, Ruby, Python, JavaScript, or TypeScript.
Gauge is not a Behavior-Driven Development (BDD) framework like Cucumber, though it shares some conceptual similarities. The key difference is that Gauge does not enforce the Given-When-Then syntax. You have complete freedom to structure your specifications however makes sense for your domain — using headings, bullet points, tables, and any Markdown formatting you prefer.
Key Features at a Glance
- Markdown-based specifications — Write tests in human-readable Markdown, not Gherkin
- Multi-language support — Implement steps in Java, C#, Ruby, Python, JavaScript, or TypeScript
- Data-driven testing — Built-in support for table-driven and parameterized tests
- Parallel execution — Run tests in parallel across multiple threads or processes
- Extensible plugin architecture — Generate HTML reports, integrate with CI/CD, and more
- IDE integrations — First-class support in VS Code, IntelliJ, and Eclipse
- Living documentation — Specs double as up-to-date project documentation
Why Gauge Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Traditional test automation often suffers from a communication gap. Automated tests written purely in code become opaque to non-technical stakeholders. Meanwhile, BDD frameworks like Cucumber impose rigid syntax rules that can feel unnatural. Gauge bridges this gap elegantly by letting you write specifications in the most natural format possible — structured Markdown — while keeping the implementation in full-fledged programming languages.
Here are the concrete problems Gauge solves:
- Test readability gap — Specifications are plain Markdown that anyone can read, review, and contribute to. No special syntax training required.
- Maintenance overhead — Because steps are mapped by text matching (not regex), refactoring is simpler. Change a step text in one place, and the framework guides you to update implementations.
- Vendor lock-in — Gauge is fully open-source under the Apache 2.0 license with no commercial tier. You own your tests completely.
- Slow feedback loops — Parallel execution is built-in, not bolted on. You can run hundreds of tests concurrently without complex configuration.
- Documentation drift — Your spec files become living documentation. When tests pass, you know the documented behavior matches reality.
Getting Started: Installation and Setup
Gauge provides a straightforward CLI-based installation. You'll need the Gauge binary itself, a language plugin for your chosen programming language, and optionally an IDE plugin.
Step 1: Install the Gauge CLI
On macOS with Homebrew:
brew install gauge
On Linux (using the install script):
curl -SsL https://raw.githubusercontent.com/getgauge/gauge/master/install.sh | bash
On Windows, download the installer from the Gauge website or use Chocolatey:
choco install gauge
Verify the installation:
gauge --version
Step 2: Install a Language Plugin
Choose the plugin for your preferred language. For Java:
gauge install java
For Python:
gauge install python
For JavaScript/TypeScript:
gauge install js
For C# (.NET):
gauge install dotnet
For Ruby:
gauge install ruby
Step 3: Create a New Gauge Project
Gauge provides an interactive project scaffolding command:
gauge init java
This prompts you for a project name and creates the complete directory structure with sample files. You can also scaffold non-interactively:
gauge init java my-test-project
Step 4: Install IDE Support (VS Code)
The Gauge VS Code extension provides syntax highlighting, auto-completion for step references, and the ability to run specs directly from the editor. Install it from the VS Code marketplace by searching for "Gauge".
Gauge Project Structure
After running gauge init, you'll see a project structure like this:
my-test-project/
├── specs/
│ └── example.spec
├── src/
│ └── tests/
│ └── StepImplementations.java
├── manifest.json
├── env/
│ └── default.properties
└── .gauge/
└── (internal config files)
Here's what each part does:
- specs/ — Contains your Markdown specification files (
.specextension). These are the human-readable test documents. - src/tests/ — Contains the step implementation source code that maps to steps in your specs.
- manifest.json — Project configuration file specifying language, plugin versions, and environment settings.
- env/ — Environment-specific configuration files (base URLs, credentials, timeouts) that can be switched at runtime.
- .gauge/ — Internal Gauge metadata — do not edit manually.
manifest.json Explained
Here's a typical manifest.json for a Java Gauge project:
{
"language": "java",
"plugins": [
"html-report",
"java"
],
"env": {
"default": "env/default.properties"
}
}
The language field tells Gauge which language plugin to use. The plugins array lists active plugins (like the HTML report generator). The env object maps environment names to property files.
Writing Your First Gauge Test
Let's build a complete example: testing a simple login feature for a web application. We'll write the specification in Markdown and implement the steps in Java.
Step 1: Write the Specification File
Create specs/login.spec:
# Login Feature
This specification covers user login scenarios for the application.
## Successful Login with Valid Credentials
Tags: login, smoke, positive
* Open the login page
* Enter username "john.doe@example.com"
* Enter password "SecurePass123!"
* Click the login button
* Verify the dashboard page is displayed
* Verify the welcome message contains "Welcome, John"
## Login Failure with Invalid Password
Tags: login, regression, negative
* Open the login page
* Enter username "john.doe@example.com"
* Enter password "WrongPassword"
* Click the login button
* Verify an error message is displayed
* Verify the error message text is "Invalid username or password"
## Login with Empty Fields
Tags: login, edge-case
| username | password | expectedError |
|----------|----------|----------------------------|
| | anypass | Username is required |
| user123 | | Password is required |
| | | Username is required |
* Open the login page
* Enter username <username>
* Enter password <password>
* Click the login button
* Verify an error message is displayed
* Verify the error message text is <expectedError>
Notice several important things here:
- Steps are denoted with
*(asterisk) — they become executable steps that Gauge matches to implementations. - Tags allow you to categorize and filter tests at runtime.
- Data tables drive parameterized test execution — each row becomes a separate test run.
- Parameters in angle brackets like
<username>are dynamically substituted from table data. - Quoted strings in steps are captured as parameters automatically.
Step 2: Implement the Steps
Now create src/tests/LoginSteps.java:
package tests;
import com.thoughtworks.gauge.Step;
import com.thoughtworks.gauge.Table;
import com.thoughtworks.gauge.TableRow;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
import static org.junit.Assert.*;
public class LoginSteps {
private WebDriver driver;
private WebDriverWait wait;
public LoginSteps() {
driver = new ChromeDriver();
wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
@Step("Open the login page")
public void openLoginPage() {
driver.get("https://example-app.com/login");
driver.manage().window().maximize();
}
@Step("Enter username ")
public void enterUsername(String username) {
WebElement usernameField = driver.findElement(By.id("username"));
usernameField.clear();
usernameField.sendKeys(username);
}
@Step("Enter password ")
public void enterPassword(String password) {
WebElement passwordField = driver.findElement(By.id("password"));
passwordField.clear();
passwordField.sendKeys(password);
}
@Step("Click the login button")
public void clickLoginButton() {
WebElement loginButton = driver.findElement(By.id("login-btn"));
loginButton.click();
}
@Step("Verify the dashboard page is displayed")
public void verifyDashboardDisplayed() {
wait.until(ExpectedConditions.urlContains("/dashboard"));
String currentUrl = driver.getCurrentUrl();
assertTrue("Expected URL to contain '/dashboard', but got: " + currentUrl,
currentUrl.contains("/dashboard"));
}
@Step("Verify the welcome message contains ")
public void verifyWelcomeMessage(String expectedMessage) {
WebElement welcomeElement = driver.findElement(By.id("welcome-msg"));
wait.until(ExpectedConditions.visibilityOf(welcomeElement));
String actualMessage = welcomeElement.getText();
assertTrue("Expected welcome message to contain '" + expectedMessage +
"', but got: '" + actualMessage + "'",
actualMessage.contains(expectedMessage));
}
@Step("Verify an error message is displayed")
public void verifyErrorMessageDisplayed() {
WebElement errorElement = driver.findElement(By.className("error-message"));
wait.until(ExpectedConditions.visibilityOf(errorElement));
assertTrue("Error message should be visible",
errorElement.isDisplayed());
}
@Step("Verify the error message text is ")
public void verifyErrorMessageText(String expectedError) {
WebElement errorElement = driver.findElement(By.className("error-message"));
String actualError = errorElement.getText();
assertEquals("Expected error message '" + expectedError +
"', but got: '" + actualError + "'",
expectedError, actualError);
}
// Cleanup after each scenario
@Step("Close browser")
public void closeBrowser() {
if (driver != null) {
driver.quit();
}
}
}
Key implementation details:
- The
@Stepannotation binds a method to a specification step. Gauge performs fuzzy text matching to find the right implementation. - Parameters in angle brackets like
<username>are passed as method arguments. Quoted strings in steps (like"john.doe@example.com") are also captured as parameters. - You can use any assertion library — JUnit assertions, AssertJ, Hamcrest, or TestNG assertions all work fine.
- Gauge creates a fresh instance of the step implementation class for each scenario, ensuring test isolation.
Step 3: Run the Tests
Execute all specs in the project:
gauge run specs/
Run a specific spec file:
gauge run specs/login.spec
Run only tests with specific tags:
gauge run --tags "smoke" specs/
Run in parallel (4 concurrent streams):
gauge run --parallel -n 4 specs/
Run with a different environment configuration:
gauge run --env staging specs/
Step Implementation in Different Languages
Gauge's multi-language support means you can implement steps in whatever language your team prefers. Here are the same login steps implemented in Python and JavaScript to illustrate the pattern.
Python Implementation
# src/tests/login_steps.py
from gauge.step import step
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import unittest
driver = None
wait = None
@step("Open the login page")
def open_login_page():
global driver, wait
driver = webdriver.Chrome()
wait = WebDriverWait(driver, 10)
driver.get("https://example-app.com/login")
driver.maximize_window()
@step("Enter username ")
def enter_username(username):
field = driver.find_element(By.ID, "username")
field.clear()
field.send_keys(username)
@step("Enter password ")
def enter_password(password):
field = driver.find_element(By.ID, "password")
field.clear()
field.send_keys(password)
@step("Click the login button")
def click_login():
driver.find_element(By.ID, "login-btn").click()
@step("Verify the dashboard page is displayed")
def verify_dashboard():
wait.until(EC.url_contains("/dashboard"))
assert "/dashboard" in driver.current_url
@step("Verify the welcome message contains ")
def verify_welcome_message(message):
element = driver.find_element(By.ID, "welcome-msg")
wait.until(EC.visibility_of(element))
assert message in element.text
@step("Verify an error message is displayed")
def verify_error_displayed():
element = driver.find_element(By.CLASS_NAME, "error-message")
wait.until(EC.visibility_of(element))
assert element.is_displayed()
@step("Verify the error message text is ")
def verify_error_text(expected_error):
element = driver.find_element(By.CLASS_NAME, "error-message")
assert expected_error == element.text
JavaScript/TypeScript Implementation
// src/tests/login_steps.ts
import { Step } from 'gauge-ts';
import { Builder, By, until, WebDriver } from 'selenium-webdriver';
import * as assert from 'assert';
let driver: WebDriver;
export class LoginSteps {
@Step("Open the login page")
async openLoginPage(): Promise {
driver = await new Builder().forBrowser('chrome').build();
await driver.get("https://example-app.com/login");
await driver.manage().window().maximize();
}
@Step("Enter username ")
async enterUsername(username: string): Promise {
const field = await driver.findElement(By.id("username"));
await field.clear();
await field.sendKeys(username);
}
@Step("Enter password ")
async enterPassword(password: string): Promise {
const field = await driver.findElement(By.id("password"));
await field.clear();
await field.sendKeys(password);
}
@Step("Click the login button")
async clickLogin(): Promise {
await driver.findElement(By.id("login-btn")).click();
}
@Step("Verify the dashboard page is displayed")
async verifyDashboard(): Promise {
await driver.wait(until.urlContains("/dashboard"), 10000);
const url = await driver.getCurrentUrl();
assert.ok(url.includes("/dashboard"), "URL should contain /dashboard");
}
@Step("Verify the welcome message contains ")
async verifyWelcomeMessage(message: string): Promise {
const element = await driver.findElement(By.id("welcome-msg"));
await driver.wait(until.elementIsVisible(element), 10000);
const text = await element.getText();
assert.ok(text.includes(message),
`Expected message to contain "${message}" but got "${text}"`);
}
@Step("Verify an error message is displayed")
async verifyErrorDisplayed(): Promise {
const element = await driver.findElement(By.className("error-message"));
await driver.wait(until.elementIsVisible(element), 10000);
assert.ok(await element.isDisplayed());
}
@Step("Verify the error message text is ")
async verifyErrorText(expectedError: string): Promise {
const element = await driver.findElement(By.className("error-message"));
const text = await element.getText();
assert.strictEqual(text, expectedError);
}
}
Advanced Gauge Features
Data-Driven Testing with Tables
Gauge excels at data-driven testing. Tables in spec files can drive multiple iterations of the same scenario. Each row becomes an independent test execution:
# User Registration Validation
## Validate registration with various inputs
Tags: registration, data-driven
| username | email | password | expectedResult |
|--------------|--------------------|--------------|----------------|
| validUser | user@test.com | Strong1Pass | success |
| short | short@test.com | Short1 | error |
| no-at-sign | invalidemail | Strong1Pass | error |
| spacesInName | spaces@test.com | Strong1Pass | success |
* Open the registration page
* Enter username <username>
* Enter email <email>
* Enter password <password>
* Submit the registration form
* Verify the result is <expectedResult>
Gauge executes this scenario four times — once for each table row. Each execution is independent, and failures in one row don't block others. The HTML report shows results for each row separately.
Environment Configuration
Create env/default.properties:
# Default environment configuration
base_url = https://example-app.com
browser = chrome
timeout = 10
admin_username = admin
admin_password = AdminSecret123
Create env/staging.properties:
# Staging environment overrides
base_url = https://staging.example-app.com
browser = firefox
timeout = 15
admin_username = staging_admin
admin_password = StagingSecret456
Access these properties in your step implementations using Gauge's environment API. In Java:
import com.thoughtworks.gauge.Gauge;
import com.thoughtworks.gauge.Step;
public class ConfigurableSteps {
@Step("Open the application")
public void openApplication() {
String baseUrl = Gauge.readEnvironmentProperty("base_url");
String browser = Gauge.readEnvironmentProperty("browser");
// Use these values to configure WebDriver
driver = WebDriverFactory.create(browser);
driver.get(baseUrl);
}
}
Switch environments at runtime:
gauge run --env staging specs/
Scenario Hooks (Before/After)
Gauge supports setup and teardown hooks at multiple levels. Implement them using special annotations:
import com.thoughtworks.gauge.BeforeScenario;
import com.thoughtworks.gauge.AfterScenario;
import com.thoughtworks.gauge.BeforeSpec;
import com.thoughtworks.gauge.AfterSpec;
import com.thoughtworks.gauge.BeforeSuite;
import com.thoughtworks.gauge.AfterSuite;
public class Hooks {
@BeforeSuite
public void beforeSuite() {
// Runs once before all specs in the project
System.out.println("=== Starting test suite ===");
}
@AfterSuite
public void afterSuite() {
// Runs once after all specs complete
System.out.println("=== Test suite finished ===");
}
@BeforeSpec
public void beforeSpec() {
// Runs before each .spec file
System.out.println("Starting spec: " + Gauge.getCurrentSpecification());
}
@AfterSpec
public void afterSpec() {
// Runs after each .spec file completes
System.out.println("Finished spec");
}
@BeforeScenario
public void beforeScenario() {
// Runs before each scenario (heading + its steps)
// Initialize resources, set up test data
System.out.println("Setting up scenario...");
}
@AfterScenario
public void afterScenario() {
// Cleanup after each scenario
// Close browser, delete test data, reset state
if (driver != null) {
driver.quit();
}
System.out.println("Scenario cleanup complete");
}
}
The hook hierarchy follows this order: BeforeSuite → BeforeSpec → BeforeScenario → (steps execute) → AfterScenario → AfterSpec → AfterSuite.
Custom Messages and Screenshots
You can embed diagnostic information — screenshots, custom messages — directly into Gauge reports:
import com.thoughtworks.gauge.Step;
import com.thoughtworks.gauge.Gauge;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
public class ReportingSteps {
@Step("Take a screenshot for debugging")
public void captureScreenshot() {
// Capture screenshot as base64 and embed in report
TakesScreenshot ts = (TakesScreenshot) driver;
String screenshot = ts.getScreenshotAs(OutputType.BASE64);
Gauge.writeMessage("Screenshot captured at critical point");
Gauge.captureScreenshot(screenshot);
}
@Step("Log custom diagnostic info")
public void logDiagnostics() {
Gauge.writeMessage("Current URL: " + driver.getCurrentUrl());
Gauge.writeMessage("Page title: " + driver.getTitle());
Gauge.writeMessage("Available cookies: " + driver.manage().getCookies());
}
}
Step Recovery and Retry
Gauge supports step-level retry logic for flaky operations:
import com.thoughtworks.gauge.Step;
import com.thoughtworks.gauge.ContinueOnFailure;
public class ResilientSteps {
@ContinueOnFailure
@Step("Attempt a flaky operation")
public void flakyOperation() {
// If this step fails, Gauge continues to the next step
// rather than aborting the entire scenario
try {
driver.findElement(By.id("dynamic-element")).click();
} catch (Exception e) {
Gauge.writeMessage("Flaky operation failed, continuing anyway");
}
}
}
For true retry behavior, combine with hooks or implement custom retry logic in your step methods.
Refactoring and Step Renaming
When you change step text in a spec file, Gauge helps you keep implementations in sync. Run the validation command:
gauge validate specs/
This reports any steps in spec files that lack corresponding implementations, and any orphaned implementations that no longer match any spec step. It's essentially a compilation check for your test suite.
Best Practices for Gauge Projects
1. Structure Specs by Business Capability, Not by Page
Organize your spec files around business features, not around UI pages or API endpoints. A spec file should tell a complete business story:
# Good: Business-oriented structure
specs/
├── user-registration.spec
├── product-search.spec
├── checkout-flow.spec
└── order-tracking.spec
# Avoid: Page-oriented structure
specs/
├── login-page.spec
├── dashboard-page.spec
└── settings-page.spec
2. Use Tags Strategically
Establish a tagging convention that enables flexible test filtering in CI/CD pipelines:
Tags: regression, smoke, critical, sprint-23
Common tag categories include:
- Priority:
smoke,critical,regression - Feature area:
checkout,login,profile - Test type:
api,ui,integration - Sprint/iteration:
sprint-23,release-2.1 - Environment:
staging-only,production-safe
3. Keep Steps Atomic and Reusable
Write steps that do one thing and can be reused across multiple scenarios:
# Good: Atomic, reusable steps
* Open the login page
* Enter username "test@user.com"
* Enter password "Secret123"
* Click the login button
# Avoid: Overly specific, non-reusable steps
* Log in as test@user.com with password Secret123 and verify success
Atomic steps become building blocks you can recombine endlessly across different scenarios.
4. Use Data Tables for Variations, Not Separate Scenarios
When testing multiple input combinations, prefer data tables over duplicating scenarios:
# Good: One scenario with a data table
## Validate email field with various inputs
| emailInput | shouldAccept |
|---------------------|--------------|
| user@example.com | true |
| user@.com | false |
| @example.com | false |
| user@example | false |
| "" | false |
* Open registration page
* Enter email <emailInput>
* Submit the form
* Verify acceptance status is <shouldAccept>
5. Implement Robust Step Matching
Gauge uses fuzzy text matching for steps. Keep step text consistent but avoid being overly rigid. These all match the same implementation:
* Open the login page
* open the login page
* Open Login Page
However, for parameters, be explicit. Use angle brackets for dynamic values and quotes for literal strings that should be captured:
* Enter username and password
* Verify the message is "Successfully logged in"
6. Separate Test Data from Step Implementations
Don't hardcode test data in your step implementations. Use environment properties, external data files, or table-driven specs:
// Good: Data from environment properties
@Step("Connect to the database")
public void connectToDatabase() {
String dbHost = Gauge.readEnvironmentProperty("db_host");
String dbUser = Gauge.readEnvironmentProperty("db_user");
String dbPass = Gauge.readEnvironmentProperty("db_password");
connection = DriverManager.getConnection(dbHost, dbUser, dbPass);
}
// Avoid: Hardcoded values
@Step("Connect to the database")
public void connectToDatabase() {
connection = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/testdb", "admin", "password123");
}
7. Leverage Parallel Execution Early
Design your tests to be independent from the start. Each scenario should set up its own state and clean up after itself. This enables parallel execution without complex refactoring later:
@BeforeScenario
public void setup() {
// Each scenario gets a fresh browser instance
driver = new ChromeDriver();
// Create unique test data for this scenario
testUser = "user_" + UUID.randomUUID().toString().substring(0, 8);
}
@AfterScenario
public void cleanup() {
driver.quit();
// Delete any data created during this scenario
apiClient.deleteUser(testUser);
}
8. Write Specs That Non-Developers Can Read
The true power of Gauge comes when your specs serve as living documentation. Write them so that a product manager can understand the expected behavior without reading code:
# Shopping Cart Behavior
## Adding items to an empty cart
Tags: cart, smoke
This scenario verifies that a user can add a product to their
shopping cart and see the correct item count and subtotal update.
* Navigate to the product catalog
* Select the product "Wireless Headphones"
* Verify the product detail page shows price "$79.99"
* Click the "Add to Cart" button
* Verify the cart icon shows count "1"
* Verify the cart subtotal displays "$79.99"
## Removing the only item from the cart
Tags: cart, regression
After adding an item, the user should be able to remove it
and see the cart return to an empty state.
* Navigate to the product catalog
* Select the product "Wireless Headphones"
* Click the "Add to Cart" button
* Open the cart dropdown
* Click the "Remove" button for "Wireless Headphones"
* Verify the cart icon shows count "0"
* Verify the cart displays the message "Your cart is empty"
Notice the descriptive paragraph explaining the business context. This makes the spec file self-documenting.
9. Use the Gauge Report Generator
Enable the HTML report plugin in manifest.json and generate detailed reports after each run:
gauge run --html specs/
Reports include pass/fail status per scenario and per table row, execution time, embedded screenshots, and custom messages. These reports can be published to CI/CD artifacts for stakeholder review.
10. Integrate Gauge into CI/CD Pipelines
Gauge returns standard exit codes (0 for success, non-zero for failure) and produces JUnit-compatible XML output, making CI integration straightforward. A typical pipeline stage:
# Example CI pipeline step (Jenkins/GitHub Actions/GitLab CI)
gauge run --env ci --parallel -n 4 --tags "smoke" specs/
if [ $? -ne 0 ]; then
echo "Smoke tests failed — blocking deployment"
exit 1
fi
For Jenkins, use the JUnit plugin to consume reports/xml/ output. For GitHub Actions, upload the HTML report as an artifact.
Common Pitfalls and How to Avoid Them
Pitfall 1: Steps That Are Too Granular
While atomic steps are good, steps that are too tiny create noisy specs:
# Too granular — tedious to read and maintain
* Open browser
* Maximize window
* Type "https://example.com" in address bar
* Press Enter key
* Wait for page load
* Locate username field
* Clear username field
* Type "user@test.com" in username field
Instead, group logical actions:
# Better granularity — still atomic but meaningful
* Open the login page
* Enter username "user@test.com"
* Enter password "Secret123"
* Click the login button
Pitfall 2: State Leakage Between Scenarios
Gauge creates a fresh step implementation instance per scenario, but global/static state can leak. Always reset shared state in @BeforeScenario hooks. Avoid static WebDriver instances; use instance fields instead.
Pitfall 3: Ignoring the Validation Command
Run gauge validate regularly — ideally as a pre-commit hook or CI check. It catches orphaned implementations and missing steps before they cause mysterious runtime failures.
Pitfall 4: Over-Engineering Step Implementations
Step implementations should be thin adapters between spec text and your automation