Data-Driven BDD: Scenario Outlines and Parametrize in pytest-BDD
pytest-BDD supports three layers of data-driven testing: Scenario Outlines in feature files, @pytest.mark.parametrize on scenario functions, and params on fixtures. Understanding when to use each — and how to combine them — is the key to eliminating test duplication without sacrificing readability.
Key Takeaways
- Scenario Outlines belong in feature files when the data variation is domain-meaningful and non-developers need to read it
@pytest.mark.parametrizebelongs in step files when the variation is technical (environment URLs, database backends, browser engines)- Fixture
paramsrun the same scenario across infrastructure variations silently — useful for cross-database or cross-region tests - You can nest all three; pytest generates one test ID per combination
- Use
pytest.param(..., id="readable-name")to make parametrized test IDs human-readable in CI output
Three Ways to Parametrize
Before writing any code, understand which tool fits your situation:
| Approach | Who reads the data | Data lives in | Example use case |
|---|---|---|---|
| Scenario Outline | Developers + PMs | Feature file | Valid/invalid login combinations |
@pytest.mark.parametrize |
Developers only | Step file | Same scenario across 3 API versions |
Fixture params |
Developers only | conftest.py | Same test against SQLite and PostgreSQL |
Scenario Outlines
A Scenario Outline replaces repeated scenarios that differ only in data. The Examples table provides rows, and each row becomes an independent test run.
# features/login.feature
Feature: User login validation
Scenario Outline: Login form validation
Given the login form is visible
When I enter "<email>" and "<password>"
And I submit the form
Then I should see "<expected_message>"
And the form should show status "<status>"
Examples: Valid credentials
| email | password | expected_message | status |
| admin@example.com | correct123 | Welcome, admin | success |
| user@example.com | mypassword | Welcome, user | success |
Examples: Invalid credentials
| email | password | expected_message | status |
| admin@example.com | wrongpass | Invalid credentials | error |
| nobody@example.com | anything | User not found | error |
| notanemail | pass | Invalid email format | error |Multiple Examples blocks in one Outline generate separate test groups in pytest output, making it easy to see which category failed.
Step definitions parse the angle-bracket placeholders automatically:
# steps/test_login.py
import pytest
from pytest_bdd import scenario, given, when, then
from pytest_bdd import parsers
@scenario("../features/login.feature", "Login form validation")
def test_login_validation():
pass
@given("the login form is visible", target_fixture="browser_page")
def login_page(browser):
browser.goto("http://localhost:8000/accounts/login/")
assert browser.locator("form").is_visible()
return browser
@when(parsers.parse('I enter "{email}" and "{password}"'))
def enter_credentials(browser_page, email, password):
browser_page.fill('[name="email"]', email)
browser_page.fill('[name="password"]', password)
@when("I submit the form")
def submit_form(browser_page):
browser_page.click('[type="submit"]')
browser_page.wait_for_load_state("networkidle")
@then(parsers.parse('I should see "{message}"'))
def check_message(browser_page, message):
assert browser_page.locator(f"text={message}").is_visible(), (
f"Could not find '{message}' on page"
)
@then(parsers.parse('the form should show status "{status}"'))
def check_status(browser_page, status):
selector = f'[data-status="{status}"]'
assert browser_page.locator(selector).count() > 0, (
f"Status element '{selector}' not found"
)Scenario Outlines for API Validation
Outlines shine for REST API validation where the structure of request and expected response is identical but the data varies:
# features/api_validation.feature
Feature: Order API validation
Scenario Outline: Create order input validation
Given I am authenticated
When I submit an order with:
| product_id | <product_id> |
| quantity | <quantity> |
| address | <address> |
Then the response status should be <http_status>
And the error field should be "<error_field>"
Examples:
| product_id | quantity | address | http_status | error_field |
| 1 | 1 | 123 Main St | 201 | |
| 999 | 1 | 123 Main St | 404 | product_not_found |
| 1 | 0 | 123 Main St | 422 | quantity_too_low |
| 1 | 10001 | 123 Main St | 422 | quantity_too_high |
| 1 | 1 | | 422 | address_required |@scenario("../features/api_validation.feature", "Create order input validation")
def test_create_order_validation():
pass
@given("I am authenticated", target_fixture="auth_headers")
def auth_headers():
import requests
r = requests.post(
"http://localhost:8000/auth/login",
json={"username": "testuser", "password": "testpass"},
)
token = r.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@when("I submit an order with", target_fixture="order_response")
def submit_order(auth_headers, datatable):
import requests
payload = {row["field"]: row["value"] for row in datatable}
# Convert types
if "product_id" in payload:
payload["product_id"] = int(payload["product_id"])
if "quantity" in payload:
payload["quantity"] = int(payload["quantity"])
return requests.post(
"http://localhost:8000/orders/",
json=payload,
headers=auth_headers,
)
@then(parsers.parse("the response status should be {status:d}"))
def check_status_code(order_response, status):
assert order_response.status_code == status
@then(parsers.parse('the error field should be "{field}"'))
def check_error_field(order_response, field):
if not field:
return # No error expected on successful requests
data = order_response.json()
assert "error" in data, f"No 'error' key in response: {data}"
assert data["error"] == field, f"Expected error '{field}', got '{data['error']}'"@pytest.mark.parametrize on Scenario Functions
When the variation is infrastructure-level (not domain-meaningful), keep it out of feature files and use parametrize on the test function:
# steps/test_cross_environment.py
import pytest
from pytest_bdd import scenario
@pytest.mark.parametrize("base_url", [
pytest.param("http://localhost:8000", id="local"),
pytest.param("https://staging.example.com", id="staging"),
pytest.param("https://prod.example.com", id="prod"),
])
@scenario("../features/health.feature", "API health check")
def test_health_check(base_url):
pass
@pytest.fixture
def api_base(base_url):
return base_urlpytest generates three test IDs: test_health_check[local], test_health_check[staging], test_health_check[prod]. Run only one with:
pytest -k "health_check and local"Combining Scenario Outlines with parametrize
You can layer both. The feature file defines domain variations; parametrize adds technical variations. The total test count is (outline rows) × (parametrize values).
@pytest.mark.parametrize("db_backend", [
pytest.param("sqlite", id="sqlite"),
pytest.param("postgres", id="postgres"),
])
@scenario("../features/users.feature", "Create a new user")
def test_create_user_multi_db(db_backend):
passIf the Examples table has 4 rows, this generates 8 tests: 4 × 2 backends.
Fixture params for Infrastructure Variation
Fixture parametrize is cleaner than function parametrize when the variation requires setup/teardown:
# conftest.py
import pytest
@pytest.fixture(
params=[
pytest.param({"driver": "sqlite", "url": "sqlite:///:memory:"}, id="sqlite"),
pytest.param({"driver": "postgres", "url": "postgresql://user:pass@localhost/test"}, id="postgres"),
]
)
def db_config(request):
config = request.param
from sqlalchemy import create_engine
from myapp.models import Base
engine = create_engine(config["url"])
Base.metadata.create_all(engine)
yield engine
Base.metadata.drop_all(engine)
engine.dispose()Every test that depends on db_config runs twice. This is the best approach when:
- Setup/teardown is required per parameter (containers, connections)
- The variation is invisible to the feature file author
- You want the same Gherkin to run against multiple backends
Parametrizing with indirect
indirect=True tells pytest to pass the parameter through a fixture rather than directly into the test function. This enables complex object construction from simple parameter values:
@pytest.fixture
def user_role(request):
"""Turn a role name into a User instance with the right permissions."""
role = request.param
from factories import UserFactory
from django.contrib.auth.models import Group
user = UserFactory()
group, _ = Group.objects.get_or_create(name=role)
user.groups.add(group)
return user
@pytest.mark.parametrize("user_role", ["admin", "editor", "viewer"], indirect=True)
@scenario("../features/dashboard.feature", "View the dashboard")
def test_dashboard_access(user_role):
passData Tables in Step Definitions
For inline parametrize via Gherkin tables (not Outlines), pytest-BDD exposes the datatable as a list of dicts:
Scenario: Batch user creation
Given the following users exist:
| username | role | email |
| alice | admin | alice@example.com |
| bob | editor | bob@example.com |
| carol | viewer | carol@example.com |
When I request the user list
Then I should see 3 users@given("the following users exist", target_fixture="created_users")
def create_users(datatable):
from factories import UserFactory
users = []
for row in datatable:
user = UserFactory(
username=row["username"],
email=row["email"],
)
users.append(user)
return usersReadable Test IDs
By default, parametrized test IDs include the raw parameter value. For complex objects or long strings, this is noisy. Use pytest.param(..., id=...):
@pytest.mark.parametrize("payload,expected_status", [
pytest.param(
{"username": "alice", "email": "alice@example.com"},
201,
id="valid-user",
),
pytest.param(
{"username": "", "email": "alice@example.com"},
422,
id="empty-username",
),
pytest.param(
{"username": "alice", "email": "not-an-email"},
422,
id="invalid-email",
),
])
@scenario("../features/users.feature", "User creation validation")
def test_user_creation(payload, expected_status):
passCI output becomes:
PASSED test_user_creation[valid-user]
FAILED test_user_creation[empty-username]
PASSED test_user_creation[invalid-email]Running Specific Parametrize Combinations
# All parametrize combinations
pytest tests/ -v
# Only the "sqlite" backend
pytest tests/ -k "sqlite" -v
# Only outline examples in a specific group
pytest tests/ -k "Valid credentials" -v
# Show all parametrize IDs without running
pytest tests/ --collect-only -qAnti-Patterns
Overloading Scenario Outlines with technical data. If rows contain database connection strings or internal service URLs, move them to parametrize or fixture params. Feature files should contain domain data only.
Giant Examples tables. If an Outline has 30 rows, most of them are probably redundant. Group by equivalence class — one valid case, one of each error type — and delete the rest.
Parametrize duplicating Scenario Outlines. If you have a Scenario Outline with 5 rows AND parametrize with 3 values AND fixture params with 2 values, you get 30 tests. That's often more coverage than the risk warrants. Start with the feature file Outline; add parametrize only when you need infrastructure variation.
Not naming parameters. A test ID of test_login[alice@example.com-correctpass-Welcome-success-0] is useless in a CI log. Always add id= to pytest.param.
Conclusion
pytest-BDD's parametrize story has three tools for three different audiences. Feature file Outlines are for product people and QA leads who need to see the domain cases. Function-level parametrize is for developers testing the same behaviour across environments. Fixture params are for infrastructure variation that the feature file author shouldn't care about. Use them at the right layer, name your combinations, and CI output stays readable even as your test suite grows.