DEV Community

Cover image for Rust BDD tests with Cucumber
Roger Torres Paes (he/him/ele)
Roger Torres Paes (he/him/ele)

Posted on • Updated on

Rust BDD tests with Cucumber

TL;DR: I will show the basics of how to use Cucumber (and its language, Gherkin) alongside Rust.

Cucumber is a tool for behavior-driven development (BDD) that uses a language called Gherkin to specify test scenarios with a syntax that is very close to natural language, using key-words such as When and Then.

Here I will not explain the particularities of neither Cucumber nor Gherkin. My goal is to just show you how to use them with Rust. If you know nothing about them, I recommend you to:

That being said, the examples I am using are elementary, so you should have no problem following along even if you have never seen Cucumber and Gherkin before.


Sample project

The code used in this tutorial can be found here.

To focus on how to use Cucumber with Rust, I decided to code a dummy multiplication function, so you don't get distracted by the idiosyncrasies of a particular project.

First, create a library crate:

$ cargo new --lib bdd
Enter fullscreen mode Exit fullscreen mode

Then, remove the mod tests from lib.rs and code this:

pub fn mult(a: i32, b: i32) -> i32 {
    a * b
}
Enter fullscreen mode Exit fullscreen mode

And that's all for lib.rs.


Setting up the manifest

The manifest (Cargo.toml) require these entries:

[[test]]
name = "cucumber"
harness = false

[dependencies]
tokio = { version = "1.9.0", features = ["rt-multi-thread", "macros", "time"] }


[dev-dependencies]
cucumber_rust = "0.9"
Enter fullscreen mode Exit fullscreen mode

In [[test]] we have:

  • name, which is the name of the .rs file where we will code the tests, cucumber.rs in this case.
  • harness is set to false, allowing us to provide our own main function to handle the test run. This main function will be placed inside the .rs file specified above.

In [dependencies] we have tokio, which is an async runtime required to work with cucumber-rust (even if you are not testing anything async). You may use another runtime.

And in [dev-dependencies] we have cucumber_rust, the star of this show.


Project structure

We have to create a couple of files:

  • tests/cucumber.rs, where we will have the main function that will run the tests.
  • features/operations.feature, where we will code our test scenario using Gherkin.

At the end, we have this:

bdd
├── features
│   └── operations.feature
├── src
│   └── lib.rs
├── tests
│   └── cucumber.rs
└── Cargo.toml
Enter fullscreen mode Exit fullscreen mode

Coding the test with Gherkin

This is how the operation.feature file looks like:

Feature: Arithmetic operations

  # Let's start with addition. BTW, this is a comment.
  Scenario: User wants to multiply two numbers
    Given the numbers "2" and "3"
    When the User adds them
    Then the User gets 6 as result
Enter fullscreen mode Exit fullscreen mode

Here we have a context set by Given, an event described by When and an expected result expressed by Then.

Although my purpose is not to teach Cucumber, I have to say that this is not a good BDD scenario. Not so much because it is dead simple, but because it is a unit test in disguise. I have, nevertheless, kept it here because it has the virtue of making things crystal clear regarding how we are going to interpret this scenario with Rust. For best practices, check this.


Handle the scenario with Rust

From now on, we stick with the mighty Crabulon Rust.

World object

The first thing we need is a World, an object that will hold the state of our test during execution.

Let's start coding cucumber.rs:

use cucumber_rust::{async_trait, Cucumber, World};
use std::convert::Infallible;

pub enum MyWorld {
    Init,
    Input(i32, i32),
    Result(i32),
    Error,
}

#[async_trait(?Send)]
impl World for MyWorld {
    type Error = Infallible;

    async fn new() -> Result<Self, Infallible> {
        Ok(Self::Init)
    }
}
Enter fullscreen mode Exit fullscreen mode

For now, we created an enum named MyWorld that will be our "World object", holding the data between steps. Init is its initial value.

The MyWorld object implements the trait World provided by the cucumber_rust, which in turn gives us the methods to map the steps (Given, When, Then, etc.).

The Error type is a requirement from this trait, as is the attribute #[async_trait(?Send)].

Step builder

Now it is time to actually code the interpreter. I will explain the code block by block, so it might be useful to also have the complete code open, so you don't lose sight of the whole picture.

Given

mod test_steps {
    use crate::MyWorld;
    use cucumber_rust::Steps;
    use bdd::mult;

    pub fn steps() -> Steps<MyWorld> {
        let mut builder: Steps<MyWorld> = Steps::new();

        builder.given_regex(
            // This will match the "given" of multiplication
            r#"^the numbers "(\d)" and "(\d)"$"#,
            // and store the values inside context, 
            // which is a Vec<String>
            |_world, context| {
                // With regex we start from [1]
                let world = MyWorld::Input(
                    context.matches[1].parse::<i32>().unwrap(),
                    context.matches[2].parse::<i32>().unwrap(),
                );
                world
            }
        );

        // The rest of the code will go here.
    }
}
Enter fullscreen mode Exit fullscreen mode

After declaring the mod, we created our builder, a Steps struct that will store our steps.

The crate cucumber_rust provides three variations for the main Gherkin prefixes (Given, When, Then):

  • The "normal" one that matches fixed values (e.g. when());
  • The regex version that parses the regex input (e.g. when_regex()).
  • The async version to handle async tests, something I am not covering here (e.g. when_async()).
  • The async+regex, which is a combination of the last two (e.g. when_regex_async()), also not covered here.

I am using given_regex() to parse the two numbers. Remember that in operations.feature I specified this:

Given the numbers "2" and "3"
Enter fullscreen mode Exit fullscreen mode

When you call a step function such as given_regex() you get a closure containing the World object and a Context. The latter have a field called matches that is a Vec<String> containing the regex matches (if you're not using a _regex step, the Vector will be empty). In this case, as I am using regex, it has three values:

  • [0] has the entire match, the numbers "2" and "3" in this case.
  • [1] has the first group, 2 in this case.
  • [2] has the first group, 3 in this case.

This is the regex "normal" behavior. If you are not familiar with regex, this is a good intro (thank you YouTube for holding my watch history for so long).

With these values, I return my World object now set as Input.

Before we move to when, I have two quick remarks to make:

  • I am not checking the unrwap() because the regex is only catching numbers with (\d). Sometimes you might want to capture everything with something like (.*) and validate the content inside your code.
  • If you want to change your World object (for example, if it is a struct holding multiple values and/or states), just place mut before world in the closure, and you will get a mutable object.

When

builder.when(
    "the User multiply them", 
    |world, _context|{
        match world {
            MyWorld::Input(l, r) => MyWorld::Result(mult(l,r)),
            _ => MyWorld::Error,
        }
    }
);
Enter fullscreen mode Exit fullscreen mode

This one is very straightforward. I use match to get the enum inner value, multiply both inputs and return the World object with a new value.

The function mult is ratter useless, but it has a role to play here: to show you how to import what we declared within the library crate.

Then


builder.then_regex(
    r#"^the User gets "(\d)" as result$"#, 
    |world, context|{
        match world {
            MyWorld::Result(x) => assert_eq!(x.to_string(), context.matches[1]),
            _ => panic!("Invalid world state"),
        };
        MyWorld::Init
    }
);

builder
Enter fullscreen mode Exit fullscreen mode

Here I use regex again to compare the value that was calculated in the Then step with the value provided by Gherkin (which is, as I said, a very suspicious BDD scenario).

At the very end, I return builder.


The substitute main function

After the mod, we declare our main function:

#[tokio::main]
async fn main() {
    Cucumber::<MyWorld>::new()
        .features(&["./features"])
        .steps(test_steps::steps())
        .run_and_exit()
        .await
}
Enter fullscreen mode Exit fullscreen mode

Here we are:

  1. Creating a World object
  2. Pointing to the .feature file containing the Gherkin test.
  3. Adding the steps defined with cucumber_rust.
  4. Running and exiting once it is done.
  5. The await because cucumber_rust requires the whole thing to be async.

That's it! To test it, all you have to do is to run

$ cargo test
Enter fullscreen mode Exit fullscreen mode

Rust will run the main function found in the file specified in the manifest: cucumber.rs. This is the result.

Alt Text

I recommend you to mess with the values and run the tests, so you can see how the errors are captured.


Much more can be done with cucumber_rust. I hope this tutorial helps you get started with it.

Cover image by Roman Fox.

Discussion (3)

Collapse
tyranron profile image
Kai Ren

It's better and more ergonomic to use macros feature and proc-macro support for regular Rust functions to define steps. For some reason it has been removed from README and isn't shown in examples, but if we do look at previous versions, we can see it.

Collapse
rogertorres profile image
Roger Torres Paes (he/him/ele) Author

Hi @tyranron , thank you for pointing that out!

Collapse
priteshusadadiya profile image
Pritesh Usadadiya

[[...Pingback...]]
Curated as a part of #21st issue of Software Testing notes