Data-Driven BDD: Scenario Outlines and Parametrize in pytest-BDD

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.parametrize belongs in step files when the variation is technical (environment URLs, database backends, browser engines)
  • Fixture params run 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_url

pytest 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):
    pass

If 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):
    pass

Data 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 users

Readable 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):
    pass

CI 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 -q

Anti-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.

Read more

Start now free