A Complete Guide To Behavior-Driven Testing With Pytest BDD
Have you ever thought about testing your software behavior from a user’s perspective?
You’re probably familiar with unit tests, maybe even integration tests and regression tests. But what about the entire feature and its behavior?
For example, the user logs in, adds products to a shopping cart, and checks out.
How do you validate this entire end-to-end scenario? There could be infinite scenarios.
How to bridge the gap between product engineers, QA testers, and developers — to make sure you’re speaking the same language?
The key lies in Behavior-Driven-Development (BDD) — given a feature, you test its behavior.
BDD helps you understand the journey from your end user’s perspective and make sure that journey (and other journeys) work as expected.
With improved communication, clear requirements, and a more collaborative approach to testing, BDD acts like the English language in a world full of chaos and differences.
pytest-bdd
is a Pytest plugin that combines the flexibility and power of Pytest with the readability and clarity of BDD.
In this article, you’ll learn the basics of BDD and how to use pytest-bdd
to test the features and behaviors of your application.
You’ll also learn about the Gherkin language, how to define features, scenarios, how to use dynamic parameters, and get your hands dirty with real practical examples.
You’ll emerge on the other side with a clear understanding of using pytest-bdd
to test software features and behaviors.
Let’s get into it.
What You’ll Learn
By reading this article you’ll learn/have
- A practical understanding of Behavior-Driven Development (BDD).
- To use the
pytest-bdd
plugin to perform BDD testing in Pytest. - To write a feature file using the Gherkin language.
- To test features, scenarios, and behaviors including parameters.
- To communicate and collaborate with non-technical stakeholders.
- Explore real examples to easily implement
pytest-bdd
and BDD in your projects today.
What is Behavior Driven Development (BDD)?
So what exactly is BDD?
To put it into simple terms, in software, behavior is how a feature or functionality works.
It’s a combination of inputs, actions, and outcomes.
For example — you log into a website, post a picture, see the picture on your timeline, fill out a form, submit it, receive an email, perform a search, get results, and so on.
All of these are user behaviors. They represent how a user interacts with a software system. Btw these could be interactions with a service API too.
Behavior Driven Development is the practice of software development that puts user behavior at the epicenter of software testing.
It’s the practice of testing the functionality of your code from the user behavior, and bridges the gap between business stakeholders and the technical team.
At its core, BDD encourages the creation of simple, understandable descriptions of software behavior as scenarios.
You start by identifying the desired outcomes of your features from the perspective of your end-users.
This involves writing user stories that describe how a feature will work from the user’s viewpoint.
Each story is then broken down into scenarios, which are further detailed using a format known as Gherkin.
We’ll learn about Gherkin and see examples shortly.
The Gherkin Language
Gherkin is a domain-specific language designed for behavior descriptions, enabling all stakeholders to understand the behavior without needing technical knowledge.
The syntax is simple, focusing on the use of Given-When-Then statements to describe software features, scenarios, and outcomes.
Gherkin’s power lies in its readability and ability to serve as living documentation and a basis for automated testing.
Here’s a simple example.1
2
3
4
5Feature: Account Withdrawal
Scenario: Withdraw money from an account with sufficient funds
Given the account balance is $100
When the account holder requests $20
Then the account balance should be $80
Another one1
2
3
4
5Feature: User login
Scenario: Successful login with correct credentials
Given I am on the login page
When I enter valid credentials
Then I should be redirected to the dashboard
You can see how this helps you zoom out from the intricacies of your day-to-day coding to see the bigger picture and whether or not the feature you’re building, actually works from your user’s perspective.
Because, let’s face it, nobody cares about the code we write, they just want it to work as intended, reliably.
Now that you understand this, let’s see why and how you can use Pytest to achieve true Behavior-Driven-Development.
Pytest BDD Test Frameworks
A BDD framework bridges the features and scenarios specified in your Gherkin feature file and your test code.
Some popular frameworks for Pytest are pytest-bdd, behave and radish.
While all of these are popular, pytest-bdd
is one of the simplest and most commonly used.
Being a plugin itself, pytest-bdd
can leverage the power of the entire Pytest ecosystem — including fixtures, parameters, hooks, code coverage, parallel execution, and other features.
Before we jump into pytest-bdd
and how to use it, let’s quickly do a recap of Pytest fixtures as we’ll be using fixtures shortly.
Pytest Fixtures (Short Recap)
Pytest fixtures are a powerful feature for setting up and tearing down conditions for tests.
They provide a flexible way to reuse setup code across tests, ensuring consistency and reducing boilerplate.
Using decorators, you can define fixtures and inject them into your tests as arguments, allowing for clean and manageable test code.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15import pytest
# Setup fixture: runs before each test function that uses it
def setup_database():
# Simulated database setup
print("Setting up database")
yield "database_connection"
# Teardown code: runs after the test finishes
print("Tearing down database")
def test_database_operations(setup_database):
# setup_database fixture is used here
assert setup_database == "database_connection"
print("Running test_database_operations")
In the short example above, you can see how we can reuse the setup_database
fixture in several tests.
The setup and teardown lifespan can be controlled using the scope
parameter.
We covered Pytest fixture scopes in great length in this article.
Now let’s set up your local environment so you can follow along with me.
Set Up Your Local Environment
Before diving into the source code, let’s set up your local environment so you can follow along.
Clone the Repo — EXAMPLE CODE REPO
The project has the following structure.1
2
3
4
5
6
7
8
9
10
11
12
13├── .gitignore
├── Pipfile
├── Pipfile.lock
├── pytest.ini
└── tests
├── features
│ ├── bank_transactions.feature
│ ├── bank_transactions_param.feature
│ └── bank_transactions_scenario_outline.feature
└── step_definitions
├── test_bank_transactions.py
├── test_bank_transactions_param.py
└── test_bank_transactions_scenario_outline.py
Don’t worry too much about the features
and step_definitions
folders we’ll cover what they mean shortly.
Some knowledge of Python (used v3.12 for this example but any recent version should work) and Pytest and fixtures is useful.
Note — I’m using Pipenv to manage virtual environments and packages instead of Conda but feel free to use what’s best for you.1
2$ pipenv shell
$ pipenv install - dev
Once you’re all set up with dependencies let’s move on.
The most important ones being pytest
and pytest-bdd
.
Note — I’m using the VSCode IDE but aware that Pycharm Professional Version can auto-create the test code (step definitions) for you from the feature file.
If you can, I recommend installing a Gherkin extension for VSCode. I used this one.
Writing Your First BDD Test with PyTest
Now let’s look at how to write your first BDD test.
Create a Feature File
Create a folder features
in your tests
folder.
Now create a file bank_transactions.feature
with the following.
tests/features/bank_transactions.feature
1
2
3
4
5
6
7Feature: Bank Transactions
Tests related to banking Transactions
Scenario: Deposit into Account
Given the account balance is $100
When I deposit $20
Then the account balance should be $120
It’s a fairly simple feature file but let’s break it down nevertheless.
The feature under test is “Bank Transactions”. The line below it is interpreted as comments.
The scenario is “Deposit into Account”.
- Given an initial balance of $100
- When $20 is deposited
- The new balance should be $120
Fairly simple example. Keep in mind
Scenario — A Test Case or Behavior Specification
Given — Initial State
When — Takes an action
Then — Verify an expected outcome
Write Step Definitions
The 2nd step in our example is the write step-definitions aka the tests.
Create a step_definitions
folder under tests
and include the following code.
tests/step_definitions/test_bank_transactions.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25import pytest
from pytest_bdd import scenarios, scenario, given, when, then
# Load all scenarios from the feature file
scenarios("../features/bank_transactions.feature")
# Fixtures
def account_balance():
return {"balance": 100} # Using a dictionary to allow modifications
# Given Steps
def account_initial_balance(account_balance):
account_balance["balance"] = 100
# When Steps
def deposit(account_balance):
account_balance["balance"] += 20
# Then Steps
def account_balance_should_be(account_balance):
assert account_balance["balance"] == 120
Let’s make sense of what’s going on.
- From
pytest_bdd
we import thescenarios
,given
,when
andthen
decorators to use in subsequent functions. - We load the feature file using the
scenarios
method with a path to said feature file. - We define a Pytest Fixture
account_balance
which is a simple object containing a key-value pair. - Next, we have the
given
decorator and we pass the same string as we specified in thefeature
file. Remember,given
is the initial state so in this case we simply set theaccount_balance
object keybalance
to 100. - Now comes the
when
decorator — which is the step that takes an action, in this case, increments theaccount_balance[“balance”]
by 20. - Lastly, comes the
then
decorator — here we verify the outcome. The new balance should be 120.
By breaking down the steps, you can see how pytest-bdd
allows us to be very clear and specific about the Arrange-Act-Assert model.
Now let’s run it.1
$ pytest tests/step_definitions/test_bank_transactions.py -v -s
This is a very basic example and helps get a feel for how to run your first BDD test for a single scenario using pytest-bdd
.
Now let’s move up a notch.
Example 2 (Parameterization Features)
While this example helped us learn the ropes, we don’t want to be restricted in values.
Let’s parameterize numbers to test a wide variety of cases by modifying the feature file.
tests/features/bank_transactions_param.feature
1
2
3
4
5
6
7
8
9
10
11
12Feature: Bank Transactions
Tests related to banking Transactions
Scenario: Deposit into Account
Given the account balance is $"100"
When I deposit $"20"
Then the account balance should be $"120"
Scenario: Withdraw from Account
Given the account balance is $"100"
When I withdraw $"20"
Then the account balance should be $"80"
What’s different?
- The numeric values are included in double-quotes.
- We’ve added a second scenario — Withdraw from Account.
We’re re-using the given
and then
statements and only changing the when
statement.
The double-quotes signify that value is a parameter and not to be hardcoded in the tests.
Let’s see how this changes our step definition.
tests/step_definitions/test_bank_transactions_param.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35import pytest
from pytest_bdd import scenarios, given, when, then, parsers
# Load all scenarios from the feature file
scenarios("../features/bank_transactions_param.feature")
# Fixtures
def account_balance():
return {"balance": 0} # Using a dictionary to allow modifications
# Given Steps
def account_initial_balance(account_balance, balance):
account_balance["balance"] = balance
# When Steps
def deposit(account_balance, deposit):
account_balance["balance"] += deposit
# When Steps
def withdraw(account_balance, withdrawal):
account_balance["balance"] -= withdrawal
# Then Steps
def account_balance_should_be(account_balance, new_balance):
assert account_balance["balance"] == new_balance
The first bit is pretty much the same.1
2
3
4# Given Steps
def account_initial_balance(account_balance, balance):
account_balance["balance"] = balance
The @given
decorator makes use of the parsers.parse
method to parse the parameterized given
string using "{balance:d}”
. d
just means it’s an int and it’s value is stored as balance
.
You can read more about the parse
method here.
You’ll see a 2nd @when
case which is the withdrawal scenario.1
2
3
4# When Steps
def withdraw(account_balance, withdrawal):
account_balance["balance"] -= withdrawal
This is very similar to the first only the when
statement is different.
Running this we get1
$ pytest tests/step_definitions/test_bank_transactions_param.py -v -s
You can observe that 2 tests ran, one for each scenario.1
2tests/step_definitions/test_bank_transactions_param.py::test_deposit_into_account PASSED
tests/step_definitions/test_bank_transactions_param.py::test_withdraw_from_account PASSED
Example 3 (Scenario Outlines)
If you’re familiar with Pytest Parametrization, you know the power of generating multiple test case scenarios without writing n
test cases.
You can do something similar here — it’s called Scenario Outlines.
This allows you to specify parameters (called examples) in your feature file.
tests/features/bank_transactions_scenario_outline.feature
1
2
3
4
5
6
7
8
9
10
11
12Feature: Bank Transactions
Tests related to banking Transactions
Scenario Outline: Deposit into Account
Given the account balance is $<balance>
When I deposit $<deposit>
Then the account balance should be $<new_balance>
Examples:
| balance | deposit | new_balance |
| 100 | 20 | 120 |
| 0 | 5 | 5 |
| 100 | 0 | 100 |
Note the reference to the parameters in the Given, When and Then statements as <balance>
, <deposit>
and <new_balance>
.
The Examples
field takes in a table of values for balance
, deposit
and new_balance
.
Let’s now see how our step definition looks.
tests/step_definitions/test_bank_transactions_scenario_outline.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30import pytest
from pytest_bdd import scenarios, given, when, then, parsers
# Load all scenarios from the feature file
scenarios("../features/bank_transactions_scenario_outline.feature")
# Fixture to represent account balance
def account_balance():
return {"balance": 0} # Using a dictionary to allow modifications
# Given Steps
)
def account_initial_balance(balance):
return {"balance": balance}
# When Steps
def deposit(account_balance, deposit):
account_balance["balance"] += deposit
# Then Steps
def account_balance_should_be(account_balance, new_balance):
assert account_balance["balance"] == new_balance
We use the parsers.parse
method to pass the {balance:d}
value to the @given
decorator and similarly for @when
and @then
.
The only difference is the target_fixture
value passed to the @given
decorator to maintain the given state.
Running this.1
$ pytest tests/step_definitions/test_bank_transactions_scenario_outline.py -v -s
You can see 3 tests run (one for each example case) which is exactly what we expected.
Using Tags
A typical production project may have hundreds if not thousands of scenarios.
You’ll be testing user interactions, various paths, APIs, web browser automations, auth and more.
Running ALL the tests every time takes ages and creates friction in following continuous release and delivery principles.
How do you solve this problem?
Simple, by filtering, through tags.
Similar to Pytest markers, you can tag your features and scenarios using the @
symbol. Like this.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Feature: Bank Transactions
Tests related to banking Transactions
Scenario: Deposit into Account
Given the account balance is $"100"
When I deposit $"20"
Then the account balance should be $"120"
Scenario: Withdraw from Account
Given the account balance is $"100"
When I withdraw $"20"
Then the account balance should be $"80"
You can see we’ve applied the @banking
tag to the feature and individual @deposit
and @withdrawal
tags to the scenarios.
Note — Tags applied at the feature level will be auto-applied at the scenario level.
Testing it is also simple.
Let’s use the -k
flag in Pytest which stands for keyword.1
$ pytest -k "deposit"
1
$ pytest -k "banking"
You can see how it ran 5 tests instead of 6.1
$ pytest -k "withdrawal"
Similarly here, only 1 test scenario ran.
Another interesting concept is that we can pass 2 tags.1
$ pytest -k "banking and withdrawal"
This will run both tags.
Similarly, you can classify your testing using tags such as ui
, ux
, api
, auth
, infrastructure
and so on.
Very neat and handy!
Conclusion
This wraps it up for this article.
In this one, you learned the basics of Behavior-Driven-Development or BDD as it’s commonly called, and why this is so powerful in your testing kit.
You also learned how to use thepytest-bdd
framework to read feature files and write step-definitions using the @given
, @when
, @then
decorators.
Then we dived into parameters including scenario outlines which provide a great base to run tests against a variety of input data.
In subsequent articles, we’ll learn more real-life BDD examples like testing a REST API, Web UI using Selenium, and more — so let me know if you’d like to receive that.
Meanwhile, go ahead and start using BDD in your work, speak to your test colleagues to align on this. It’s something you won’t regret and your users will thank you.
If you have ideas for improvement or like me to cover anything specific, please send me a message via Twitter, GitHub or Email.
Till the next time… Cheers!
Additional Reading
Example Code Used in this Article — GitHub Repo
pytest-bdd - Official Docs
Python REST API Unit Testing for External APIs
How To Use Pytest With Selenium For Web Automation Testing
How to Effortlessly Generate Unit Test Cases with Pytest Parameterized Tests
The Arrange, Act, Assert Model
What Are Pytest Fixture Scopes? (How To Choose The Best Scope For Your Test)
What is Setup and Teardown in Pytest? (Importance of a Clean Test Environment)
How Pytest Fixtures Can Help You Write More Readable And Efficient Tests
Free Course - Behavior Driven Python with pytest-bdd
BDD 101: UNIT, INTEGRATION, AND END-TO-END TESTS
Book - BDD in Action: Behavior-Driven Development for the Whole Software Lifecycle