pytest-bdd: BDD Testing with Gherkin in Python
pytest-bdd brings Behavior-Driven Development to Python by letting you write plain-English Gherkin scenarios and execute them with the pytest runner. It integrates natively with pytest fixtures, parametrize, and plugins, making BDD a first-class citizen in any Python test suite.
Key Takeaways
pytest-bdd runs on top of pytest. You keep every pytest feature — fixtures, plugins, markers, parallel execution — while adding Gherkin-style scenarios on top.
Feature files are the specification. .feature files written in Gherkin become living documentation that non-engineers can read and validate.
Step definitions map text to code. The @given, @when, and @then decorators bind natural-language phrases to Python functions.
Fixtures integrate seamlessly. pytest fixtures can be injected into step definitions by name, so shared setup and teardown reuse the same mechanism you already know.
Scenario Outlines eliminate duplication. The Examples: table in Gherkin maps directly to parametrized runs, keeping your scenarios DRY without custom loops.
What Is pytest-bdd?
pytest-bdd is a Python library that adds Gherkin scenario execution to the pytest test runner. Unlike standalone BDD tools that ship their own CLI, pytest-bdd is a pytest plugin. That means your BDD scenarios run with pytest, appear in the same test report, and work with every pytest plugin you already use — coverage, xdist, html, allure, and so on.
The library maps Gherkin steps (Given/When/Then) to ordinary Python functions decorated with @given, @when, and @then. Because these functions live inside normal pytest modules, they can accept pytest fixtures as arguments, giving you access to the full pytest dependency-injection system without any extra ceremony.
When to choose pytest-bdd
- Your team is already on Python and pytest
- Product managers or QA analysts write Gherkin, developers implement steps
- You want BDD living documentation without leaving the pytest ecosystem
- You need fine-grained control over fixtures and parametrization that heavier frameworks don't provide
Installation
pip install pytest-bddpytest-bdd requires Python 3.8+ and pytest 6+. No external test runner, no separate configuration file beyond pytest.ini or pyproject.toml.
Verify the installation:
pytest --version
# pytest 8.x.x with plugins: bdd-...Writing Your First Feature File
Feature files use the Gherkin language. Create a directory features/ at the root of your project.
# features/shopping_cart.feature
Feature: Shopping cart
As a customer
I want to add products to my cart
So that I can purchase multiple items at once
Scenario: Adding a single item
Given the shop has a product "Wireless Mouse" priced at 29.99
When I add "Wireless Mouse" to my cart
Then my cart should contain 1 item
And the cart total should be 29.99
Scenario: Removing an item
Given I have "Wireless Mouse" in my cart
When I remove "Wireless Mouse" from my cart
Then my cart should be emptyThe Feature: block is documentation. The Scenario: blocks are the executable tests. Each step (Given / When / Then / And / But) maps to a Python function.
Implementing Step Definitions
Create a test file — by convention inside a tests/ or step_defs/ directory — and bind the Gherkin steps to Python code.
# tests/test_shopping_cart.py
import pytest
from pytest_bdd import scenarios, given, when, then, parsers
from myapp.cart import Cart, Product
# Link this module to the feature file
scenarios("../features/shopping_cart.feature")
# ── Shared state via fixture ──────────────────────────────────────────────────
@pytest.fixture
def cart():
return Cart()
@pytest.fixture
def shop_inventory():
return {}
# ── Step definitions ──────────────────────────────────────────────────────────
@given(parsers.parse('the shop has a product "{name}" priced at {price:f}'))
def product_in_shop(shop_inventory, name, price):
shop_inventory[name] = Product(name=name, price=price)
@when(parsers.parse('I add "{name}" to my cart'))
def add_to_cart(cart, shop_inventory, name):
product = shop_inventory[name]
cart.add(product)
@then(parsers.parse("my cart should contain {count:d} item"))
def cart_item_count(cart, count):
assert len(cart.items) == count
@then(parsers.parse("the cart total should be {total:f}"))
def cart_total(cart, total):
assert cart.total == pytest.approx(total)A few things to note:
scenarios("../features/shopping_cart.feature")registers all scenarios in the file as individual pytest test items.parsers.parseuses Python'sstr.format-style type codes ({price:f},{count:d}) to extract typed values from step text.cartandshop_inventoryare ordinary pytest fixtures injected by name.
Using Fixtures for Shared Setup
pytest-bdd step functions are regular pytest functions. Any pytest fixture defined in conftest.py is available by argument name.
# conftest.py
import pytest
from myapp.database import Database
@pytest.fixture(scope="session")
def db():
database = Database("sqlite:///:memory:")
database.migrate()
yield database
database.drop_all()
@pytest.fixture(autouse=True)
def rollback(db):
"""Wrap every test in a transaction that rolls back afterwards."""
with db.transaction() as txn:
yield txn
txn.rollback()Step definitions that need the database simply declare db as an argument:
@given(parsers.parse('a user "{username}" exists'))
def existing_user(db, username):
db.users.insert(username=username, password_hash="hashed")Scenario Outlines and the Examples Table
Scenario Outlines let you run the same scenario with multiple data sets. The Examples: table drives parametrization.
# features/discount.feature
Feature: Volume discounts
Scenario Outline: Applying quantity discounts
Given I have <quantity> units of "Widget" in my cart
When the discount engine runs
Then the discount should be <discount_percent>%
Examples:
| quantity | discount_percent |
| 1 | 0 |
| 5 | 5 |
| 10 | 10 |
| 50 | 20 |pytest-bdd generates one test per row automatically. The parametrized values are injected into step functions through the same parsers.parse mechanism:
@given(parsers.parse("I have {quantity:d} units of {product} in my cart"))
def add_quantity(cart, shop_inventory, quantity, product):
product_obj = shop_inventory.get(product.strip('"'), Product(product.strip('"'), 9.99))
for _ in range(quantity):
cart.add(product_obj)
@then(parsers.parse("the discount should be {discount_percent:d}%"))
def check_discount(cart, discount_engine, discount_percent):
assert discount_engine.calculate(cart) == discount_percentRunning pytest -v will show each row as a separate test item:
tests/test_discount.py::test_applying_quantity_discounts[quantity=1-discount_percent=0] PASSED
tests/test_discount.py::test_applying_quantity_discounts[quantity=5-discount_percent=5] PASSED
tests/test_discount.py::test_applying_quantity_discounts[quantity=10-discount_percent=10] PASSED
tests/test_discount.py::test_applying_quantity_discounts[quantity=50-discount_percent=20] PASSEDOrganising with conftest.py
For large projects, keep step definitions close to the features they serve but share common steps through conftest.py.
project/
├── features/
│ ├── cart/
│ │ ├── shopping_cart.feature
│ │ └── discount.feature
│ └── auth/
│ └── login.feature
├── tests/
│ ├── conftest.py ← shared fixtures and steps
│ ├── cart/
│ │ ├── conftest.py ← cart-specific fixtures
│ │ └── test_cart.py
│ └── auth/
│ ├── conftest.py
│ └── test_auth.pySteps decorated with @given/@when/@then and placed in conftest.py are automatically discovered by pytest-bdd across all test modules in that directory tree, so you never duplicate the "I am logged in as" step.
Hooks and Tags
pytest-bdd respects pytest markers. You can tag scenarios in Gherkin with @ prefixes and map them to pytest marks:
@slow @integration
Scenario: Full checkout flow
...# conftest.py
from pytest_bdd import scenarios
import pytest
def pytest_collection_modifyitems(items):
for item in items:
if "slow" in item.nodeid:
item.add_marker(pytest.mark.slow)Run only tagged scenarios:
pytest -m "not slow"
pytest -m integrationFor before/after scenario hooks, use standard pytest fixtures with yield:
@pytest.fixture(autouse=True)
def scenario_hook(request):
print(f"\n→ Starting: {request.node.name}")
yield
print(f"\n✓ Finished: {request.node.name}")Running pytest-bdd
# Run all BDD tests
pytest tests/
# Show step names in verbose mode
pytest -v tests/
# Generate an HTML report (requires pytest-html)
pytest --html=report.html tests/
# Run in parallel (requires pytest-xdist)
pytest -n auto tests/
# Show which steps are undefined (helpful during development)
pytest --collect-only tests/ 2>&1 | grep "no definition"Generating Step Stubs
pytest-bdd can generate stub step definitions from an existing feature file, which saves time when implementing steps from a spec written by a product team:
pytest-bdd generate features/shopping_cart.featureOutput:
@given(u'the shop has a product "Wireless Mouse" priced at 29.99')
def step_impl():
raise NotImplementedError(u'STEP: Given the shop has a product "Wireless Mouse" ...')Copy the stubs into your test module and fill in the implementation.
Integration with HelpMeTest
If your team uses BDD scenarios to specify behavior, you want confidence that those scenarios keep passing in production — not just in CI. HelpMeTest lets you run plain-English test scenarios against your live application continuously, acting as a complement to your pytest-bdd suite.
Where pytest-bdd covers the unit and integration layer (fast, offline, against mocks or a local DB), HelpMeTest covers the end-to-end layer: click the button, fill the form, see the result — on the real deployed URL. Because both tools share the same Given/When/Then vocabulary, mapping your Gherkin scenarios to HelpMeTest monitor tests is straightforward.
The combination gives you two safety nets: pytest-bdd catches regressions on every commit, HelpMeTest catches runtime failures in production.
Common Pitfalls
Ambiguous step matching. If two step definitions match the same text, pytest-bdd raises AmbiguousSteps. Use more specific parsers.parse patterns or parsers.re (regex) to disambiguate.
Fixture scope mismatches. A session-scoped fixture injected into a function-scoped step works fine, but the reverse (function-scoped into session-scoped) raises an error. Design your fixture scopes to reflect actual lifetime requirements.
Feature file encoding. Always save .feature files as UTF-8. Non-ASCII characters in step text can cause parse failures on Windows if the default encoding differs.
Too much logic in steps. Step definitions should read like assertions, not implement business logic. If a step is longer than 10 lines, the logic belongs in a helper or the application itself.
Summary
pytest-bdd is the pragmatic choice for Python teams that want BDD without abandoning pytest. You write Gherkin feature files as living specifications, implement steps as ordinary Python functions, and leverage the full pytest ecosystem — fixtures, plugins, markers, parallel execution — with no extra overhead. Combine it with HelpMeTest for continuous end-to-end monitoring, and your BDD scenarios become both a development spec and a production health check.