When we think of Rust in web development, words like blazing fast, memory safety, and zero-cost abstractions usually come to mind. Rust’s strict compiler guarantees that our web services won’t suffer from data races or unexpected null pointer exceptions. But how do we ensure that our web application actually fulfills business requirements?
Enter Behavior-Driven Development (BDD) and the Cucumber framework for Rust (cucumber crate).
By combining Rust’s powerhouse backend performance with Cucumber’s human-readable specifications, teams can build web applications that are both structurally bulletproof and functionally precise.
What is Cucumber in Rust?#
Cucumber is a popular BDD testing framework that allows you to write test scenarios in plain, human-readable language using Gherkin syntax (Given-When-Then).
In a Rust ecosystem, the cucumber crate acts as a test harness runner. It parses your .feature files and maps them directly to native Rust asynchronous functions (step definitions). This gives you the best of both worlds:
- Business-facing, executable specifications.
- The safety, speed, and concurrency of native Rust code.
Why Use Rust and Cucumber for Web Development?#
- Living Documentation: Feature files serve as up-to-date documentation that product owners, QA, and developers can all read and understand.
- Compile-Time and Runtime Confidence: Rust catches memory and typing bugs at compile time, while Cucumber ensures your application logic matches real-world user workflows.
- Async Native: Modern Rust web frameworks (like Axum, Actix-web, or Warp) are heavily asynchronous. The Rust Cucumber implementation natively supports
async/await, making it seamless to test HTTP endpoints and database calls.
Step-by-Step: Setting Up Cucumber in a Rust Web Project#
Let’s walk through a basic setup for testing a web API route using Rust and Cucumber.
1. Configure Cargo.toml#
First, add your dependencies. You will need cucumber, an async runtime like tokio, and a way to make HTTP requests against your web server (like reqwest).
[package]
name = "rust_web_bdd"
version = "0.1.0"
edition = "2021"
[[test]]
name = "cucumber"
harness = false
[dependencies]
axum = "0.7"
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
[dev-dependencies]
cucumber = "0.20"
reqwest = { version = "0.11", features = ["json"] }
toml2. Write Your Gherkin Feature File#
Create a directory named tests/features/ and add a file called user_api.feature:
Feature: User Management API
As a client of the web service
I want to register a new user
So that I can access protected resources
Scenario: Successfully registering a new user
Given the web server is running
When I send a POST request to "/users" with username "alice"
Then the response status should be 201
And the response body should contain "User alice created successfully"
gherkin3. Implement the World and Step Definitions#
The “World” in Cucumber represents the state shared across a single scenario. Create tests/cucumber.rs:
use cucumber::{given, when, then, World};
use reqwest::Client;
#[derive(Debug, World)]
#[world(init)]
pub struct ApiWorld {
client: Client,
server_url: String,
status_code: Option<u16>,
response_body: Option<String>,
}
#[given("the web server is running")]
async fn server_is_running(world: &mut ApiWorld) {
world.client = Client::new();
world.server_url = "http://127.0.0.1:8080".to_string();
}
#[when(expr = "I send a POST request to {string} with username {string}")]
async fn send_post_request(world: &mut ApiWorld, path: String, username: String) {
let url = format!("{}{}", world.server_url, path);
let res = world.client
.post(&url)
.json(&serde_json::json!({ "username": username }))
.send()
.await
.expect("Failed to execute request");
world.status_code = Some(res.status().as_u16());
world.response_body = Some(res.text().await.unwrap_or_default());
}
#[then(expr = "the response status should be {int}")]
async fn verify_status_code(world: &mut ApiWorld, expected_status: u16) {
assert_eq!(world.status_code, Some(expected_status));
}
#[then(expr = "the response body should contain {string}")]
async fn verify_response_body(world: &mut ApiWorld, expected_text: String) {
let body = world.response_body.as_ref().expect("No response body");
assert!(body.contains(&expected_text));
}
#[tokio::main]
async fn main() {
ApiWorld::cucumber()
.run("tests/features/user_api.feature")
.await;
}
rust4. Run Your BDD Tests#
Execute your test suite using Cargo:
cargo test --test cucumber
bashWatch as Cucumber parses your human-readable Gherkin script, spins up or communicates with your Rust application blocks, and outputs green results!
Best Practices for Rust BDD#
- Isolate State: Ensure your
Worldstruct resets cleanly between scenarios. If you use a database, spin up an ephemeral container (like via Testcontainers-rs) or use transaction rollbacks for each test scenario. - Keep Steps Reusable: Design your Gherkin steps with parameters (using
{string}or{int}) so they can scale across multiple endpoints rather than writing a new step for every single route. - Handle Async Cleanly: Leverage Tokio’s multi-threaded runtime features inside your step definitions to handle concurrent web requests effortlessly.
Conclusion#
Building web applications in Rust gives you unmatched performance and reliability. By integrating the Cucumber framework, you bridge the gap between technical execution and business requirements, ensuring your web services aren’t just fast and safe — they are built to do the right thing.