Or press ESC to close.

Writing Your Own BDD Framework in JavaScript

May 28th 2023 15 min read
medium
nodejs18.14.0
javascriptES6
api

Have you heard about the recent news surrounding the Cucumber BDD framework and its co-founder getting laid off? If you're a software developer who has used Cucumber in the past or is currently using it, this news might have left you wondering about the future of the tool. While it remains to be seen what will happen with Cucumber, we want to show you that it's possible to build your own BDD framework in JavaScript from scratch.

Not only will this give you complete control over the tool and allow you to adjust it to your liking, but it will also help you understand how BDD frameworks work under the hood. In this comprehensive guide, we'll cover everything from the basics of BDD to setting up the framework, writing feature files, defining step definitions, and running tests.

So if you're ready to take control of your BDD testing process and build a tool that meets your specific needs, then let's dive in and get started!

Wait, what is Cucumber?

Cucumber is a testing tool used for behavior-driven development (BDD) in software development. It is an open-source tool that is written in the Ruby programming language but supports many other programming languages such as Java, Python, and .NET.

The purpose of Cucumber is to allow software development teams to define and automate acceptance tests that can be executed by both technical and non-technical stakeholders. It uses a natural language syntax known as Gherkin, which is used to define the steps of a scenario in a human-readable format.

Here's how Cucumber works:

Overall, Cucumber is a useful tool for teams practicing BDD, as it helps ensure that software meets the desired behavior and functionality while allowing for collaboration and communication between technical and non-technical stakeholders.

How can we make it from scratch?

To make something like this from scratch, we will follow these five bullet points that we will break down into more detailed steps:

Now, let's dive right in without any additional delay.

Defining the syntax

When defining a Gherkin-based syntax for our feature files, we need to choose a set of keywords that are appropriate for our specific application domain. These keywords should be easy to read and understand by both technical and non-technical stakeholders.

Overall structure

In order to ensure that the post does not become too lengthy, we will be utilizing a simplified version for the overall structure. This simplified structure will consist of only one feature, which will include scenarios. Within these scenarios, we will define three main actions: the initial setup, the actions taken during the scenario, and the actions that describe the expected outcome.

Defining the keywords

As for an alternative to using "Given/When/Then" as our keywords, one option is to use "Arrange/Act/Assert" (AAA) instead. The AAA keywords have the same basic meaning as GWT but are more generic and can be used in a wider variety of contexts. Here's an example of how AAA syntax could be used:

Instead of the "feature" keyword, we will use "capability" and "task" instead of "scenario";

Using the keywords

To test our scenario, we will use a public API that allows us to book hotel rooms.

To test out our framework, we are going to write two tasks, one for booking a new room and one for removing it. For these actions our file could have the following structure:

                                     
Capability: BookerAPI
Task: Book a new room
    Arrange Ensure that a twin room has not been booked
    Act Try to book a twin room
    Assert Verify that the room was booked successfully
                        
Task: Verify that a booked room can be removed
    Arrange Ensure that a booked room exists
    Act Try to remove the booked room
    Assert Verify that the booked room has been removed
                    

The file will be regarded as a basic text file, allowing us to append a text file extension to it, such as ".txt," or alternatively, utilize a custom extension like ".bdd."

Creating a parser

Now that we have our scenarios defined, it's time to write the parse.

To simplify the process, we will divide the parser into two methods. One method will handle the parsing of the input data, which refers to the feature file. The other method will be responsible for executing the steps.

Firstly, the input string is divided into an array of lines by using the newline character. Each line is subsequently trimmed to eliminate leading or trailing whitespace, and empty lines are removed. This process yields an array consisting of trimmed lines that are not empty:

                                     
async function parseCapability(text) {
    const lines = text
        .split("\n")
        .map((line) => line.trim())
        .filter((line) => line !== "");
}
                    

Next, the function counts the number of lines that commence with the string "Capability:" by utilizing the reduce method. This calculation establishes the total number of capabilities defined in the file:

                                     
const capabilityCount = lines.reduce((count, line) => {
    if (line.startsWith("Capability:")) {
        return count + 1;
    }
    return count;
}, 0);
                    

If the number of capabilities is greater than one (indicated by capabilityCount > 1), an error message is logged to the console. However, if there is only one capability, the function proceeds to process the tasks outlined in the file:

                                     
if (capabilityCount > 1) {
    console.error("Error: Multiple capabilities found in a single BDD file!");
} else {
    //process the tasks
}
                    

In the task processing section, the function initializes an empty array called tasks to store the tasks. It also initializes an empty array called currentTask to store the lines of the current task being processed.

A loop iterates over each line in the lines array, beginning from index 1 (thus skipping the capability line). If a line commences with the string "Task", it indicates the commencement of a new task. The current task (if any) is appended to the tasks array, and the currentTask is then reset to an empty array.

Each line is appended to the currentTask array. Once the loop concludes, the final currentTask (if it exists) is included in the tasks array:

                                     
const tasks = [];
let currentTask = [];
                    
for (let i = 1; i < lines.length; i++) {
    const line = lines[i];
    if (line.startsWith("Task")) {
    if (currentTask.length > 0) {
        tasks.push(currentTask);
    }
    currentTask = [];
    }
    currentTask.push(line);
}

if (currentTask.length > 0) {
    tasks.push(currentTask);
  }
                    

Within the next iteration, the code traverses through each task in the tasks array. Initially, the name of the task is extracted from the first line of the task and displayed in the console for logging purposes. Subsequently, the executeTask function is invoked, passing the current task as an argument. It is presumed that executeTask is an asynchronous function.

Once the task execution concludes, a notification denoting the completion of the task is logged to the console. This sequence is repeated for each task within the tasks array, ensuring comprehensive task processing:

                                     
for (let i = 0; i < tasks.length; i++) {
    console.log(`Executing task: ${tasks[i][0].replace("Task: ", "")}`);
    await executeTask(tasks[i]);
    console.log("Task execution completed.\n");
}
                    

Now the executeTask function starts a loop using a for statement, iterating over each element of the task array, starting from index 1 (since it skips the first element, which is the task name):

                                     
async function executeTask(task) {
    for (let i = 1; i < task.length; i++) {
        //execution logic
    }
}
                    

Within each iteration, the function splits the current task element into an array called step by using the space character as the separator. The first element of the step array (stepKeyword) represents the keyword for the step, while the remaining elements are joined using the space character to form stepDescription, which represents the description of the step:

                                     
const step = task[i].split(" ");
const stepKeyword = step[0];
const stepDescription = step.slice(1).join(" ");
                    

The function checks if there is an implementation-defined for the current stepKeyword and stepDescription combination in the stepDefinitions object. It first checks if stepDefinitions[stepKeyword] exists, and if it does, it checks if stepDefinitions[stepKeyword][stepDescription] exists.

If an implementation is found, it logs a message indicating the step number and the current task element. It then proceeds to execute the implementation by calling stepDefinitions[stepKeyword][stepDescription](), which is assumed to be an asynchronous function (since it is awaited).

If no implementation is found, it logs a message indicating the step number and that no implementation was found for the current task element.

                                     
if (
    stepDefinitions[stepKeyword] &&
    stepDefinitions[stepKeyword][stepDescription]
    ) {
    console.log(`Step ${i}: ${task[i]}`);
    await stepDefinitions[stepKeyword][stepDescription](); // Await the execution of the step
    } else {
    console.log(`Step ${i}: No implementation found for ${task[i]}`);
}
                    

The loop continues to the next element of the task array until all elements have been processed.

Defining our step definitions

Writing the step definitions is a relatively straightforward process. It involves defining an object that encompasses three properties, namely Arrange, Act, and Assert. Each of these properties corresponds to a specific step keyword.

Each step keyword has an associated value, which is an object containing different step descriptions as properties. Each step description is a string key, and its corresponding value is an arrow function that represents the implementation for that particular step.

The implementations for each step are represented as empty arrow functions (() => { ... }). These functions can be replaced with the actual code logic for each step:

                                     
const stepDefinitions = {
    Arrange: {
        "Ensure that a twin room has not been booked": () => {
        // Implementation for Arrange step of the first task
        },
        // Implementations for other Arrange steps
    },
    Act: {
        "Try to book a twin room": () => {
        // Implementation for Act step of the first task
        },
        // Implementations for other Act steps
    },
    Assert: {
        "Verify that the room was booked successfully": () => {
        // Implementation for Assert step of the first task
        },
        // Implementations for other Assert steps
    },
    };
                          
module.exports = stepDefinitions;
                    

To ensure that everything works as expected, we can utilize simple console outputs for implementing the steps. In our example, we have actually written all the steps using the Axios client. However, since this blog is not focused on Axios, we will not delve into its details. As always, you will find the complete code examples available on our GitHub repository.

Gluing everything together

The only piece left is the runner or the so-called glue file. The way we are going to glue things together is with the help of the fs module.

The readFileSync function is used to read the contents of a file synchronously. It reads the file named "bookerApi.bdd" using the readFileSync method with the specified encoding ("utf8") and assigns the file content to the bddFile variable.

At the end, the parseCapability function is called with bddFile as an argument:

                                     
const fs = require("fs");
const { parseCapability } = require("./parser.js");
                        
const bddFile = fs.readFileSync("bookerApi.bdd", "utf8");
parseCapability(bddFile);
                    

Now when we execute our runner.js file, we get an output like this:

Executing task: Book a new room
Step 1: Arrange Ensure that a twin room has not been booked
Step 2: Act Try to book a twin room
Step 3: Assert Verify that the room was booked successfully
Task execution completed.

Executing task: Verify that a booked room can be removed
Step 1: Arrange Ensure that a booked room exists
Step 2: Act Try to remove the booked room
Step 3: Assert Verify that the booked room has been removed
Task execution completed.

Since this demo project only contained one step definition file, we imported it in the parser, but in case we have multiple ones, we could just let the executeTask method accept the step definition as a parameter.

Next steps

To be honest, we have only touched the surface in this blog post. A comprehensive BDD framework offers a plethora of additional features, but what we have covered so far serves as a solid foundation. If you wish to further enhance the current project, here are some suggested next steps to consider:

We will show how to implement some of these features in the upcoming blog posts. In the meantime, feel free to expand the project to your liking. It's available on our GitHub repository, as always. Bye 🙂