Cucumber-rust since 0.7 – The Most Important Changes

cucumber-rust has had a long way, since my last post about the 0.7 release in October 2020. It’s time to come back and see what happened since back then. First of all, starting from the initial 0.8.0 release, I will dig through the changelog and evaluate my favorite changes. Then we will update the Cucumber tests of my encrspyter project to the most recent version. Lots of stuff to do, so let’s go!

New things first

Let’s start soft: With 0.8.4, we got a --debug command line flag that leverages the test execution to nicely print stdout and stderr for each executed step. We can activate the debug mode in the runner creation code of our test’s main function:

fn main() {
    let runner = cucumber::Cucumber::<EncrsypterTestWorld>::new()
        .features(&["./tests/features/"])
        .steps(encrypt_decrypt_steps::steps())
        .debug(true); // This activates the new debug mode 
    ...
}

By running cargo test, we can see it in action:

Cucumber-rust's Debug mode produces sections in the test's cli output called Captured stdout and Captured stderr respectively. Captured stdout contains stdout text in white, Captured stderr contains stderr text in blue.

Neat, right?

t!-Macro extended with a World parameter type

Tiny but neat addition: We can now add the type of our Cukes World-object to the t!-closure.

t!(|mut world: MyPersonalCukesWorld, ctx| { [...] }

Although the generated code is the same as without the explicit type, it adds a bit more Rust-style expressivity. Sweet!

New callback methods for the Cucumber runner: before and after

In vanilla Cucumber, I admired its feature to define hooks that intercept the execution of a feature or a scenario. You can write some code and tell Cucumber to execute it before, after or before and after a scenario, feature or even a step. This is useful to for example set up or tear down a test database before or respectively after a test run.

With the release of 0.9.0, we can do similar things in Rust, too. There is a significant implementation difference to vanilla Cukes though: Our hooks won’t be picked up from wherever they are defined, but are defined as properties of the Cucumber runner instead. To compensate, our before and after hooks come with powerful query options to decide where to execute the defined method.

The second difference is that they are not officially called „hooks“ but „lifecycle methods“ instead. I might get this wrong due to habits. Please bear with me.

Lets head into an example. Given 2 features, one of them in English, one of them in German, each in 2 separate files:

# Feature 1 (English description)
Feature: Encrypt messages and write them to a file.

  Scenario: Encrypt a simple Hello World - message.
    Given I have an encryptor initialized with input "Hello World!"
     When I test print to STDOUT
      And I test print to STDERR
     Then I should see "Hello World!" in the test encryptor's input field
     When I encrypt the encryptor's input
     Then testfile.txt exists
      And testfile.txt is not empty
     When I decrypt testfile.txt
     Then the decrypted result should be "Hello World!"
# language: de
# Feature 1 (German description)
Funktionalität: Verschlüssele Nachrichten und schreibe sie in eine Datei.

  Beispiel: Encrypt a simple Hello World - message.
    Angenommen I have an encryptor initialized with input "Hello World!"
     Wenn I test print to STDOUT
      Und I test print to STDERR
     Dann I should see "Hello World!" in the test encryptor's input field
     Wenn I encrypt the encryptor's input
     Dann testfile.txt exists
      Und testfile.txt is not empty
     Wenn I decrypt testfile.txt
     Dann the decrypted result should be "Hello World!"

What we want to do now is get greeted and dismissed in the respective language. We will define proper lifecycle methods on our Cucumber runner to do that. In the main method:

    let english_feature_name = "Encrypt messages and write them to a file."; // full string filter for the English...
    let german_feature_pattern = Regex::new("Verschlüssele Nachrichten.*").unwrap(); // and a Regex filter for the German variant.

let runner = cucumber::Cucumber::<world::EncrsypterTestWorld>::new()
.features(&["./tests/features/"])
        .steps(crate::encrypt_decrypt_steps::steps())
        .language("de") 
        .before(feature(english_feature_name), |_ctx| {
            async { println!("Greetings, encryptor!") }.boxed()
        })
        .after(feature(english_feature_name), |_ctx| {
            async { println!("Goodbye, encryptor!") }.boxed()
        })
        .before(feature(german_feature_pattern.clone()), |_ctx| { // clone is necessary here due to the trait bounds of Inner<Pattern>
            async { println!("Hallo, Verschlüsselnder.") }.boxed()
        })
        .after(feature(german_feature_pattern), |_ctx| {
            async { println!("Tschüss, Verschlüsselnder.") }.boxed()
        });

feature() expects either the full feature description as a &str or a valid regex::Regex() matching your targets‘ description string. The latter requires the regex module as a dependency in your Cargo.toml, but it will provide you a highly powerful filtering tool, so adding that additional dependency is highly recommended.

Executing cargo test will show us what we expect. For the English feature file:

Greetings, encryptor!
Feature: Encrypt messages and write them to a file.

[...]

  ✔ Then the decrypted result should be "Hello World!"                                                                
Goodbye, encryptor!

For the German Feature file:

Hallo, Verschlüsselnder.
Funktionalität: Verschlüssele Nachrichten und schreibe sie in eine Datei.

[...]

  ✔ Dann the decrypted result should be "Hello World!"                                                               
Tschüss, Verschlüsselnder.

Great stuff! Last but not least, let me note that this does not only work with Feature, but with Scenario and Rule, too. You can even create more custom filters by combining them with And and Or. Please refer to the cucumber-rust code base for more about that.

Heads up, a breaking change!

With 0.9.0 we got one significant change in Cukes‘ public API, but don’t worry: Fixing it is quickly done and even quite easily automatable. If you review my guide on cucumber-rust for 0.7, you will see the related step definitions written like this:

.given_regex_async(
    r#"^I have an encryptor initialized with input "([\w\s!]+)"$"#,
    t!(|mut world, texts_to_encrypt, _step| {
        world.encryptor.input = Cow::Owned(texts_to_encrypt[1].to_owned());
        world
    }),
)

This throws a compiler error now stating that the „signature“ of the t! macro has changed: Instead of the regex matches object in parameter #2 and _step in parameter #3, we now have a single StepContext object that contains the properties matches and step.

Therefore, in the above example we have to do the following:

  1. Remove the _step parameter entirely
  2. Rename our matches parameter texts_to_encrypt to something that reflects the StepContext type: ctx
  3. Replace the occurrences of texts_to_encrypt with ctx.matches[index_used_previously]

For _step we have no replacements to do, because we didn’t use it in the first place, so that’s basically it. The runnable step definition should now look like this:

.given_regex_async(r#"^I have an encryptor initialized with input "([\w\s!]+)"$"#, t!(|mut world, ctx| {
                world.encryptor.input = Cow::Owned(ctx.matches[1].to_owned());
                world
    }),
)

Personally I like this particular change quite a lot, because it keeps the already loaded t! macro clean and organised. What do you think? Feel free to let me know in the comments below.

Feature: Add before and after lifecycle functions to the Cucumber builder. This function takes a selector for determining when to run 'before' or 'after', and a callback

Feature: add language argument to Cucumber builder to set default language for all feature files (ON HOLD)

Encrsypter’s Cucumber tests in a new look

I updated the tests in Encrsypter’s project master and in the cukes_0.9.0 branch, so if you want to see the changes in full action, give it a git pull on the master or a git checkout on the mentioned branch and enjoy.

Conclusion: great changes and improvements

Phew, so long. cucumber-rust really does have a long way, and many things have changed for more Cukes excitement. Personally I like the current implementation state really a lot and I’m looking forward to seeing its bright future. But for now, let’s wrap up the wrapup, shall we?

If you want to read more about Cukes in Rust, here’s my intro to Cucumber in Rust written for 0.7. Or you might say „meh, I prefer the vintage things of life, give me the vanilla stuff“. In that case, here you can find the original version of my intro guide.
And last but for sure not least, here’s the project’s full changelog with all the goodness listed. Happy cuking!

Home » Rust
Share it with:

Rust Modules Cheat Sheet

If you struggle as regularly with the super explicit Rust modules system as I do, then welcome to the club. For us poor souls, I decided to write down a quick cheat sheet about how to structure our modularized code.

Limitations

This post focuses on the directory and file structure only. It does not cover use, mod, crate:: et. al. in-depth. If you would like me to cover them, feel free to let me know in the comments.

The Module Setup

Consider a generic cargo new – generated project with one module we want to expose. We will have the following participating files and folders named in a very generic way here. These are listed from deep inside the project tree to higher levels:

  1. file1.rs, file2.rs,... that contain code we want to expose
    1. public functions and structs are written here using the keyword pub at the beginning of their declaration
    2. macros come with their #[macro_export] anyways, so no further changes needed
  2. a directory src/folder that contains the files from 1.
  3. a file src/folder.rs that lists all files within folder that we want to expose by doing:
    1. pub mod file1;
    2. pub mod file2;
  4. our main.rs (or lib.rs, depending of your app design) that references the exposed code by doing:
    1. mod folder;
    2. use crate::folder::file1::pub_function1
    3. use crate::folder::file1::pub_function2
    4. use crate::folder::file2::pub_function1
    5. use crate::folder::file2::pub_function2
    6. ….

Please check this bitbucket repo for an in action-demo. We will use the maths Rust module next. The macros module is a bonus for you. Here’s a quick mapping of the file and folder names according to the schema above:

  1. src/maths/functions1.rs, src/maths/functions2.rs
  2. src/maths/
  3. src/maths.rs
  4. src/main.rs

When we review those files, we will see that they do exactly, what the schema expects us to.

So far about Rust modules…

I hope this little sheet will help you to remember the complex structure of Rust modules. If you have an improvement idea, or if you found a defect, please feel free to drop it in the comments – I watch the comment sections regularly – or send me an email.

Have great day!

Home » Rust
Share it with:

Cucumber in Rust 0.7 – Beginner’s Tutorial

Introduction

Recently I have introduced us to Cucumber and how to use it in Rust, and while doing the writeup, cucumber-rust 0.7 has been released bringing a huge set of new and unique features. After a closer look through the readme, the strong focus on asynchronous test execution caught my eye. And since I’m a huge fan of ansynchronous programming having done lots of pet stuffs in NodeJS, seeing both my favorite BDD framework and my favorite system level language going strong in async got me severely hyped.

So let’s go!

Reminder: What is Cucumber?

Cucumber is a framework that implements Behavior Driven Development. The rules of BDD can be summarized as formulizing the requirements step by step in a more and more technical way. We start with the written requirements by your fellow business department and reformulate the requirements into a machine-readable format. Next, we use this text version to write an automated test case that fails, and implement the feature until the test passes. This flow gives it the popular resemblance to Test Driven Development. Cucumber leverages BDD by providing the machine- and human-readable layer based on so-called feature files. These use the Gherkin syntax, a simple syntax based on the keywords Given, When, Then, And and But.

Cucumber is still widely used as a test runner, although BDD is rarely actually applied due to the all-time-popular time limitation in nowaday’s software projects. Another rather unfortunate similarity to TDD.

Reminder: What is Rust?

Rust is a fairly new and rising system level programming language that operates in the same markets as C++ and friends. Besides system-level performance, its main focus lies in builtin security and safety. Furthermore, due to its security and safety-heavy design, it is able to completely omit automated memory management. It just doesn’t need it while still guaranteeing memory safety.

All these points are topped off by an exceptional developer experience: The Rust toolchain brings its full-fledged API documentation and its popular text book right to your command line-operating finger tips, and even compiler errors are designed as tiny educational lessons.

Our Test Object: A Simple AES Encryption Tool

In my previous post, we talked about a small encryption tool with the unspeakable name „Encrsypter“, which was started, when I did my first baby steps in Rust. Today it will serve us once more as our example test object.

The tool is based on aes-gcm, an AES encryption library (or „crate“ in Rust terms) that got audited successfully by the nccgroup. The full source code is available in my bitbucket repo, but for training purposes, I recommend removing the tests/ directory, as we will incrementally build it up during this tutorial.

Writing Cucumber-based Tests

Before we add the sources for our test cases, let’s check the test object’s project layout. We will start with the following directories and files:

encrsypter’s project directory without Cucumber tests. Here you find Cargo.toml, Cargo.lock and the src directory. In src/ you find constants.rs, decryptor.rs, encryptor.rs, lib.rs and main.rs.
encrsypter’s project directory without tests

Before we can start coding the test, we must add a cargo-compatible test subproject structure. On your favorite command line, please create the following directories with these terminal commands (all directories relative to the project root):

mkdir tests
mkdir tests/features

We will create and store our feature file that specifies the test steps of our Cucumber test in the features/ subdirectory. The step implementation will later go directly to the tests/ directory alongside the central configuration that we will create now. As described in the official documentation, we create a file called cucumber.rs in tests/ with the following content:

mod encrypt_decrypt_steps;

use async_trait::async_trait;
use encrsypter_lib::{decryptor, encryptor};
use std::borrow::Cow;
use std::convert::Infallible;

pub struct EncrsypterTestWorld {
    encryptor: encryptor::Encryptor<'static>,
    decryptor: decryptor::Decryptor<'static>,
    encrypted_base64: String,
    decrypt_result: String,
}

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

    // Much more straightforward than the Default Trait before. :)
    async fn new() -> Result<Self, Infallible> {
        let key = &[1; 32];
        let nonce = &[3; 12];

        Ok(Self {
            encryptor: encryptor::Encryptor {
                input: Cow::Borrowed(""),
                key,
                nonce,
            },
            decryptor: decryptor::Decryptor {
                file_path: "./testfile.txt",
                key,
                nonce,
            },
            encrypted_base64: "".to_string(),
            decrypt_result: "".to_string(),
        })
    }
}

fn main() {
    // Do any setup you need to do before running the Cucumber runner.
    // e.g. setup_some_db_thing()?;
    let runner = cucumber::Cucumber::<EncrsypterTestWorld>::new()
        .features(&["./tests/features/"])
        .steps(encrypt_decrypt_steps::steps());

    // You may choose any executor you like (Tokio, async-std, etc)
    // You may even have an async main, it doesn't matter. The point is that
    // Cucumber is composable. :)
    futures::executor::block_on(runner.run());
}

The EncrsypterTestWorld struct contains the mutable instances of our test objects: the encryptor and decryptor that serve to encrypt and decrypt our messages using AES. Further we will maintain special fields to keep track of the test object’s respective outputs. In version 0.7 we have an actual main function that serves as our entry point instead of the cucumber! macro in the previous version. Here we perform the basic configuration that gets our Cucumber test up and running: We…

  • … specify the test’s World struct containing our test objects, …
  • … tell Cucumber where to find feature files, …
  • … declare the module that contains our step implementations and …
  • … declare, which asynchronous executor we use to resolve the async step calls.

During this tutorial we use async-std supported by the futures and async-trait package. The latter is necessary to extend traits with asynchronous functionality that is not officially supported as of now (Rust 1.47.0). async-std is by no means set in stone though; you can use tokio or any other asynchronous runner equally well. I’m just much more familiar with async-std and futures.

The next config part is done in the project’s Cargo.toml. Again according to the official documentation, we should specify the dev-dependencies and the [[test]] directive as shown here:

[package]
name = "encrsypter"
version = "0.1.0"
authors = ["Florian Reinhard <me@florianreinhard.de>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
aes-gcm = "0.6.0"
rand = "0.7.3"

[lib]
name = "encrsypter_lib"
path = "src/lib.rs"

[[test]]
name = "cucumber"
harness = false # Allows Cucumber to print output instead of libtest

[dev-dependencies]
cucumber = { package = "cucumber_rust", version = "^0.7.0" }
base64 = "0.12.3"
futures = "0.3.6"
async-trait = "0.1.41"

In terms of dependencies we need the cucumber_rust package to run our tests and the futures and async-trait packages as discussed above.

Then we need the base64 package, because we will work with and do assertions on raw bytes. Although not entirely necessary, it may come in handy for visualisation purposes.

Under [[test]] we give our Cucumber test a name and we route the execution output to stdout to have a nice and tidy output, where we need it.

Alright, the config is done. Now we are ready to specify our first test case. We will encrypt a small „Hello World!“ message, give it a rough sanity check, and then we decrypt it back and hope that the decrypted output matches our input. Under ./tests/features, please create the file encryptor.feature. The containing test specification should roughly look like this:

Feature: Encrypt messages and write them to a file.

  Scenario: Encrypt a simple Hello World - message.
    Given I have an encryptor initialized with input "Hello World!"
     Then I should see "Hello World!" in the encryptor's input field
     When I encrypt the encryptor's input
     Then testfile.txt exists
      And testfile.txt is not empty
     When I decrypt testfile.txt
     Then the decrypted result should be "Hello World!"

This describes, what we want to accomplish: We want to encrypt the string „Hello World!“ and check, whether the output is there and whether it is not completely broken. Then we want to decrypt that output back and check, whether the output is the same as our input message. Next, we have to actually automate this test by implementing the Givens, Whens, Thens and Ands in the feature file.

Step Implementation Files

So far we have told Cucumber, where to find its stuff, and we created a written test specification. Great, we are almost there. The last step is to weave the magic into the Gherkin steps that do the heavy lifting, when Cucumber reads a step in the current feature file. Lets check out the following example step and see, what that means:

.when_async(
    "I encrypt the encryptor's input",
    t!(|world, _step| {
        world.encryptor.write_encrypted();
        world
    }),
)

This means whenever the Cucumber engine finds a step that matches „When I encrypt the encryptor’s input“ inside the feature file, the code within the closure that is constructed by the builtin t! macro is executed. Here we encrypt some random text.

The t! macro creates a wrapper around the step-implementing closure that extends it with asynchronous and future-driven functionality. It is exclusive to the asnychronous step methods. In the regular non-asynchronous step methods you can use regular closures.

Back to step implementations; regular expressions are usable, too:

.given_regex_async(
    r#"^I have an encryptor initialized with input "([\w\s!]+)"$"#,
    t!(|mut world, texts_to_encrypt, _step| {
        world.encryptor.input = Cow::Owned(texts_to_encrypt[1].to_owned());
        world
    }),
)

This step defines the text that we want to encrypt using the When step from above. Here the text is derived from the feature file by matching the regular expression and its enclosing capture group ([\w\s!]+). The value that was read by the capture group goes to the custom closure parameter after world, in this case called text_to_encrypt. By using the regular expression above, we could have written the steps in our feature file like the following:

Given I have an encryptor initialized with input "Hi I am Floh"
=> encryptor input is "Hi I am Floh"

Given I have an encryptor initialized with input "99 bottles of beer on the wall…"
=> encryptor input is "99 bottles of beer on the wall…"

Given I have an encryptor initialized with input "Your ad here"
=> encryptor input is "Your ad here

Putting all the knowledge together, here is the sample implementation for our test steps. Please put it into ./tests/encrypt_decrypt_steps.rs (relative to the project root).

use cucumber::{t, Steps};
use std::borrow::Cow;
use std::fs;
use std::path::Path;

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

    builder
        .given_regex_async(
            r#"^I have an encryptor initialized with input "([\w\s!]+)"$"#,
            t!(|mut world, texts_to_encrypt, _step| {
                world.encryptor.input = Cow::Owned(texts_to_encrypt[1].to_owned());
                world
            }),
        )
        .then_regex_async(
            r#"^I should see "([\w\s!]+)" in the encryptor's input field$"#,
            t!(|world, expected_texts, _step| {
                assert_eq!(expected_texts[1], world.encryptor.input);
                world
            }),
        )
        .when_async(
            "I encrypt the encryptor's input",
            t!(|world, _step| {
                world.encryptor.write_encrypted();
                world
            }),
        )
        .then_async(
            "testfile.txt exists",
            t!(|_world, _step| {
                let testfile_path = Path::new("./testfile.txt");
                assert_eq!(testfile_path.exists(), true);
                _world
            }),
        )
        .then_async(
            "testfile.txt is not empty",
            t!(|mut world, _step| {
                let enc_message = fs::read("./testfile.txt").expect("Could not read test file.");
                world.encrypted_base64 = base64::encode(&enc_message);

                assert_eq!(world.encrypted_base64.len() > (0 as usize), true);
                world
            }),
        )
        .when_async(
            "I decrypt testfile.txt",
            t!(|mut world, _step| {
                world.decrypt_result = world.decryptor.read_decrypted();
                world
            }),
        )
        .then_regex_async(
            r#"^the decrypted result should be "([\w\s!]+)"$"#,
            t!(|mut world, expected_texts, _step| {
                assert_eq!(expected_texts[1], world.decrypt_result);
                world
            }),
        );

    builder
}

Please note that we use raw string literals written in r#...# in order to spare us escaping intentional doublequotes and backslashes.

Now we are ready for the first test run. Please execute the following command in your favorite terminal:

cargo test --test cucumber

If all goes well, it shows us a positive test result:

All 7 Cucumber feature steps passed. Yay!
All 7 Cucumber feature steps passed. Yay!

Conclusion: The All New Cucumber-Rust

The new version line cucumber-rust 0.7 brought a lot of super powers to the tips of our test automation fingers. With asynchronous tests, we are a huge step closer to real test parallelization and thus to less performance headaches, a quite notorious problem in test automation. The default trait got replaced by an intuitive and asynchronous World::new function, which makes working with Worlds much more intuitive, and as a great personal side effect, I got rid of the hassle that the World instance’s lifetime caused me. This helps me immensely to read, write and reason about the code. In future versions we might expect more simplifying changes to make asynchronous testing even more intuitive. For example with the power of procedural macros maybe we will get by without the t! macro ..?

I’m most certainly looking forward to the future versions.

If you are curious about how the test looked like in 0.6, here you can find my previous Cukes tutorial. Or if you’d like to know, here i talk about why I picked up test automation in the first place. And, as mentioned in my original Cucumber Rust article, here is my quick tutorial on how to use Rust Modules.

Have a great day & happy testing!

EDIT Nov. 2021: A lot of things have been worked on in Cucumber Rust, so I compiled a comprehensive summary about the most crucial changes in Cucumber Rust. Hope you enjoy it!

Home » Rust
Share it with:

Cucumber in Rust – Beginner’s Tutorial

Introduction

When I started my first QA role back in 2014, my first tasks included the maintenance and extension of a large test base, that was supposed to work for 4 different projects in parallel. It was based on Cucumber and the Ruby programming language, a stack I fell more and more in love with. This love still lasts to this day.

Therefore, it is time to relive the feeling, that is working with Cucumber from a fresh perspective, once more. To achieve this feeling we are going to apply an interesting little twist: We will code and test in the Rust programming language.

Rust, Ruby. 4 Letters and a capital R. Perfect!

What is Cucumber?

Cucumber is a framework, that implements Behavior Driven Development. The rules of BDD can be summarized as formulizing the requirements step by step in a more and more technical way. We start with the written requirements by your fellow business department and reformulate the requirements into a machine-readable format. Next, we use this text version to write an automated test case, that fails, and implement the feature up until the test passes. This flow gives it the popular resemblance to Test Driven Development. Cucumber leverages BDD by providing the machine- and human-readable layer based on so-called feature files. These use the Gherkin syntax, a simple syntax based on the keywords Given, When, Then, And and But.

Cucumber is still widely used as a test runner, although BDD is rarely actually implemented due to the all-time-popular time limitation in nowaday’s software projects. Another similarity to TDD, that is rather unfortunate.

What is Rust?

Rust is a fairly new and rising system level programming language, that operates in the same markets as C++ and friends. Besides system-level performance, its main focus lies in security and safety being builtin. Furthermore, due to its security and safety-heavy design architecture, it is able to completely omit automated memory management. It just doesn’t need it, while still guaranteeing memory safety.

All of these points are topped off by an exceptional developer experience: The Rust toolchain brings its full-fledged API documentation and its popular text book right to your command line-operating finger tips, and even compiler errors are designed as tiny educational lessons.

Our Test Object: A Simple AES Encryption Tool

I coded my first working Rust app, when I was learning its renowned ownership and borrow model. Usually, when it comes to the First App ™, I tend to write Fibonacci calculators in all kinds of setups: Fibonacci REST APIs, Fibonacci CLI calculators, Fibonacci FFI libs inside a Flutter app… But this time, i wanted something different. Something, that actually does stuff on a level worthy to let it be called a „system application“. So I decided to write a simple AES string encryption tool, that I gave the unspeakably cute name „Encrsypter“. It is based on aes-gcm, an AES encryption library (or „crate“ in Rust terms), that got audited successfully by the nccgroup a few months ago.

The full source code is available in my bitbucket repo. [Update: I’m currently working on an updated post with the new Cucumber-rs version. For this tutorial, please checkout the branch cukes_0.6.0 and, of course, stay tuned for the update.]

For training purposes, I recommend removing the tests/ directory, because we will successively build it up, as we go through the tutorial.

Writing Cucumber-based Tests

Before we add the sources for our test cases, let’s be aware of the test object’s project layout. We will start with the following directories and files:

Cargo.lock, Cargo.toml, src/, constants.rs, decryptor.rs, encryptor.rs and main.rs. No Cucumber tests yet.
encrsypter’s project directory without Cucumber tests

Before we can code the test, we must add a cargo-compatible subproject structure. On your favorite command line, please create the following directories with these terminal commands (all directories relative to the project root):

mkdir tests
mkdir tests/features
mkdir tests/steps

We will create and store our feature file, that specifies the test steps of our Cucumber test, in the features/ subdirectory, whereas the steps‘ implementations will go to steps/. But first of all, we will prepare the central configuration. As described in the official documentation, we create a file called cucumber.rs in tests/ with the following content:

#[path = "../src/encryptor.rs"] mod encryptor;
#[path = "../src/decryptor.rs"] mod decryptor;
#[path = "./steps/encrypt_decrypt_steps.rs"] mod encrypt_decrypt_steps;
use cucumber::cucumber;
use std::borrow::Cow;

pub struct World<'a> {
    encryptor: encryptor::Encryptor<'a>,
    decryptor: decryptor::Decryptor<'a>,
    encrypted_base64: String,
    decrypt_result: String
}

impl cucumber::World for World<'_> {}
impl std::default::Default for World<'_> {
    fn default() -> World<'static> {
        let key = &[1; 32];
        let nonce = &[3; 12];

        World { encryptor: encryptor::Encryptor{ input: Cow::Borrowed(""), key, nonce },
                decryptor: decryptor::Decryptor{ file_path: "./testfile.txt", key, nonce },
                encrypted_base64: "".to_string(),
                decrypt_result: "".to_string()
        }
    }
}

cucumber! {
    features: "./tests/features/", // Path to our feature files
    world: crate::World, // The world needs to be the same for steps and the main cucumber call
    steps: &[
        encrypt_decrypt_steps::steps // the `steps!` macro creates a `steps` function in a module
    ]
}

The World struct contains the mutable instances of our test objects: The encryptor and decryptor, that serve to encrypt and decrypt messages using AES. Further, we will maintain special fields to keep track of their respective outputs. The cucumber! block serves as our entry point, where we perform the basic configuration, that gets our Cucumber test up and running: We…

  • … tell Cucumber where to find feature files.
  • … specify the test’s World struct, that contains our test objects.
  • … declare the module, that contains our step implementations.

The next part of configuration is done in the project’s Cargo.toml. Again according to the official documentation, we should specify dependencies and a test directive like this:

[[test]]
name = "cucumber"
harness = false # Allows Cucumber to print output instead of libtest

[dev-dependencies]
cucumber = { package = "cucumber_rust", version = "^0.6.0" } 
base64 = "0.12.3"

In terms of dependencies, we need the cucumber_rust package to run our tests, then we need the base64 package, because we will work with and do assertions on raw bytes. Although not entirely necessary, it comes in handy for visualisation purposes.

Under [[test]], we give our Cucumber test a name, and we route execution outputs to stdout. We will see its use later, when we finally come to the executing part.

Alright, the config is done. Now we are ready to specify our first test. We will encrypt a small „Hello World!“ message, give it a rough sanity check, and then we decrypt it back and hope, that the decrypted output matches our input. Under ./tests/features, please create the file encryptor.feature. The containing test specification should roughly look like this:

Feature: Encrypt messages and write them to a file.

  Scenario: Encrypt a simple Hello World - message.
    Given I have an encryptor initialized with input "Hello World!"
     Then I should see "Hello World!" in the test encryptors input field
     When I encrypt the Encryptor's input
     Then testfile.txt exists
      And testfile.txt is not empty
     When I decrypt testfile.txt
     Then the decrypted result should be "Hello World!"

This describes, what we want to accomplish; we want to encrypt the string „Hello World!“, check, whether the output is there and whether it is not completely broken. Then we want to decrypt that output back and check, whether the output is the same as our input message. Next, we have to actually automate this test by implementing the Givens, Whens, Thens and Ands.

Step Implementation Files

So far we have told Cucumber, where to find its stuff, and we created a written test specification. Great, we are almost there. The last step is to weave the magic into the Gherkin steps, that do the heavy lifting, when Cucumber reads a step in the current feature file. Lets check out the following example step and see, what that means:

when "I encrypt the Encryptor's input" |world, _step| {
    world.encryptor.write_encrypted();
};

This means whenever the Cucumber engine finds a step, that matches „When I encrypt the Encryptor’s input“ inside the feature file, the code within the closure is executed. Here, we encrypt some random text.

Regular expressions are usable, too:

given regex r#"^I have an encryptor initialized with input "([\w,\s,!]+)"$"# (String) |world, text_to_encrypt, _step| {
        // the # are necessary to prevent the inner quotations marks as part of the String
        world.encryptor.input = Cow::Owned(text_to_encrypt);
    };

This step defines the text, that we want to encrypt using the When step from above. Here, the text is derived from the feature file by matching the regular expression in r# and the enclosing capture group ([\w,\s,!]+). The value, that was read by the capture group, goes to the closure parameter after world, in this case text_to_encrypt. Note that the „r“ in r# stands for „raw string“ instead of „regular expression“. Raw strings are a means to spare us from copious amounts of escape slashes within the regular expression string; otherwise, they are regular strings. I won’t go into too much detail here. If you want to learn more about them, check out this post about raw string literals.

By using the regular expression above, we could have written the steps in our feature file like the following:

Given I have an encryptor initialized with input "Hi I am Floh"
=> encryptor input is "Hi I am Floh"

Given I have an encryptor initialized with input "99 bottles of beer on the wall…"
=> encryptor input is "99 bottles of beer on the wall…"

Given I have an encryptor initialized with input "Your ad here"
=> encryptor input is "Your ad here"

Putting all the knowledge together, here is the sample implementation for our test steps. Please put it into ./tests/steps/encrypt_decrypt_steps.rs (related to the project root).

use cucumber::steps;
use std::fs;
use std::path::Path;
use std::borrow::Cow;

steps!(crate::World<'static> => {
    given regex r#"^I have an encryptor initialized with input "([\w,\s,!]+)"$"# (String) |world, text_to_encrypt, _step| {
        // the # are necessary to prevent the inner quotations marks as part of the String
        world.encryptor.input = Cow::Owned(text_to_encrypt);
    };

    then regex r#"^I should see "([\w,\s,!]+)" in the test encryptors input field"# (String) |world, expected_text, _step| {
        assert_eq!(expected_text, world.encryptor.input);
    };

    when "I encrypt the Encryptor's input" |world, _step| {
        world.encryptor.write_encrypted();
    };

    then "testfile.txt exists" |_world, _step| {
       let testfile_path = Path::new("./testfile.txt");
       assert_eq!(testfile_path.exists(), true);
    };

    then "testfile.txt is not empty" |world, _step| {
        let enc_message = fs::read("./testfile.txt").expect("Could not read test file.");
        world.encrypted_base64 = base64::encode(&enc_message);

        assert_eq!(world.encrypted_base64.len() > (0 as usize), true);
    };

    when "I decrypt testfile.txt" |world, _step| {
        world.decrypt_result = world.decryptor.read_decrypted();
    };

    then regex r#"^the decrypted result should be "([\w,\s,!]+)"$"# (String) |world, expected_text, _step| {
        assert_eq!(expected_text, world.decrypt_result);
    };
});

Now we are ready for the first test run. Please execute the following in your favorite terminal:

cargo test --test cucumber

If all goes well, it shows us a positive test result:

Positive result of our Cucumber test. 1 fearture with 1 scenario containing 7 steps, all green and checkmarked in the terminal.
The test passed. Yay!

Conclusion: Cucumber in Different Languages

This is by no means the end of Cucumber’s options and possibilities. There are many many many more well maintained ports for many different platforms out there. Not all of them may be offcial, e.g. the Rust port we used today, but they are nonetheless maintained and fully functional. And they contributes to its well deserved popularity as well as the official ports. This is what counts in the end.

For Cucumber-rs it’s not the end of possibilities, too, as version 0.7 has been released recently. It brings asynchronous test support and a new builder-based approach to the table. I’m hyped to try it out, especially because I love asynchronous coding (Please don’t judge me..)

But for now, this is a good starting point to read more about other facettes of test automation. For example, you can learn how to set up a Zalenium cluster for distributed browser UI testing. It is well-suited to be combined with Cucumber. If you’d rather learn more about unconventional and unstructured automation testing, you might like my article about fuzzing in Java. Also, since I make heavy use of Rust Modules, I have written a quick tutorial about how to use them without hassle.

Have a great day!

EDIT Nov. 2021: A lot of things have been worked on in Cucumber Rust, so I compiled a comprehensive summary about the most crucial changes. Hope you enjoy it!

And btw, if you are interested in test automation, but Rust is not particularly your cup of tea: I’ve compiled an entry-level TA tutorial with Python. Check it out!

Home » Rust
Share it with: