This article was last updated on September 24, 2024 to include advanced TDD and BDD techniques, common pitfalls, and how to avoid them.
Introduction
Software testing is critical in the software development cycle and ensures that the developed products are reliable and of high quality. The quality of the application is vital for providing a satisfying user experience. Paying attention to the development methodology used in writing tests in an application is also important.
Test-Driven Development (TDD) and Behavior Driven Development (BDD) are two popular and effective methodologies developers use to write quality tests that benefit developers, users, product managers and stakeholders.
In this article, you’ll learn about Test-Driven Development (TDD) and Behavior Driven Development (BDD), including what they entail, their principles, advantages, disadvantages, how they work and their key differences.
Steps we'll cover:
- Overview of Test-Driven Development
- Step-by-step demo example of TDD implementation
- Overview of Behavior-Driven Development
- Step-by-step demo example of BDD implementation
- When to use BDD (Behavior-Driven Development):
- When to use TDD (Test-Driven Development):
- Common Pitfalls and How I Avoid Them in TDD
- Common Pitfalls and How I Avoid Them in BDD
- Comparison of TDD and BDD
- Bonus: Advanced TDD and BDD Techniques
Overview of Test-Driven Development
TDD is a repetitive and continuous process based on agile development methodology that involves creating test cases at each stage of developing an application to define the expected code behavior.
In TDD, developers first create a unit test case to showcase the desired behavior of the code before actually implementing it. If the test fails, they iteratively write new code until it successfully passes. Afterwards, they proceed to refactor the application's source code, which involves restructuring the code without introducing new features or compromising the original functionality of the application.
To implement TDD effectively, the process entails breaking down the application's functions and generating tests for each aspect. This approach ensures systematic and thorough testing and monitoring of the components.
A good example of TDD can be seen in building an Authentication system in an application.
According to the illustration above, the developer begins by identifying and defining the authentication system's requirements, including authentication methods such as OAuth, username, password, etc. The developer then writes a test that defines the expected behavior for one of the authentication system's components, such as the login functionality.
After that, the developer would run the tests, which would initially fail because the functionality had not yet been implemented. The developer then writes the code necessary to pass the test. The tests are then re-run, and the code is refactored. After refactoring, the tests are rerun to ensure they continue to pass.
After the login functionality has been validated, additional test cases for other functions, such as account verification, registration, and password reset, are created, and the TDD process is repeated.
Pros and cons of TDD
TDD offer several benefits. Still, it also has some drawbacks, as seen below:
Faster Development Cycle: TDD allows for the continuous delivery of software updates, and its architecture enables developers to quickly identify and fix bugs in their code. The rapid integration of updates promotes faster development and the delivery of high-quality software.
Improved Code quality: Writing test cases before writing the code enables developers to understand the desired functionality better and write well-structured code. Also, using the TDD approach makes it easier to refractor the section of the code and make it less buggy without affecting the existing functionality.
Time-consuming: TDD requires more time and effort in writing test cases before implementing the functionality, which may slow down the development process for projects with limited resources and short deadlines.
Rigid: The TDD approach of writing tests before implementing code is rigid because it is unsuitable for complex projects with constantly changing requirements.
Step-by-step demo example of TDD implementation
Let's see how TDD works in practice by building a simple app.
Prerequisites
To follow along with the tutorial, ensure you have the following:
- Nodejs installed
- Basic knowledge of JavaScript
- Terminal
- Code editor (Visual Studio Code editor)
To begin, create the project directory on your system by running this command in your terminal:
mkdir tdd-project
Next, change into the directory by running this command:
cd tdd-project
Open the project in your code editor, and in your project’s directory, run the following command to initialize a new Node.js project:
npm init
Next, you need to install a testing framework that will be used for performing unit testing in your project. Several testing frameworks are available depending on the programming language used to create an application. For example, JUnit is commonly used for Java apps, pytest for Python apps, NUnit for .NET apps, Jest for JavaScript apps, and so on. We’ll use the Jest framework for this tutorial since we are using JavaScript.
To install the Jest testing framework as a dev dependency in your project's directory, simply run the following command:
npm install jest --save-dev
Once the installation is successful, jest will be installed and added to your package.json file. Replace your test script with this:
"test": "jest"
Your package.json file should look like this:
Using the TDD approach to build your application demands that you start by writing the tests. Create a file called sub.test.js in the root directory of the tdd-project that will contain the tests. Jest uses a .test.js naming convention for files, so ensure your file has that extension.
Now, you can start writing your tests. Let’s say you want to create a small calculator app, and the first functionality you’d like to implement is the subtraction function. The Jest framework has its unique way of writing tests as defined in the documentation.
Jest uses a test()
function, which accepts a description as the first argument where you can describe the behavior you want to test and a callback where you can use an expect()
function and a toBe()
matcher that lets you define the expected behavior of your code and check if the behavior matches those expectations.
Let’s see the test()
function in practice. In your sub.test.js file, add the following test that will define the behavior of subtracting values with the subtract method that you will define later in the code:
const Calc = require("./calc");
test("subtraction", () => {
const calc = new Calc();
const sub = calc.subtract(20, 10);
expect(sub).toBe(10);
});
Next, we’ll try running the test, which will fail because you haven’t written the functionality yet. However, this is an essential step as the test failing shows that the test is testing the behavior.
Run the test with this command:
npm test
After running that command, the following will be displayed in your terminal:
Next, let’s write the code that implements the functionality. In your project’s root directory, create a file called calc.js and add the following code:
class Calc {
subtract(x, y) {
return x - y;
}
}
module.exports = Calc;
Here, we are creating a class called Calc
and adding a subtract()
method for the values we defined in the test case. Then we are exporting the class to use it outside of this module.
Now, you can rerun the test with this command:
npm test
If you implemented the subtract method correctly, then the test should pass as shown below:
You have successfully written your first test case for one functionality in your application. If your test case fails, you can correct and refactor your code. Then, you can write and run more test cases for other functionalities like sum, average, division and more.
Next, we’ll look at Behavior-Driven Development (BDD)
Overview of Behavior-Driven Development
BDD is another agile-based development process for creating tests that describe an application's expected behavior based on users’ expectations. Compared to TDD, BDD focuses on meeting business needs and user requirements rather than simply passing tests.
With BDD, developers can create products focused on meeting users' needs based on their interactions with the product. The BDD approach encourages collaboration between product managers (usually in charge of defining the product's requirements), developers and testers.
In BDD, developers can use testing tools such as Cucumber, SpecFlow, Behave, and others to plan and write tests in a language known as Gherkin, which helps define the product's business requirements or specifications in a structured format using keywords in human-readable syntax.
See an illustration of the BDD workflow below:
The BDD workflow, as illustrated above, consists of several stages, which are explained below:
Identifying User Features: This is the first stage in BDD where the features that need to be developed are identified. The features are described here based on the users' expectations.
Create Feature files: This stage entails creating files to document the application's features in a structured format that developers, product teams, and testers can understand using the Gherkin language.
Writing Scenarios: At this stage, test cases are defined in feature files with examples describing the expected behavior of the feature. The Gherkin language has a syntax for defining test cases.
Team Assessment:This is the stage at which the developers, product team, and testers collaborate to evaluate the feature files and scenarios created and defined in previous stages. The evaluation is performed to ensure that the defined scenarios align with the business requirements and the expectations of the users.
Writing Step Implementations: At this point, the implementation of the scenarios described in the Gherkin language begins. Developers write code in a specific programming language (e.g., JavaScript or Java) that depends on the BDD framework to map each step in the scenario to the corresponding actions that must be executed.
Test Automation: After the steps defined in the scenarios are implemented, automated tests are written to run the scenarios by simulating user interactions with the application and determining whether the behavior matches the specifications in the scenarios.
Test Validation and Reporting: At this stage, the automated tests are run, and the outcome of the scenarios (whether fail or pass) are recorded for the developers, product team, and testers to review.
Continuous Development: As developers receive new requirements from users or the product team, updates are made to the feature files and scenarios, and the entire cycle (i.e. the previous BDD stages) is repeated until the expected behavior is achieved.
BDD testing
BDD testing refers to testing practices within the BDD framework. In other words, It is the testing side of the BDD. These testing practices usually involve:
- Writing tests as executable specifications in Gherkin syntax (Given-When-Then format).
- Running these tests automatically before every code change.
- Collaborating with stakeholders (like product owners and business analysts) to define the scenarios. Focusing on testing the entire system behavior, not just individual units of code.
Pros and cons of BDD
BDD has several pros as well as cons. Here are a few:
Building Customer-centric Products: Products developed using the BDD approach are customer-centric because most of the features implemented are based on customer feedback. As a result, BDD ensures that the products align with and meet the customers' expectations.
Foster Collaboration and Transparency: The BDD approach provides transparency for developers, product teams, and testers to collaborate and understand the features defined, ensuring that they align with business requirements and user expectations.
Feedback-based: BDD depends on clear and effective communication between users and developers. When the communication channel is disrupted, the feature development process is hampered by a lack of collaboration between users and developers.
Step-by-step demo example of BDD implementation
In this example, you’ll learn how to create tests using the BDD approach.
Prerequisites
To follow along with this tutorial, you’ll need the following:
- Code editor (Visual Studio Code editor)
- Terminal
- Basic knowledge of JavaScript
- Nodejs installed
Let’s start by creating a directory for the project. Open your terminal and run the following command to create a folder called bdd-project:
mkdir bdd-project
Open the project in your code editor. In the root project’s directory, run the following command to initialize a new Node.js project:
npm init -y
Cucumber.js works with Node.js and is available as an npm module, so you’ll use it as your testing framework. Within your project directory, run the following command to install Cucumber.js as a development dependency:
npm install --save-dev @cucumber/cucumber
Next, create a folder called features with a file called auth.feature (the .feature extension is compulsory) that will contain the scenarios you’ll define. In the auth.feature file, you’ll use keywords provided by the Gherkin Syntax to describe the behavior of logging into the application from the users’ perspective as follows:
Feature: Login feature
As a customer
I would like to log into the application
So that I can gain access to my account
Scenario: Successful login
Given I am at the login page
When I type in my correct username and password
And click the "Login" button
Then I should be redirected to my home page
The keywords used above are explained below:
- Feature: The Feature keyword is the first keyword that describes the feature in a short text.
- Scenario: This Scenario keyword defines the specific test case that describes a particular behavior of the login feature.
- Given: The Given keyword specifies the initial state of the scenario.
- When: The When keyword describes the action performed by the user on the login feature.
- And: The And keyword adds more steps to the scenario that describe an action carried out by the user
- Then: The Then keyword specifies the expected outcome after the previous steps have been executed in this scenario
So far, you have defined steps in your feature file, next you’ll need to map the steps to their respective code implementation. To do this, create a file called step_implement.js and paste the following code:
const { Given, When, Then, And } = require("@cucumber/cucumber");
Given("I am at the login page", function () {
// write code that navigates to the login page
});
When("I type in my correct username and password", function () {
// write code to enter valid user credentials
});
When("click the {string} button", function (buttonText) {
// write code to click on the specified button
});
Then("I should be redirected to my home page", function () {
// write code to verify the redirection to the home page
});
You're importing the Given
, When
, Then
, and And
keywords from the Cucumber npm module you installed and writing the code to implement all of the steps and actions defined in the Scenario.
To execute the steps defined in the feature file, create another file called configure.js that will contain the Cucumber.js configuration:
module.exports = {
default: '--format-options \'{"snippetInterface": "synchronous"}\'',
};
You're configuring the default options for the Cucumber.js test runner here by specifying the format of the output generated by Cucumber to synchronous.
In your package.json file, set your test script to this:
"test": "cucumber-js"
Then, run the tests with the following command:
npx cucumber-js
Once you run the command, the outcome should be displayed in your terminal as follows:
The tests will pass once you add the appropriate code to implement the scenarios.
When to use BDD (Behavior-Driven Development):
- Collaborative requirements definition: Use BDD when you want to involve non-technical stakeholders (such as business analysts, product owners, or clients) in the definition of requirements. BDD's use of natural language allows for the creation of scenarios that are easily understood by all parties and everyone is on the same page.
- User-centric applications: BDD is particularly useful when the focus is on user behavior and ensuring that the system provides specific value to the user. It is ideal for projects where user stories are a key component of the development process, and the end user's experience is a priority.
- Complex business logic: When the project involves complex business logic that requires clear understanding and communication among stakeholders, BDD helps to clarify the desired behaviors through examples and ensures that all team members have a common understanding.
- Refinement of acceptance criteria: BDD is beneficial when the team requires a clear set of acceptance criteria that can be translated into automated tests. This ensures that the features meet the predefined behaviors before being considered complete.
When to use TDD (Test-Driven Development):
- Focus on quality and design: Use TDD when you want to ensure high code quality and a clean design from the start. TDD encourages small, incremental changes and refactoring, which can lead to a well-structured and less complex codebase.
- Developer-level feedback: When rapid feedback on the code's functionality is required, TDD provides immediate input to developers about whether their code works as expected. This can reduce the debugging time and speed up the development process.
- Regulatory compliance and documentation: In environments where there is a need for detailed documentation of the development process for regulatory compliance, TDD can provide an audit trail of tests that correspond to specific requirements of the system.
- Continuous integration environments: TDD is ideal in continuous integration environments where tests are run frequently. It ensures that new code does not break existing functionality, thus facilitating continuous improvement of the code with minimal disruption.
Common Pitfalls and How I Avoid Them in TDD
Too Many Small Tests are Being Written This often gets me caught up in writing tests for every little function or method. It feels so complete, but it's really just slowing me down and making the test suite a lot more maintenance-heavy.
How I avoid it: I now test the whole system most of the time for its behavior rather than testing all internal details.
// Cannot test private or tiny methods.
test('should add item to cart', () =>
const cart = new ShoppingCart();
cart.addItem({ name: 'Book', price: 10 });
expect(cart.items.length).toBe(1);
);
END
No Refactoring
I had often been tempted, once it passed, to just move on and skip cleaning up the code. But not refactoring usually begets messy, hard-to-maintain code later on.
Always take the time to refactor immediately after getting the test to pass. That's why TDD's "Red, Green, Refactor" is so important.
Writing Code Before Tests This is also you can often find me writing the code first, then subsequently adding tests for; it's here I've realized that indeed buries the advantages of TDD.
Now, I write the test always first—even if it takes some extra time upfront. This way, I can catch bugs quickly and remain focused on my goal.
Common Pitfalls and How I Avoid Them in BDD
Too General or Too Specific Writing Scenarios I notice that both very vague and very highly detailed writing of scenarios leads to confusion: where they are vague, they miss important behavior; where they are too detailed, it is overwhelming.
I strive to focus my attention on the writing of scenarios that describe users' behavior in free text, striking a balance between the level of detail and clarity.
Scenario: User logs in successfully
Given the user is on the login page
When the user inputs correct credentials
He will then be taken to the dashboard.
Not Involving Non-Technical Stakeholders I had written some BDD scenarios in the past when I did not collaborate enough with the product owners or stakeholders. The scenarios weren't really aligned with the business need in those cases.
I make sure to involve product owners and nontechnical stakeholders up-front because this means that scenarios reflect real business goals.
Not Automating BDD Tests
I have from time to time written good BDD scenarios yet didn't automate them, which led to missed tests and manual errors.
How I avoid it: I always use tools like Cucumber in order to automate the BDD tests—so all go smooth and consistent.
END // Example of BDD scenario automation using Cucumber.js
const { Given, When, Then } = require('@cucumber/cucumber');
Given('the user is on the login page', function ()
// code to divert to the login page
})
When('the user enters valid credentials', function () {
//Credentials Input Code END Then('the user is redirected to the dashboard', function () // code to check the redirecting to dashboard );
END
Comparison of TDD and BDD
So far, you've learned what TDD and BDD are, what they entail, and how they work. Let's look at how they differ in various aspects, as shown in the table below:
Aspect | TDD (Test-Driven Development) | BDD (Behavior-Driven Development) |
---|---|---|
Core Focus | Focuses on the implementation of functionality. | Focuses on the behavior and interaction of the system with the user. |
Scope | Typically limited to the developer’s scope; testing mainly at the unit level. | Broader scope involving stakeholders; tests at the system level with scenarios. |
Test Creation | Tests are written before code is implemented. | Scenarios are defined before tests are written; tests are derived from these. |
Language Used | Uses programming languages to write test cases. | Uses natural language, often English, to write scenarios. |
Automation | Tests are automated. | Scenarios can be automated using tools like Cucumber. |
Written By | Written by developers. | Written by or with stakeholders, such as product owners, developers, and testers. |
Test Examples | Should increment the count when the method is called. | Given a registered user, when they log in, then they see their dashboard. |
Communication | Primarily between developers or between developer and code. | Promotes communication between technical and non-technical stakeholders. |
Similarities | Both encourage writing tests before the actual code. | Both require writing tests before the actual code. |
Both aim to create a suite of automated tests. | Both aim to create a suite of automated tests. | |
Both methods emphasize the importance of testing in the development process. | Both methods emphasize the importance of testing in the development process. | |
Differences | TDD does not involve business stakeholders directly. | BDD encourages direct involvement of business stakeholders. |
Code-centric approach. | Behavior-centric approach, often through user stories. | |
Less emphasis on documentation that's understandable by business stakeholders. | Emphasizes documentation that doubles as living documentation for the system. |
Bonus: Advanced TDD and BDD Techniques
Lately, I have been looking at some of the more advanced TDD and BDD techniques. For me, these have made the difference, mainly in controlling some complex test cases.
Advanced TDD Techniques
Mocking and Stubbing
Whenever I have code that depends on something external, like a database or an API, testing gets really tricky. Mocks and stubs help me feel like I'm really insulating what I'm trying to test.
I utilize mocking frameworks in order to simulate some external dependencies so that the tests will stay fast and focused.
const fetchData = require("./fetchData");
const axios = require("axios");
jest.mock("axios");
test("should fetch user data", async () => {
const user = { name: "John" };
axios.get.mockResolvedValue({ data: user });
const result = await fetchData();
expect(result.name).toBe("John");
});
In the following example, the axios.get
method is mocked away from useEffect in order to simulate an API request for testing the behavior of fetchData
.
Testing Edge Cases and Exceptions
TDD is great for ensuring that code works as expected, but it's equally important to test how it will handle unexpected or invalid inputs. I always try to write tests that cover the edge cases of exceptions.
I write tests for unexpected input values, null values, or edge cases that might not occur frequently but may cause the application to break.
test("should return an error when there is no data", () => {
const func = () => processData(null);
expect(func).toThrow("Invalid data");
});
I am testing here how my function handles a null input. This should raise an error.
Advanced BDD Techniques
Summarizing the Case
In my experience with BDD, scenario outlines can definitely help in reducing repeated tests for the same scenario with different values in data.
Scenario outlines allow you to write a single test structure and run it multiple times with different inputs. This will save you time and clean up the scenarios.
Scenario Outline: Verifying user input
Given that "<input>" are the user inputs
When the form is submitted by the user
Then the message "<message>" shall appear
Examples:
| input | message |
| John | Input is valid |
| "" | Input is required |
| 123 | Error Input |
In this scenario, running the scenario a few times with different inputs helps me concisely cover a number of different cases.
Selective Testing Using Tags
When working on big projects, running all BDD tests may take much time. In BDD, I have started using tags to run selectively only the scenarios in focus.
I label scenarios based on priority or feature so at development time I can run selected sets of tests.
@login
Scenario: User has logged in successfully
Given the user is on the login page
When the user enters valid credentials
Then the user is redirected to the dashboard
In practice, what it means is that, at any given time, working on the login functionality, I run only the tests tagged as having @login
but not all tests.
BDD with Continuous Integration
BDD tests incorporated into the CI pipeline changed my professional life. It ensured that the scenarios are tested automatically after every code change.
I have configured BDD tools such as Cucumber to run as part of CI processes so that the behavior is continually validated.
# Example CI pipeline with BDD tests
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run BDD tests
run: npm run cucumber
These BDD tests can be used in automation form within the CI pipeline to catch potential behavioral issues quite early, and also ensure at all times that the code behaves as per expectation.
Conclusion
Finally, you've reached the end of this article, where you learned about Test-Driven Development (TDD) and Behavior-Driven Development (BDD), including what they entail, their principles, their benefits and drawbacks, and how they differ. You also saw TDD and BDD in action in a demo application.