Cucumber in Rust with Thirtyfour (Selenium) and Allure

Daniel Delimata
11 min readJul 18, 2023

Welcome to my course of Cucumber framework for beginners. In this post we will go through setting up the project in Rust. The project will be based on Selenium and the results will be reported by Allure reporting framework.

If you need some more general information about Cucumber and Gherkin please read the introduction.

In my series, I present how to efficiently organize code in various languages. When working with BDD, it is important to remember that coding is the final step. One should never start with coding. BDD begins with a discussion about functionalities, use cases, and examples. After this discussion, a feature description in terms of Given-When-Then is created. Such a description should not focus on webpages. Please see my another post on this topic.

RUST AS A PROGRAMMING LANGUAGE

Rust is a systems programming language known for its focus on safety, performance, and concurrency. Developed by Mozilla, Rust aims to provide developers with a language that eliminates common programming errors, such as null pointer dereferences and data races, through its strong type system and ownership model. It allows low-level control over system resources while enforcing strict memory safety guarantees. Rust’s borrow checker ensures that references to data are managed properly, preventing data races and concurrency issues. Due to its versatility, Rust is used in a wide range of applications, from web development to operating systems and embedded systems.

WHY RUST DESERVES ATTENTION?

Rust is designed to be very efficient in performance. After years of the paradigm “If something works then do not fix it. If it works slowly, then buy better hardware,” we finally get something that prioritizes performance.

For years, optimizations for performance were taken into account only in large- scale numerical methods. It is not surprising at all. For other types of software the more valuable than minimal benefits in speed was maintainability of the code, was work time of humans. The price of computing power was plummeting, and the cost of work of humans was growing.

The year 2023 is commonly considered as the year of great hype for artificial intelligence. We can expect growing popularity of LLM and fine-tuning. Such activities require a lot of computation, and this makes optimization techniques interesting again for the programmer community.

Rust is a perfect tool here because it allows for both low-level optimizations and is also safer than C or C++. Currently, Rust is an official language for Linux kernel development. C++ has never achieved such a status. We can expect that tools for fine-tuning will be more often coded in Rust.

HOW RUST DIFFERS FROM OTHER LANGUAGES?

If you have experience in other programming languages only, you may be surprised by Rust. Things that were very simple in other languages may appear rather difficult in Rust. For example, there are no simple arithmetic operations on mixed types of numbers. If you have an integer and a float, then you have to cast something before the operation. Another example is the lifetime of objects. In Rust, there is no garbage collector nor destructors in the form known from C++.

In Rust, the lifetime of objects is managed through a concept called “ownership.” The key principles of the ownership model are:

  • Ownership Acquisition: When an object is created, it becomes the owner of the memory it occupies.
  • Scope: Objects have a limited scope, meaning they are valid only within the block of code they are defined in.
  • Ownership Transfer: Ownership can be transferred from one variable to another, effectively moving the object’s ownership.
  • Borrowing: Objects can be borrowed to access their data without transferring ownership, either as immutable or mutable references.
  • Drop: When an object goes out of scope, the Rust compiler automatically calls the drop function to release the memory associated with the object.

By following these rules, Rust ensures memory safety and eliminates common issues like dangling pointers and memory leaks.

WHAT DO WE WANT TO TEST?

Our test page is pretty simple (the same for the whole course). It contains several typical elements of UI forms:

  • text field (input),
  • dropdown,
  • checkbox,
  • table with dynamic content,
  • text element with dynamic content.

The URL address of this page is: https://danieldelimata.github.io/sample-page/.

IDE AND OTHER NECESSARY TOOLS

You can install Rust by executing the following command.

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

To edit Rust code, I recommend Visual Studio Code (shortly vscode), but IntelliJ or CLion are also good options. You can download vscode from https://code.visualstudio.com for your operating system.

The following vscode extensions are useful in Rust code editing.

  • rust-analyzer (rust-lang.rust-analyzer)
  • crates (serayuzgur.crates)
  • Better TOML (bungcip.better-toml)
  • Cargo.toml Snippets (kevinkassimo.cargo-toml-snippets)
  • CodeLLDB (vadimcn.vscode-lldb)

Make sure that you have Java installed. It will be needed to run the Selenium server.

Visual Studio Code with Rust project

Once you have prepared the IDE, you can create a new Rust project.

SETTING UP THE PROJECT

Execute the following command.

cargo new my_rust_cucumber_project

This command will create a new project. Let us look at it. On the root of the project, you can find the file Cargo.toml. This file contains a list of the project dependencies along with their versions. This allows for easy updating of all required packages and tools. Let us add something here. Execute:

cargo add cucumber
cargo add thirtyfour
cargo add async-trait
cargo add tokio

After these commands, the file should be updated, and 4 dependencies should appear there. We need several specific features that are not enabled by default, so let us edit these entries.

Thirtyfour is a name of the Selenium in Rust world. Why? Because Selenium in the 34th element in the periodic table of the elements.

We need also add a section responsible for running our tests. Here we specify the name of the source code file witch will be responsible for running our tests.

Cargo.toml:

[package]
name = "my_rust_cucumber_project"
version = "0.1.0"
edition = "2021"

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

[dependencies]
cucumber = { version = "0.20.0", features = ["output-junit"] }
thirtyfour = { version = "0.31.0", features = ["component"] }
async-trait = "0.1"
tokio = "1.16.1"

[[test]]
name = "test_runner" # this should be the same as the filename of your test target
harness = false # allows Cucumber to print output instead of libtest

Cargo is not only the package manager. We can also run our tests by executing the command cargo test. If we want just to build the project, we execute cargo build. There are also other useful commands: cargo run for running non-test projects, cargo doc to build the package documentation, cargo check to check a local package and all of its dependencies for errors, and cargo clippy (installed separately) is a linter for Rust.

SETTING UP THE STRUCTURE OF CODEBASE

We want to achieve the following structure. It needs however some explanation.

my-rust-cucumber-project
├── Cargo.toml
├─┬ src
│ ├── lib.rs
│ └── customerspage.rs
└─┬ tests
├─┬ features
│ └── f01.feature
├─┬ stepdefinitions
│ ├── mod.rs
│ └── stepdefinitions1.rs
└── test_runner.rs

The Rust’s module system is quite unique and can be confusing for people which have experience with other languages only. The tree of modules it is not the same as the tree of code source files. There is no implicit mapping, so we need to explicitly build the module tree.

Rust’s module system organizes code into manageable and reusable units called modules. Each module serves as a boundary for encapsulating related functions, structs, enums, and other items.

Key points about Rust’s module system:

  • Module Declarations: Modules and submodules are declared using the mod keyword followed by the module's name. Module files are usually organized into a directory structure that reflects the module hierarchy. The syntax is as follows.
mod my_module;
  • This informs compiler to look for my_module.rs or my_module/mod.rs in the same directory.
  • Visibility: By default, items in a module are private and can only be accessed within that module. To make an item accessible from outside the module, the pub keyword is used to declare it as public.
  • Path Resolution: To access items from other modules, you use paths. Paths can be either absolute or relative, specifying the module hierarchy to reach the desired item.
  • Bringing Items into Scope: The use keyword allows you to bring items from a module into the current scope, making them accessible without using fully qualified names. Both paths and fully qualified names use :: as hierarchical separator.
  • The crate Module: The top-level module in a Rust program is known as the crate module. It represents the root of the module hierarchy and serves as the entry point of the program.

Knowing this we can create the file stepdefinitions/mod.rs.

stepdefinitions/mod.rs:

mod stepdefinitions1;

In this way we can have several files with step definitions in stepdefinitions directory.

The file customerspage.rs is in the same directory as lib.rs, so it is enough to place pub mod customerspage; inside.

lib.rs:

pub mod customerspage;

First, of course, we will place feature files.

features/f01.feature:

Feature: F01 Searching - Clearing of searching criteria

User story:
* As a user of Customers page
* I want to be able to clear searching criteria
* in order to quickly type new criteria

Acceptance criteria:
* After clearing, search criteria and summary should be as in the very beginning.

Scenario Outline: Clearing of searching criteria
Given the user is on the page
When the user enters the value "<Search>" in the text-input
And the user selects value "<Column>" in the drop-down
And the user sets case sensitivity switch to "<Case>"
And the user clears filters
Then the user should see that search criteria are cleared
And the user should see that the search result summary is as in the very beginning

Examples:
| Search | Column | Case |
| Alice | Email | True |
| alice | Email | False |

To be able to run our test we have to crate a runner. Let us create the following files in proper location (as presented on the schema above). Pay attention that the filename of runner (here test_runner.rs) must match with the entry in Cargo.toml.

test_runner.rs

use cucumber::{writer, World};
use std::fs;
use thirtyfour::{DesiredCapabilities, WebDriver};

mod stepdefinitions;

#[derive(cucumber::World)]
#[world(init = Self::new)]
pub(crate) struct Context {
driver: WebDriver,
search_summary_at_very_beginning: String,
}

impl Context {
async fn new() -> Self {
let mut capabilities: thirtyfour::ChromeCapabilities = DesiredCapabilities::chrome();
let _ = capabilities.add_chrome_arg("--headless");
let _ = capabilities.add_chrome_arg("--start-maximized");
let driver: WebDriver = WebDriver::new("http://localhost:4444", capabilities)
.await
.unwrap();
Self {
driver,
search_summary_at_very_beginning: "".to_string(),
}
}
}

impl std::fmt::Debug for Context {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.debug_struct("Context")
.field("driver", &self.driver.session_id)
.field(
"search_summary_at_very_beginning",
&self.search_summary_at_very_beginning,
)
.finish()
}
}

#[tokio::main]
async fn main() {
let file = fs::File::create(dbg!("target/junit.xml")).unwrap();
Context::cucumber()
.with_writer(writer::JUnit::new(file, 1))
.fail_on_skipped()
.run("tests/features")
.await;
}

In the code above Context is a World object. It lives on per-scenario basis and is common to all test steps, so can be used to pass some values between them.

The trait Debug is not implemented by default for our World, so we have to implement it by ourselves.

There is no specific Allure library for Rust. We can however achieve Allure reporting using JUnit XML file.

Contrary to Selenium in Java, C#, Python and many other languages Thirtyfour do not launch chromedriver by itself. It requires running Selenium Server. This is not a big inconvenience. So before running your test you need to have Java on board, download the server and execute the following command

java -jar selenium-server-4.10.0.jar standalone

This will launch a server which will listen requests coming from your tests.

Now let’s prepare Page Object Model.

customerspage.rs

use core::time;
use std::thread::sleep;
use thirtyfour::{
components::{Component, ElementResolver, SelectElement},
prelude::*,
resolve,
};

#[derive(Component, Clone)]
pub struct CustomersPage {
pub(crate) base: WebElement,

#[by(id = "clear-button")]
pub(crate) clear_button: ElementResolver<WebElement>,

#[by(id = "search-input")]
pub(crate) search_input: ElementResolver<WebElement>,

#[by(id = "search-column")]
pub(crate) drop_down: ElementResolver<WebElement>,

#[by(id = "match-case")]
pub(crate) match_case: ElementResolver<WebElement>,

#[by(id = "table-resume")]
pub(crate) summary: ElementResolver<WebElement>,
}

impl CustomersPage {
pub async fn from_driver_ref(driver_ref: &WebDriver) -> WebDriverResult<CustomersPage> {
let base_element = driver_ref.query(By::XPath("//html")).single().await?;
let customers_page: CustomersPage = base_element.into();
Ok(customers_page)
}

pub async fn open(self, driver_ref: &WebDriver) -> WebDriverResult<CustomersPage> {
driver_ref
.goto("https://danieldelimata.github.io/sample-page/")
.await?;
Ok(self)
}

pub async fn set_search_input(self, input: &str) -> WebDriverResult<CustomersPage> {
let search_input_element = resolve!(self.search_input);
search_input_element.send_keys(input).await?;
Ok(self)
}

pub async fn set_search_column_drop_down_list_field(
self,
value: &str,
) -> WebDriverResult<CustomersPage> {
let dropdown_element = resolve!(self.drop_down);
SelectElement::new(&dropdown_element)
.await?
.select_by_visible_text(value)
.await?;
Ok(self)
}

pub async fn set_match_case_checkbox_field(
self,
value: &str,
) -> WebDriverResult<CustomersPage> {
let case_checkbox_element = resolve!(self.match_case);
if case_checkbox_element.is_selected().await?.to_string() != value {
case_checkbox_element.click().await?;
}
Ok(self)
}

pub async fn clear_button_click(self) -> WebDriverResult<CustomersPage> {
let clear_button_element = resolve!(self.clear_button);
clear_button_element.click().await?;
Ok(self)
}

pub async fn get_summary_text(self) -> WebDriverResult<String> {
let search_summary_element = resolve!(self.summary);
let result = search_summary_element.text().await?;
Ok(result)
}

pub async fn get_search_input_text(self) -> WebDriverResult<String> {
sleep(time::Duration::from_secs(1));
let input_text_element = resolve!(self.search_input);
let result = input_text_element.text().await?;
Ok(result)
}
}

Having Page Objects we can finally use them in the glue.

stepdefinitions1.rs

use cucumber::{given, then, when};
use thirtyfour::prelude::WebDriverResult;
use my_rust_cucumber_project::customerspage::CustomersPage;

use super::super::Context;

#[given("the user is on the page")]
pub(crate) async fn the_user_is_on_the_page(context: &mut Context) -> WebDriverResult<()> {
context
.driver
.goto("https://danieldelimata.github.io/sample-page/")
.await?;

let customers_page = CustomersPage::from_driver_ref(&context.driver).await?;
context.search_summary_at_very_beginning = customers_page.get_summary_text().await?;
Ok(())
}

#[when(expr = "the user enters the value {string} in the text-input")]
pub(crate) async fn the_user_enters_the_value_in_the_text_input(
context: &mut Context,
value: String,
) -> WebDriverResult<()> {
CustomersPage::from_driver_ref(&context.driver)
.await?
.set_search_input(&value)
.await?;
Ok(())
}

#[when(expr = "the user selects value {string} in the drop-down")]
pub(crate) async fn the_user_selects_value_in_the_drop_down(
context: &mut Context,
value: String,
) -> WebDriverResult<()> {
CustomersPage::from_driver_ref(&context.driver)
.await?
.set_search_column_drop_down_list_field(&value)
.await?;
Ok(())
}

#[when(expr = "the user sets case sensitivity switch to {string}")]
pub(crate) async fn the_user_sets_case_sensitivity_switch_to(
context: &mut Context,
value: String,
) -> WebDriverResult<()> {
CustomersPage::from_driver_ref(&context.driver)
.await?
.set_match_case_checkbox_field(&value)
.await?;
Ok(())
}

#[then(expr = "the user should see the following result summary {string}")]
pub(crate) async fn the_user_should_see_the_following_result_summary(
context: &mut Context,
value: String,
) -> WebDriverResult<()> {
assert_eq!(
CustomersPage::from_driver_ref(&context.driver)
.await?
.get_summary_text()
.await?,
value
);
Ok(())
}

#[when(expr = "the user clears filters")]
pub(crate) async fn the_user_clears_filters(context: &mut Context) -> WebDriverResult<()> {
CustomersPage::from_driver_ref(&context.driver)
.await?
.clear_button_click()
.await?;
Ok(())
}

#[then(expr = "the user should see that search criteria are cleared")]
pub(crate) async fn the_user_should_see_that_search_criteria_are_cleared(
context: &mut Context,
) -> WebDriverResult<()> {
assert_eq!(
CustomersPage::from_driver_ref(&context.driver)
.await?
.get_search_input_text()
.await?,
""
);
Ok(())
}

#[then(expr = "the user should see that the search result summary is as in the very beginning")]
pub(crate) async fn the_user_should_see_that_the_search_result_summary_is_as_in_the_very_beginning(
context: &mut Context,
) -> WebDriverResult<()> {
assert_eq!(
CustomersPage::from_driver_ref(&context.driver)
.await?
.get_summary_text()
.await?,
context.search_summary_at_very_beginning
);
Ok(())
}

Everything together we can run with the cargo test command (or cargo test --test mytest if there are several test definitions in Cargo.toml).

Our results are stored in the directory target. To visualize them we can execute:

allure serve target

The results should open in the default web browser.

Allure report

Thirtyfour offers interesting “components” mechanism, and there is also oxidize library, which provides more sophisticated assertions with fluent interface. I humble encourage the reader to own explorations of the Rust world.

REPOSITORY

If you are interested you can see everything together in the following repository:

The story was originally created by me, but it may contain parts that were created with AI assistance. My original text has been corrected and partially rephrased by Chat Generative Pre-trained Transformer to improve the language.

If you like this article and would be interested to read the next ones, the best way to support my work is to become a Medium member using this link:

If you are already a member and want to support this work, just follow me on Medium.

--

--