Behaviour-Driven Development in practice
Behaviour-Driven Development (BDD) is a software development process. As the name implies, it is based on the idea that the expected behavior of a system is defined before the source code for that system is written.
The behavior is documented in the form of a suit of tests that can be executed to validate how the system responds to a set of examples that cover the use cases of that system.
In this article I’ll present a practical example of a BDD project to build a simple REST service. I will mostly cover the analysis phase, but have published a GitHub repository with the full source code for both the specification and the implementation.
TDD for the whole crew
BDD grew out of Test-Driven Development (TDD), becoming a process that enhances collaboration in software projects.
Although its name suggests otherwise, TDD is actually a design approach, not a testing technique. When you write a test for a component before writing the component itself, you force yourself to think about how will it interact with the other components in the system, that is, to design it.
The term BDD was coined by Dan North in his 2006 article “Introducing BDD”, where he describes the challenges he faced in helping others understand how TDD operates. Many developers struggled with understanding what constitutes a test, what needs to be tested, and what shouldn’t, as well as interpreting failing tests.
To simplify things for others, he reasoned that the term “behavior” was more appropriate than “test”, since it clearly indicates what needs verification: how the software behaves under specified circumstances.
At that time, there was no formal definition of ‘behavior’ in the context of software projects. Dan recognized this change as more profound than merely renaming it, and envisioned a new collaborative approach that would bring together development teams, QA, and stakeholders in a more cohesive way around the definition of behavior.
Just as a unit test defines how a individual component should operate, a Behavior Test describes how the whole system should respond to a given input. This is defined by the team in the analysis phase, when the requirements and acceptance criteria are defined.
As he wrote in his article:
BDD provides a “ubiquitous language” for analysis.
BDD exists in the context of Agile development, and leverages its foundational principles:
- Individuals and interactions over processes and tools
- Working software over comprehensive documentation
- Customer collaboration over contract negotiation
- Responding to change over following a plan
It also brings the spirit of TDD, based on having a broken Test before adding a new feature to your system. That Test defines how it will work and helps the team to build the right thing.
A typical BDD cycle looks like this:
The process starts with a meeting where the team defines the User Stories and the acceptance criteria. From that definition, a (set of) failing Behavior Test(s) is created so the developers know what exactly they need to build.
To be clear, doing TDD to build the piece of software that passes the Behavior Test is not a requirement, but in the BDD context that would be the natural choice. For each component in the system, a failing Unit Test would be written before hand to start the “Red-Green-Refactor” cycle.
Separate specification from implementation
It is a common practice to maintain two separate repositories:
- A specification repository where we store the API definition and the Behavior Tests.
- An implementation repository that contains the service code.
There are several reasons for this approach:
- Different natures: Each repository will have its own independent lifecycle and versioning scheme.
- Different teams: The implementation project belongs to the development team. The specification project is owned by the whole team, including business representatives and QA. This setup makes collaboration easier without conflicts.
- Different tools: Each project will have its own tooling and pipeline configuration, which is easier if they do not depend on each other.
Following this approach, the specification repository contains the behavior tests that validate the implementation repository.
The “Three Amigos” meeting
A BDD project starts with a kick-off meeting where the following three perspectives need to be represented (hence the “Three Amigos” name):
- Business: responsible for defining the product vision.
- Development: ensures that requirements are well-defined and feasible to implement.
- Quality Assurance: responsible for defining clear acceptance criteria.
That initial meeting is a conversation where the new feature is defined and all questions are answered. The outcome will be a set of User Stories that define the features to be implemented (typically following the template “As a…, I …, So that…”), and a set of concrete examples that will eventually constitute the acceptance criteria for the project.
Documenting requirements
Once the User Stories and the Acceptance Criteria are clearly defined, the next step is to document them, and materialize the Acceptance Criteria in a Test Suite that can be executed to validate the sofware once written.
While there are many languages you can use to document your requirements, Gherkin is probably the most popular. It is part of a bigger project, Cucumber, that also includes tools to execute Gherkin requirements documents as test cases.
Cucumber’s history is closely tied to that of BDD. It emerged as a tool designed to benefit both developers and non-technical stakeholders alike.
A Gherkin file serves a dual purpose:
- It documents required behavior, in the form of concrete examples of how the service should work.
- It can also be executed to validate a specific implementation of your service.
Example: The Catalog Service
For this example, we will work in a Catalog REST Service that will let managers of an imaginary e-commerce site define products and their discounts.
The first feature being implemented will be the posibility to manage (create, read, update, delete) products.
User Stories
The following team members gather in the kick-off meating:
- The Product Owner, representing the business perspective
- The Architect, representing the development team
- The Quality Assurance developer
In that meeting, the team comes up with a set of User Stories:
Story 1: Add a product to the catalog
As a Catalog Manager
I should be able to add a product to the catalog
so that the product is stored in the system
Story 2: Update a product of the catalog
As a Catalog Manager
I should be able to update the product details
so that the product is modified in the system
Story 3: Remove a product from the catalog
As a Catalog Manager
I should be able to remove a product from the catalog
so that the product is removed from the system
Each user story is a simple text that follows the scheme “As a XXX, I should be able to YYY so that ZZZ” and describes from a very hight level a given functionality.
The team also finds some important conditions:
- A product needs to have, at least, an ID, a name and a price value.
- A product’s price has to be a positive integer.
- A product can optionally have other fields:
- description
- discounts (composed of a rate, and a dates interval)
Gherkin scenarios
When the team receives the User Stories and requirements, they use Gherkin to express them as a set of scenarios.
If you are working with Java, the easiest way to create the project to contain the specifiation is by cloning the cucumber java skeleton.
In the specification project, you create a feature file containing all the scenarios agreed in the kick-off meeting:
Feature: Product management
# Happy paths
Scenario: New product creation
Given There is no product with id "test-product-id"
And I call the service with
|id |name |price |
|test-product-id |Test Product |999 |
Then the response code is 201
And A product exists with id "test-product-id"
And The product name is "Test Product"
And The product price is 999
Scenario: Product modification
Given A product exists with
|id |name |price |
|test-product-id |Test Product |999 |
When I call the service with
|id |name |price |
|test-product-id |New name |888 |
Then the response code is 200
Then A product exists with id "test-product-id"
And The product name is "New name"
And The product price is 888
Scenario: Product deletion
Given A product exists with
|id |name |price |
|test-product-id |Test Product |999 |
When I delete the product with id "test-product-id"
Then the response code is 204
Then There is no product with id "test-product-id"
# Validation Errors
Scenario: Product id cannot be empty
Given There is no product with id "test-product-id"
And I call the service with
|id |name |price |
| |Test Product |999 |
Then the response code is 404
And There is no product with id "test-product-id"
Scenario: Product name cannot be empty
Given There is no product with id "test-product-id"
And I call the service with
|id |name |price |
|test-product-id | |999 |
Then the response code is 400
And There is no product with id "test-product-id"
Scenario: Product price cannot be negative
Given There is no product with id "test-product-id"
And I call the service with
|id |name |price |
|test-product-id |Test Product |-1 |
Then the response code is 400
And There is no product with id "test-product-id"
The first thing to notice is that the Gherkin syntax makes this code readable by non technical people. This makes it a great choice for this type of documentation.
From this requirements definition we can start thinking on our service’s API. We need to be able to create, update, read and delete products. Listing will not be needed at this moment, so we can only create one endpoint /product/{id}
and accept PUT
, GET
and DELETE
requests.
One great way to define a REST interface is in an OpenAPI document like the following:
openapi: 3.0.4
info:
title: Catalog Service
description: Allows users to manage products and campaigns
license:
name: "Apache 2.0"
url: "https://www.apache.org/licenses/LICENSE-2.0.html"
version: 0.0.1
servers:
# List here all the environments we will be testing
- url: http://localhost:8080
description: Localhost
paths:
/product/{id}:
get:
tags:
- "products"
summary: Returns a product.
description: Returns the full data of the product with the given id
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"404":
description: No product found with provided id
"200":
description: The requested product
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
put:
tags:
- "products"
summary: Creates or updates a product
description: Creates or updates the product with the given Id
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
responses:
"201":
description: Product successfully created
"200":
description: Product successfully updated
"400":
description: The Product data was not valid
content:
application/json:
schema:
type: object
properties:
error:
type: string
delete:
tags:
- "products"
summary: Removes a product.
description: Removes the product identified by the given id
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"204":
description: The response was successful, no additional information provided.
components:
schemas:
Product:
type: object
required:
- id
- name
- price
properties:
id:
type: string
name:
type: string
description:
type: string
price:
type: integer
discounts:
type: array
items:
$ref: "#/components/schemas/Discount"
Discount:
type: object
required:
- rate
- startDate
properties:
rate:
description: the discount rate
type: number
format: float
minimum: 0.0
maximum: 1.0
startDate:
description: the date and time when the discount starts
type: string
format: date-time
endDate:
description: the date and time when the discount is no longer valid (optional)
type: string
format: date-time
Once you have the API of your service defined, you can use the OpenAPI code generator to create both a client and (part of) the code to implement your service.
I have used the OpenAPI Maven plugin to integrate the code generator in my specification project.
For the service I will use Micronaut, which includes the OpenAPI generator out of the box, you only need to enable it in your maven or gradle project file.
Execute Gherkin scenarios as Behavior Tests
As mentioned, I will be using Cucumber to create my Behavior Tests. I’m not covering all the details here, but you can refer to the project documentation if needed.
To make the Gherkin file executable we need to:
-
Create a Junit Test and annotate it to be ran by the Cucumber engine. That test will find your features file and execute all the scenarios.
-
Implement all the step definitions, so that the Cucumber engine knows what you mean with
There is no product with id {string}
, orThe product name is {string}
.
In this step definitions we will be using the client generated with OpenAPI to invoke the service, using the endpoints defined in the servers
section of the API spec.
Once you have a your Gherkin Behavior Test, and your step definitions, you can run your tests by executing the mvn test
command on the specification repository:
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running es.nachobrito.catalog.RunCucumberTest
Scenario: New product creation # es/nachobrito/catalog/product.create.feature:4
Given There is no product with id "test-product-id" # es.nachobrito.catalog.StepDefinitions.thereIsNoProductWithId(java.lang.String)
org.opentest4j.AssertionFailedError: [Extracted: code]
expected: 404
but was: 0
at es.nachobrito.catalog.StepDefinitions.thereIsNoProductWithId(StepDefinitions.java:45)
at ✽.There is no product with id "test-product-id"(classpath:es/nachobrito/catalog/product.create.feature:5)
And I call the service with # es.nachobrito.catalog.StepDefinitions.iCallTheServiceWith(io.cucumber.datatable.DataTable)
| id | name | price |
| test-product-id | Test Product | 999 |
Then the response code is 201 # es.nachobrito.catalog.StepDefinitions.theResponseCodeIs(int)
And A product exists with id "test-product-id" # es.nachobrito.catalog.StepDefinitions.aProductExistsWithId(java.lang.String)
And The product name is "Test Product" # es.nachobrito.catalog.StepDefinitions.theProductNameIs(java.lang.String)
And The product price is 999 # es.nachobrito.catalog.StepDefinitions.theProductPriceIs(int)
...
[ERROR] Tests run: 6, Failures: 6, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 6.240 s
[INFO] Finished at: 2025-04-07T20:43:00+02:00
[INFO] ------------------------------------------------------------------------
This will invoke the service on http://localhost:8080
, as we defined in our OpenAPI file, and verify all the scenarios.
Obviously, the first time you run your Behavior Tests they should fail. That is the point, now the team knows what they need to build - they have to make these tests pass.
If you have a pipeline, these tests should be deployed somewhere and ran to validate every new release, to certify it follows the contact defined by the specification.
Remember you can check this GitHub repository and review the source code of both the specification and implementation projects.
When is this useful?
Like diets, no development process is perfect for everyone. Each team needs to evaluate themselves and assess whether BDD is the right option.
The main benefit of applying BDD is having an artifact - the Behavior Tests Suite - that you can use to automatically certify each release in your project.
This is particularly useful when doing Continuous Delivery, as you can use the BDD tests in your pipeline to gate each release you build. Only those that pass the behavior tests will be deployed.
Another type of projects that benefit from this are those with compliance requirements. In regulated sectors like healthcare or finance, where software is regularly audited, BDD tests provide critical evidence of compliance.