Scenarios & steps¶
A narrated test uses two things: the @scenario decorator, which puts the test in the report, and the steps given, when and then, which describe what it does.
@scenario¶
@scenario(name, tags=None, *, stories=None, pins=None, group_parametrized=True)
Only tests with @scenario appear in the report. The decorator returns your function unchanged.
stories=takes one story or a list of stories, andpins=one sentence handle or a list. Both link the scenario to domain stories; see Domain Storytelling.group_parametrized=Falseturns off grouping for a parametrized test; see Parametrized scenarios.
tags= takes any strings. Use / to nest tags in the report's Tags sidebar: ticket/ABC-123 appears under a ticket heading, and selecting the heading shows all tags below it. Tags only affect the report; they are not pytest marks.
given / when / then¶
given(text, *, pins=None), when(text, *, pins=None), then(text, *, pins=None)
You can use a step in four places: as a context manager in a test body, as a decorator on a fixture or a helper function, or as an Annotated label on a test parameter.
pins= links the step to specific story sentences; see Pins.
In a test body¶
Each with block narrates the code inside it:
with given('an empty cart'):
cart = []
with when('I add an item'):
cart.append('coffee')
with then('the cart has one item'):
assert len(cart) == 1
Choosing the phase¶
Choose the phase by what the code does:
givensets things up. This includes setup calls that change state, likemachine.insert(200)or seeding a database.whenperforms the one action under test.thenonly checks the outcome.
The narration lint catches common mistakes, like setup hidden in a second when, or an action done inside a then's assertion.
Steps outside a scenario¶
A step in a test without @scenario does nothing, except emit a pytest_given.PytestGivenWarning. If you share a helper between narrated and plain tests, you can silence the warning with filterwarnings = ["ignore::pytest_given.PytestGivenWarning"].
On fixtures¶
Fixtures are setup, so only @given is allowed on a fixture. @when or @then on a fixture raises an error when the fixture runs:
Generator fixtures work too, but only the part before yield is narrated. The teardown after yield isn't recorded, and a step or attach there raises PytestGivenError.
A fixture's label must be a plain string; @given(Template(...)) on a fixture raises an error. If the label needs to change, move the step into a helper function.
On test parameters¶
With Annotated, you can add a given step to a test parameter: a fixture or a @pytest.mark.parametrize value. Use it to:
- show a parametrized value as a
givenstep (otherwise it only appears in the parameter table) - label a fixture that has no
@given, including pytest's built-in fixtures - replace a fixture's
@givenlabel in one scenario
Only given is allowed here:
from typing import Annotated
@scenario('Underpayment is rejected')
@pytest.mark.parametrize('cents', [0, 50, 199])
def test_rejects_underpayment(
machine, cents: Annotated[int, given(Template('{cents} cents inserted'))]
):
with (
when_then('a customer tries to buy a coffee',
'the machine reports the shortfall'),
pytest.raises(ValueError, match='insufficient'),
):
buy_coffee(machine, cents)
The report shows a Template placeholder as {cents} in the grouped scenario, and the actual value in each row of the parameter table. when and then raise an error here, because the action and its check belong in the test body.
The label takes pins= like any step. On a fixture with its own @given(..., pins=...), the label's pins replace the fixture's pins; pins=[] removes them. Pins of steps inside the fixture body stay as they are.
On helper functions¶
given, when and then can all decorate a helper function. The helper then records a step each time it's called. To put argument values into the step text, use pytest_given.Template with the helper's parameter names:
@when('inserting money')
def insert(amount):
...
@when(Template('I insert ${amount}'))
def insert(amount):
...
This works the same for async def helpers and async generator fixtures.
Nesting steps¶
You can nest steps of the same phase, for example to split one action into named sub-actions:
with when('I place a large order'):
with when('I select 3 coffees'):
order_count = 3
with when('I apply loyalty discount'):
...
Nesting a different phase raises PytestGivenError, for example a then inside a when. This includes decorated helpers: calling a @when helper inside a given block raises too. So if you call a helper from more than one phase, don't decorate it; narrate it where you call it instead.
Expected failures¶
A scenario that fails as expected gets the status xfailed. That covers @pytest.mark.xfail, a pytest.param(..., marks=pytest.mark.xfail) row of a parametrized test, and pytest.xfail() in the body. The report keeps its steps up to where it broke, the error that broke it and the reason, and does not count it as failing.
The main use is a scenario written ahead of its implementation: an executable statement of planned behavior.
@scenario('Loyalty card earns a free coffee')
@pytest.mark.xfail(strict=True, reason='loyalty cards not implemented yet')
def test_loyalty_card_free_coffee(machine):
...
Mark it strict=True, or set the xfail_strict ini option, so the run fails once the scenario starts passing and the mark comes off. A non-strict mark passes quietly and stays, so a later regression reports as an expected failure instead of failing the run.
when_then¶
when_then(when_text, then_text)
Sometimes one call is both the action and the thing you check, most often when you expect an exception. Combine when_then with pytest.raises to narrate the action and its outcome in one with:
from pytest_given import when_then
@scenario('Sold out is rejected')
def test_sold_out(machine):
with given('a machine that has sold its last coffee'):
machine['coffees'] = 0
with (
when_then('a customer tries to buy a coffee',
'the machine reports it is sold out'),
pytest.raises(ValueError, match='sold out'),
):
buy_coffee(machine)
The body runs as the when step. The then step is recorded after the body finishes without an error (here: after pytest.raises catches the exception). If an exception escapes the body, only the when is recorded, and the exception propagates as usual.