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.

EXAMPLE CODE REPO

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
5
Feature: 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 one

1
2
3
4
5
Feature: 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
15
import pytest  

# Setup fixture: runs before each test function that uses it
@pytest.fixture
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
7
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

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
25
import pytest  
from pytest_bdd import scenarios, scenario, given, when, then

# Load all scenarios from the feature file
scenarios("../features/bank_transactions.feature")

# Fixtures
@pytest.fixture
def account_balance():
return {"balance": 100} # Using a dictionary to allow modifications

# Given Steps
@given("the account balance is $100")
def account_initial_balance(account_balance):
account_balance["balance"] = 100

# When Steps
@when("I deposit $20")
def deposit(account_balance):
account_balance["balance"] += 20

# Then Steps
@then("the account balance should be $120")
def account_balance_should_be(account_balance):
assert account_balance["balance"] == 120

Let’s make sense of what’s going on.

  1. From pytest_bdd we import the scenarios , given , when and then decorators to use in subsequent functions.
  2. We load the feature file using the scenarios method with a path to said feature file.
  3. We define a Pytest Fixture account_balance which is a simple object containing a key-value pair.
  4. Next, we have the given decorator and we pass the same string as we specified in the feature file. Remember, given is the initial state so in this case we simply set the account_balance object key balance to 100.
  5. Now comes the when decorator — which is the step that takes an action, in this case, increments the account_balance[“balance”] by 20.
  6. 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-bddallows 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

pytest-bdd-basic

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
12
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"

What’s different?

  1. The numeric values are included in double-quotes.
  2. 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
35
import pytest  
from pytest_bdd import scenarios, given, when, then, parsers

# Load all scenarios from the feature file
scenarios("../features/bank_transactions_param.feature")


# Fixtures
@pytest.fixture
def account_balance():
return {"balance": 0} # Using a dictionary to allow modifications


# Given Steps
@given(parsers.parse('the account balance is $"{balance:d}"'))
def account_initial_balance(account_balance, balance):
account_balance["balance"] = balance


# When Steps
@when(parsers.parse('I deposit $"{deposit:d}"'))
def deposit(account_balance, deposit):
account_balance["balance"] += deposit


# When Steps
@when(parsers.parse('I withdraw $"{withdrawal:d}"'))
def withdraw(account_balance, withdrawal):
account_balance["balance"] -= withdrawal


# Then Steps
@then( parsers.parse('the account balance should be $"{new_balance:d}"'),)
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  
@given(parsers.parse('the account balance is $"{balance:d}"'))
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  
@when(parsers.parse('I withdraw $"{withdrawal:d}"'))
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 get

1
$ pytest tests/step_definitions/test_bank_transactions_param.py -v -s

pytest-bdd-param

You can observe that 2 tests ran, one for each scenario.

1
2
tests/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
12
Feature: 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
30
import 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
@pytest.fixture
def account_balance():
return {"balance": 0} # Using a dictionary to allow modifications


# Given Steps
@given( parsers.parse("the account balance is ${balance:d}"),
target_fixture="account_balance",)
def account_initial_balance(balance):
return {"balance": balance}


# When Steps
@when(parsers.parse("I deposit ${deposit:d}"))
def deposit(account_balance, deposit):
account_balance["balance"] += deposit


# Then Steps
@then(parsers.parse("the account balance should be ${new_balance:d}"))
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

pytest-bdd-scenario-outline

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
@banking  
Feature: Bank Transactions
Tests related to banking Transactions

@deposit
Scenario: Deposit into Account
Given the account balance is $"100"
When I deposit $"20"
Then the account balance should be $"120"

@withdrawal
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"

pytest-bdd-tags-1

1
$ pytest -k "banking"

pytest-bdd-tags-2

You can see how it ran 5 tests instead of 6.

1
$ pytest -k "withdrawal"

Similarly here, only 1 test scenario ran.

pytest-bdd-tags-3

Another interesting concept is that we can pass 2 tags.

1
$ pytest -k "banking and withdrawal"

pytest-bdd-tags-4

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