The entire BDD framework relies on you writing scenarios in plain language and then connecting them to tests. The trouble starts when feature files become disconnected from the code or teams spend more time maintaining step definitions than discussing product behavior.
Pytest-BDD is a Python-based testing framework that integrates Behavior-Driven Development (BDD) into the Pytest ecosystem. It allows testers to write test scenarios in Gherkin syntax (Given-When-Then) and map them to Python functions.
In this guide, you’ll learn how Pytest-BDD works, how to set it up, write feature files and step definitions, run tests, and avoid the maintenance problems that often make BDD suites difficult to scale.
What is Behavior Driven Development (BDD)?
Behavior-Driven Development (BDD) helps you describe how an application should behave before you start thinking about test implementation. Instead of writing tests directly in code, you define expected user behavior in plain language so developers, testers, and business stakeholders can all contribute to the same conversation.
These requirements are usually written as Gherkin scenarios using the familiar Given–When–Then format. Because the scenarios are easy to read, they can serve as both product documentation and automated tests once they’re connected to step definitions.
What I’ve found most useful is that BDD keeps everyone focused on user outcomes instead of implementation details. Rather than discussing methods or APIs, teams talk about what the user is trying to achieve, making requirements easier to understand, review, and automate.
Read More: BDD vs TDD vs ATDD
Why Teams Choose Pytest-BDD
I’ve found that Pytest-BDD is most valuable when teams want their requirements and automated tests to stay in sync. Instead of maintaining separate user stories, documentation, and test cases, the same Gherkin scenarios become a shared reference for product discussions and test automation.
Some of the biggest advantages include:
- Keeps requirements aligned with implementation: Feature files describe expected behavior in a format that both technical and non-technical teams can understand, reducing misunderstandings before development begins.
- Improves release confidence: Every automated scenario confirms that important user journeys continue to work as new features and code changes are introduced.
- Reduces duplicate documentation: Feature files act as both living documentation and executable tests, so teams spend less time updating separate specifications and test cases.
- Fits naturally into existing Pytest projects: If your team already uses Pytest, you can introduce BDD without rebuilding your automation framework or changing your development workflow.
- Makes large test suites easier to maintain: Reusable fixtures, shared step definitions, and the broader Pytest ecosystem help keep automation organized as the application grows.
Using Pytest Fixtures to Reuse Test Setup
As your test suite grows, you’ll quickly notice the same setup code appearing in multiple tests. Creating test data, initializing objects, or connecting to a database repeatedly makes tests harder to maintain. Pytest fixtures solve this by letting you define the setup once and reuse it wherever it’s needed.
One of the reasons I like fixtures is that they keep test cases focused on the behavior being tested instead of the preparation required to run them. You can also control how often a fixture runs using scopes such as function, class, module, or session, which helps reduce unnecessary setup and speeds up larger test suites.
import pytest
@pytest.fixture(scope="module")
def sample_data():
return {
"name": "Alice",
"age": 30
}
def test_sample_1(sample_data):
assert sample_data["name"] == "Alice"
def test_sample_2(sample_data):
assert sample_data["age"] == 30Here, the sample_data fixture is created only once for the entire module. Both tests receive the same data without recreating it each time, making the suite more efficient and keeping the setup logic in one place.
When you’re using Pytest-BDD, fixtures become even more useful because the same setup can be shared across multiple Gherkin scenarios. Instead of repeating login steps, test users, or configuration data in every scenario, you define them once as fixtures and reuse them throughout your feature files.
Setting Up Your Environment
Getting started with Pytest-BDD doesn’t take much if you already use Pytest. I normally set up a clean virtual environment first so project dependencies stay isolated, then install only the packages needed for the test framework before adding browser or API libraries later.
1. Install Python
Make sure Python is installed and available from your terminal.
python --version
2. Create a Virtual Environment
Using a virtual environment keeps your project’s dependencies separate from other Python installations.
python -m venv venv
Activate it:
Linux/macOS
source venv/bin/activate
Windows
.\venv\Scripts\activate
3. Install Pytest and Pytest-BDD
Install the testing framework together with the BDD plugin.
pip install pytest pytest-bdd
4. Add Project-Specific Libraries
Install any additional packages your tests depend on. For example, if you’re automating browser interactions:
pip install selenium
Similarly, you might install libraries such as requests for API testing or playwright for browser automation, depending on your project.
5. Organize Your Project
I like keeping feature files separate from Python test code because the structure remains easy to navigate as the project grows.
project/ ├── features/ │ └── login.feature ├── tests/ │ └── test_login.py ├── conftest.py └── requirements.txt
With this setup in place, you’re ready to start writing Gherkin feature files, connect them to step definitions, and execute your first Pytest-BDD tests.
Read More: How to achieve advanced BDD test automation
Writing Your First Pytest-BDD Test
A Pytest-BDD test has three parts: a feature file that describes the expected behavior, step definitions that connect each step to Python code, and the test execution that runs everything together. Once you understand this flow, adding new scenarios becomes much easier.
Step 1: Write a Feature File
I always start with the feature file because it captures the expected behavior before writing any automation. These files use Gherkin syntax, making them easy for developers, testers, and product owners to read.
A feature file typically contains:
- Feature: The functionality you’re testing.
- Scenario: A specific user journey or business case.
- Given: The starting condition.
- When: The action performed.
- Then: The expected outcome.
For example:
Feature: Login functionality Scenario: Successful login Given the user is on the login page When the user enters valid credentials Then the user should be logged in
This scenario describes the behaviour without exposing any implementation details. The automation comes next.
Step 2: Connect the Scenario to Python
Each Gherkin step is linked to a Python function using decorators such as @given, @when, and @then. This is where the actual automation happens.
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from pytest_bdd import scenarios, given, when, then
scenarios('../features\login.feature')
@pytest.fixture
def driver():
browser = webdriver.Chrome()
yield browser
browser.quit()
@given('the user is on the login page')
def navigate_to_login(driver):
driver.get("https://bstackdemo.com/signin")
@when('the user enters valid credentials')
def enter_credentials(driver):
#select username
driver.find_element(by=By.ID, value="username").click()
driver.find_element(by=By.CSS_SELECTOR,value="#react-select-2-option-0-0").click()
#select password
driver.find_element(by=By.ID,value="password").click()
driver.find_element(by=By.CSS_SELECTOR, value="#react-select-3-option-0-0").click()
#click submit
driver.find_element(By.ID,"login-btn").click()
@then('the user should be logged in')
def verify_login(driver):
assert driver.title=="StackDemo"One thing I like about this approach is that the feature file stays readable while the Python code handles the implementation. If the login process changes, you usually update the step definition rather than rewriting the scenario itself.
Step 3: Run the Test
Once the feature file and step definitions are in place, running the test is no different from running any other Pytest suite.
Pytest
Pytest automatically discovers the feature file, matches each Gherkin step with its corresponding Python function, and executes the scenario. If a step fails, the output clearly shows which part of the user journey broke, making failures much easier to understand than traditional test logs.
This separation between requirements (feature files) and implementation (step definitions) is what makes Pytest-BDD easier to maintain as the number of scenarios grows.
How to Use Tags in Pytest?
Tags are useful when you want to selectively run certain tests based on their labels. You can assign tags to scenarios in feature files and use pytest to run tests associated with specific tags.
Example of using tags in a feature file:
Feature: Login functionality @smoke Scenario: Successful login Given the user is on the login page When the user enters valid credentials Then the user should be logged in
To run tests with a specific tag, use the -m flag with the tag name:
pytest -m smoke
This command will only execute scenarios marked with the @smoke tag, allowing you to organize your tests into different categories such as smoke, regression, or feature-based testing.
How to Use Scenario Outlines and Parameterization
Pytest-BDD allows you to efficiently test multiple input-output combinations using scenario outlines. This is done by defining variables in the Examples table within the .feature file and referencing them in your steps.
Feature File Example (login.feature):
gherkin
Scenario Outline: Successful login with valid credentials Given the user navigates to the login page When the user logs in with username "<username>" and password "<password>" Then the login should be successful Examples: | username | password | | user1 | pass123 | | user2 | pass456 |
Step Definition Example (test_login.py):
python
@when(parsers.parse('the user logs in with username "{username}" and password "{password}"'))
def login_user(username, password):
# Perform login logic here
assert username.startswith('user')This structure avoids code duplication and helps you test multiple cases with one reusable scenario.
How to Use Tags and Selective Test Execution
Tags in Pytest-BDD allow you to label scenarios or features for selective execution. This is especially useful for running subsets of tests (e.g., smoke, regression).
Feature File Example:
gherkin
@smoke Scenario: Check homepage title Given the user opens the homepage Then the title should be "Welcome"
To run only the @smoke tagged tests, use:
bash
pytest --gherkin-terminal-reporter -m smoke
To run all except a certain tag:
bash
pytest -m "not regression"
Make sure your pytest.ini includes:
ini
[pytest] markers = smoke: smoke tests regression: regression tests
How to Use Hooks and Fixtures
Pytest-BDD supports Pytest hooks and fixtures for powerful test setup and teardown management. You can define shared resources, authentication, or data cleanup logic using fixtures.
Example: Defining a fixture for setup:
python
import pytest @pytest.fixture def browser(): # Setup code here (e.g., launching browser) yield "browser_instance" # Teardown code here (e.g., closing browser)
Using a BDD hook:
python
from pytest_bdd import hooks
@hooks.hookimpl
def pytest_bdd_before_scenario(request, feature, scenario):
print(f"\n[Setup] Starting scenario: {scenario.name}")Combining with Step Definition:
python
@given("the user is logged in")
def user_logged_in(browser):
# Use the browser fixture to log in
assert browser == "browser_instance"Hooks give you fine-grained control over the test lifecycle, while fixtures help keep the code modular and maintainable.
Writing Maintainable BDD Tests
As your BDD suite grows, maintaining it often becomes harder than writing the initial scenarios. I’ve found that keeping feature files concise, reusing common steps, and organizing the project well makes a much bigger difference than adding more automation.
Keep these practices in mind:
- Write scenarios for people first: Use simple Gherkin statements that describe user behaviour rather than implementation details. If a product owner can’t understand the scenario, it’s probably too technical.
- Reuse step definitions: Avoid creating multiple step definitions that perform the same action. Shared steps reduce duplication and make updates much easier when the application changes.
- Organize tests with tags: Use tags such as @smoke, @regression, or @login to group related scenarios and run only the tests you need during development or CI/CD.
- Move setup into fixtures: Store common setup tasks, test data, and reusable objects in Pytest fixtures instead of repeating them across multiple step definitions.
- Separate requirements from implementation: Keep feature files focused on user behaviour and place the automation logic in Python step definition files. This keeps both easier to read and maintain.
- Keep scenarios focused: Each scenario should test one business behaviour. Trying to cover multiple workflows in a single scenario makes failures harder to diagnose.
- Keep execution fast: Remove unnecessary UI interactions, reuse fixtures where appropriate, and run tests in parallel when possible. Fast feedback makes BDD much more practical in CI/CD pipelines.
Conclusion
I’ve found that Pytest-BDD works best when teams want to keep product requirements and automated tests closely aligned without giving up the flexibility of Pytest. Feature files make user behaviour easy to discuss, while step definitions and fixtures keep the automation organized as the test suite grows.
Like any testing approach, success depends on how well it’s maintained rather than the framework itself. Keeping scenarios readable, reusing common steps, and treating feature files as living documentation helps teams build BDD suites that remain valuable long after the first release.