pytestHTML Reporter
Home Docs Test steps, Gherkin and markers
Guides

Test steps, Gherkin and markers

A status column tells you a test failed. Steps tell you where, and how long it had been running when it got there. This is the whole of the Test Steps tab: the API you write, the Gherkin you already have, and the markers you never wrote down.

What a step is

A step is a named, timed piece of a test. You name it; the reporter times it, files it under the phase it ran in, and nests it inside whatever step was already open. The result is a drill-down: the suite, then a test in it, then what that test actually did, with the slow part drawn as a bar you can see from across the room.

It is a tab of its own rather than a panel inside Test Suites, which is where Allure keeps the same information. The cost of folding it in is a high-level page you can no longer skim, and the high-level page is the one most people open first.

The tab is never empty

Every test has a set up, a body and a tear down. Each of the three is timed from pytest's own report and drawn whether or not anything was named inside it — a phase with no steps still answers where did the time go for a test whose setup is the slow part, and that is exactly the test least likely to have named a step. Alongside the phases, every test carries its markers, its parameters, the fixtures it named and its docstring.

So a suite that has never heard of step() still gets a tree. Naming steps makes that tree deeper; it does not bring it into existence.

TipA How it works button at the top of the tab opens the same cheatsheet the tab shows on a run where nobody named a step — so it is there when you go looking for it, not only before you needed it.

Seeing it first

There is a demo suite in the repository that exists for this. It needs no browser and no network — a page object whose methods are the steps, a fixture that sets up and tears down, a parametrized case and a failure buried three steps deep, with sleeps where the work would be.

shell The bundled demo
$ pytest tests/functional/test_steps.py --html-report=./report
The Test Steps tab: the suite rail on the left and one test open on the right with its Set up, Test body and Tear down blocks and per-step durations The Test Steps tab: the suite rail on the left and one test open on the right with its Set up, Test body and Tear down blocks and per-step durations
The Test Steps tab — the rail on the left, and the phases of the selected test with a duration on every step.

Getting there from a row

The Test Metrics table has a Steps column counting the steps each test recorded. Clicking it crosses to this tab with that one test already open; a test that recorded none shows a dash. The Suite and Test Case cells of the same row are clickable for the same reason — one opens the suite here, the other the test.

The rail has its own search box, matching on the suite, the test, every step title, every marker and — for a Gherkin test — the feature and the scenario. Parameters and error text are deliberately not indexed: they already run to several thousand characters across a run and would roughly double the size of the file for nothing. A test named test_a_declined_card is indexed under that spelling and under test a declined card, because the second is what people type.

Filtering the rail

Above the search box is a row of pills — All, Failed, and, when the run has both, Scenarios and Tests — each carrying its count. A filter that would empty the rail is not offered, and one that was selected before it stopped existing is let go.

From 0.4.1 there are up to three rows, and they answer different questions, which is why they are rows rather than more values on one filter. The second appears once anything in the run carries an owner: one pill per team, counted, busiest first, with an Unowned pill at the end for the tests nobody claimed. The third appears once anything carries a severity, drawn in ladder order — blocker first — rather than by how many tests are at each level, with Unrated last.

Each row counts inside the ones above it. Failed then a team gives that team's failures, and then a level gives that team's blocker failures; the number on a pill is always what the rail will show if you press it. A row is not drawn at all for a run that wrote none of that marker, so a suite using neither sees exactly the rail it had before.

See the report tour for how this tab sits beside the others.

Naming a step

One import, one name.

step(title, **params)
context manager decorator from pytest_html_reporter import step

title is the line the tab shows. Every keyword argument is kept beside the step as a parameter, so the report shows the call that was actually made rather than the sentence it was written from. The same object is both forms: used with with it times the block, used as a decorator it times the function.

python The context manager form
from pytest_html_reporter import step

def test_checkout():
    with step("Add to cart", sku="A-12"):
        cart.add("A-12")

    with step("Charge the card"):
        assert gateway.charge(cart).ok

That test reports two steps under Test body, the first carrying sku=A-12. Nothing else changes: step does not swallow exceptions, does not alter the test's outcome and needs no fixture.

NoteA title is kept to 160 characters and a parameter value to 120, each cut with an ellipsis. Steps indent up to twelve levels deep; past that a step is still recorded and still timed, only its indentation stops growing — nothing legible happens at column forty.

Decorating the code your tests share

The methods of a page object or an API client are already the steps of every test that calls them. Decorating them once names all of those tests, and the arguments of the call fill in the {placeholders} of the title. This is the recipe worth reaching for first, because nothing in the tests themselves has to know that steps exist.

python The decorator form — a page object whose methods are the steps
from pytest_html_reporter import attach_json, step


class Cart:
    def __init__(self):
        self.items = []

    @step("Log in as {user}")
    def login(self, user):
        with step("Open the login page"):
            page.goto("/login")

        with step("Submit credentials"):
            attach_json({"user": user, "remember": True}, name="Credentials")
            page.click("#submit")

    @step("Add {sku} to the cart")
    def add(self, sku, quantity=1):
        self.items += [sku] * quantity


def test_a_shopper_can_buy_one_item():
    cart = Cart()
    cart.login("amy")          # the tab shows: Log in as amy, with user=amy beside it
    cart.add("A-12")

The arguments are read off the call by name, so {user} is filled from user however it was passed — positionally, by keyword or from a default. self and cls are never shown. A signature that will not bind, because of a decorator stacked in a way inspect cannot follow, costs the title its placeholders and nothing else; and a title naming something the call did not pass keeps its braces rather than losing its name.

Nesting, phases and failures

Steps nest by being called from inside one another

Nothing is passed between steps. A step opened while another is open is a step of it, which is why decorating a helper that itself uses with step(...) gives you a tree rather than a flat list.

A step opened in a fixture stays in its own phase

Steps are filed under Set up, Test body or Tear down depending on when they were opened, and nesting is counted from each phase's own floor. That matters for a fixture that holds a step open across its yield: without the phase floor every step the test body ran would be reported as a step of the fixture — one Open a session swallowing the test that used it.

python Setup and teardown steps land under their own phases
import pytest

from pytest_html_reporter import step


@pytest.fixture
def cart():
    with step("Open a session"):
        session.connect()

    yield Cart()

    with step("Close the session"):
        session.close()

A step that raises is recorded, and the exception carries on out

The step is marked failed and keeps the message, up to 2,000 characters. The exception is then re-raised untouched — a report that swallowed a failure in order to describe it would be worse than no report.

The message is kept on the step that actually raised. The steps it was raised inside are marked failed without repeating it, so one exception walking out through four levels is printed once, on the innermost step — the only one that says where — rather than four times with the useful copy furthest down the page.

The Test body block of a failed test: two passing steps, a failed step carrying its assertion message, and the screenshot captured on that step The Test body block of a failed test: two passing steps, a failed step carrying its assertion message, and the screenshot captured on that step
The failure sits on the step that produced it — message underneath, screenshot beside it.

Threads and concurrency

Steps are recorded in the context that ran them, and each thread nests within itself. A test that fans work out to a pool would otherwise have every thread pushing onto one stack, and the steps would come back nested inside each other in whatever order the threads happened to interleave — a tree that never existed. A background thread's steps land at the top level, where they belong.

Until 0.4.1 that was only half true: the stack was per-thread and correct, but the tab rebuilt the tree by reading depths straight down the buffer, so five threads interleaving their steps into one list drew a tree nobody could follow — the right depths under the wrong parents. Every step now records the step it is a step of, and the tab walks that instead.

Anything attached lands on the step

attach_json, attach_api, attach_text, attach_file and attach need no extra argument to say which step they belong to. Whatever step is open when they are called is what they are filed under, and that step shows a paperclip with the count.

python The open step claims the attachment
with step("Submit credentials"):
    attach_api(requests.post(url, json=payload))

This is read rather than passed, because attach_json is called by code that has no idea a report exists. An image attached inside a step is shown on that step as a thumbnail; a picture belonging to no step is shown against the test body, which is where a test with no named steps ran. The helpers themselves are documented in the Python API.

Steps in an async test

New in 0.4.1step works in an async test the way it always has in a sync one. Nothing has to be installed and no setting turns it on — pytest-asyncio, anyio and trio are all driven the same way. This is why setup.py now declares python_requires=">=3.7": the machinery is contextvars.
python Both spellings, in a coroutine
import asyncio

import pytest

from pytest_html_reporter import step


@step("Notify {user}")
async def notify(user):
    await mailer.send(user)


@pytest.mark.asyncio
async def test_checkout_notifies_everyone():
    async with step("Charge the card"):
        await gateway.charge(cart)

    # Three legs, run together. They come back as three siblings.
    await asyncio.gather(notify("amy"), notify("bo"), notify("cy"))

async with was a TypeError before this — the object had no __aenter__ at all — so the only spelling that worked was a plain with block inside the coroutine.

Careful@step on an async def was worse than unsupported before 0.4.1: it reported a failing step green. Calling an async def only builds a coroutine, so the wrapper closed the step on that — nought milliseconds, PASS — and the work ran, and raised, long after the step said it had finished. It is now timed across the call, and a step that raises is recorded failed with its message. If a suite of async tests has been showing instant passing steps, that is what it was.

Work run concurrently comes back as the siblings it was

Steps used to be kept on a per-thread stack, and asyncio runs every task on the one thread: three gathered legs pushed onto that one stack and came back nested three deep inside one another. The stack is now a ContextVar holding an immutable tuple. A task starts from a copy of the context that created it, which is what keeps a leg nesting under the step that fanned it out while keeping its own steps to itself; the tuple is replaced rather than edited, because a mutable one would be the same list in every task and nothing would have changed.

Cucumber and Gherkin, with pytest-bdd

Nothing to do. A pytest-bdd scenario is already a list of named steps — it is the one style of test that arrives already broken into named pieces — so its Given / When / Then reach the tab on their own. Each is timed, each carries what its parser pulled out of the line, and each is badged with its Gherkin keyword so a specification never reads as somebody's plumbing.

Notepytest-bdd does not have to be installed. Every one of the hooks is declared optionalhook, because pytest does not warn about a hook nobody registered and does not skip it — it refuses to start, with PluginValidationError: unknown hook 'pytest_bdd_after_step'. A run without pytest-bdd is untouched.

The feature file

gherkin tests/functional/features/checkout.feature
@smoke @checkout
Feature: Checking out a basket

  Scenario Outline: A shopper buys <count> of an item
    Given a logged in shopper
    When they add <count> of "A-12" to the basket
    Then the basket holds <count> items

    Examples:
      | count |
      | 1     |
      | 3     |

  @declined
  Scenario: A declined card names the step that failed
    Given a logged in shopper
    When they add 1 of "DECLINE" to the basket
    And they check out
    Then the basket holds 0 items

The step definitions

Nothing here mentions the reporter.

python tests/functional/test_gherkin.py
from pytest_bdd import given, parsers, scenarios, then, when

scenarios("features")


@given("a logged in shopper", target_fixture="basket")
def _shopper():
    return []


@when(parsers.parse('they add {count:d} of "{sku}" to the basket'))
def _add(basket, count, sku):
    basket += [sku] * count


@when("they check out")
def _check_out(basket):
    if "DECLINE" in basket:
        raise AssertionError("card declined by the gateway")


@then(parsers.parse("the basket holds {count:d} items"))
def _holds(basket, count):
    assert len(basket) == count
shell The Gherkin demo — needs pytest-bdd installed, and nothing else
$ pytest tests/functional/test_gherkin.py --html-report=./report

What the tab then shows

On the pageWhere it comes from
A strip above the treeThe feature name, the scenario name and the feature file's path as the run saw it — a pytest-bdd test function is generated, so its own name and module say far less about it than the feature does.
Given / When / Then badgeThe keyword as it was writtenAnd and But keep their own word rather than being resolved to the one before them.
The step lineThe keyword and the line from the feature file, timed like any other step.
Parameters on the stepWhat the parser pulled out: count=3, sku=A-12. The fixtures pytest-bdd injects alongside them — one for every target_fixture the scenario has built up — are left out, or Then the basket holds 3 items would arrive with the whole basket printed beside it.
An Outline's placeholdersAlready filled in with the row that actually ran, so When they add <count> of "A-12" arrives as When they add 3 of "A-12".
The Examples rowShown as the test's parameters — count = 3 — unwrapped from the single dict pytest-bdd passes it in.
TagsThe scenario's own and the feature's, merged and sorted, arriving as markers. Both @smoke and @declined show against the second scenario above.
A step nobody implementedRecorded and marked failed, so the tab names the line of the feature that has no step function rather than simply stopping short of it.

The rail lists a BDD test under its scenario name rather than a step count, and the search box matches the feature and the scenario as well as the step lines — so typing a feature name finds every test in it.

A pytest-bdd scenario in Test Steps: the feature strip naming the feature file above Given, When and Then rows with coloured Gherkin badges and timings A pytest-bdd scenario in Test Steps: the feature strip naming the feature file above Given, When and Then rows with coloured Gherkin badges and timings
A Gherkin scenario — the feature, the scenario and the feature file above the tree, and a badge on every step.

Markers, and where they were written

Markers are shown in full — including the ones a test never mentions. A module-level pytestmark, a marker on the class, one added by request.node.add_marker while the test was running: all of them, each saying which scope it came from, which is the answer when nobody remembers applying it.

They are read off the item at teardown, the only moment that sees the whole picture: a marker added during the test is not there at collection, and a parametrized case does not know its own values until it has one.

python Three scopes and a marker added mid-run
import sys

import pytest

pytestmark = pytest.mark.regression          # every test in this module


@pytest.mark.smoke                           # every test in this class
class TestCheckout:

    @pytest.mark.skipif(sys.platform == "win32", reason="POSIX paths only")
    @pytest.mark.parametrize("quantity", [2, 5])
    def test_a_shopper_can_buy_several(self, cart, request, quantity):
        """A parametrized case keeps its own row and its own steps."""
        request.node.add_marker(pytest.mark.flaky(reruns=3))   # added while it ran

        cart.add("B-7", quantity=quantity)

        assert len(cart.items) == quantity

The quantity=2 case of that test reads like this on the tab:

FactBadgeScope reported
DescriptionA parametrized case keeps its own row and its own steps.
5 markersparametrize(quantity)function
skipif(reason=POSIX paths only)function
flaky(reruns=3)function
smokeclass
regressionmodule
1 parameterquantity = 2
1 fixturecart

The scope is whichever node the marker was found on, walking out from the test: function, class, module, package or session. A marker written at more than one level is said once — pytestmark = pytest.mark.slow on a module whose class repeats it is one badge, not two — but tier("unit") and tier("slow") both stay, because they are two different things to say.

pytest's own markers are coloured apart from yours

skip, skipif, xfail, parametrize, usefixtures and filterwarnings get a badge of their own colour. That is not a style choice: those six change how a test is run, while @smoke only names it, and the two read very differently on a badge.

Two are cut down deliberately

Two markers the plugin gives a meaning to

New in 0.4.1owner and severity are collected like any other marker, but they answer questions the rest of the row does not — who do I tell and how bad is this — so each gets a row of its own, a row of filter pills on the rail and a panel on Analytics. Both need no configuration at all, and both are registered with pytest, so --strict-markers accepts them and no run prints PytestUnknownMarkWarning for them.
python Neither needs anything in pytest.ini
@pytest.mark.owner("payments-team")
@pytest.mark.severity("blocker")
def test_a_refund_reaches_the_ledger():
    ...

A marker holding an issue key has been collected and shown since markers landed, but as a flat badge: nothing in the report knows that PROJ-123 is an issue rather than a word. report_link_pattern is the missing half. One MARKER=URL per line, with {} where the marker's argument goes.

ini pytest.ini
[pytest]
report_link_pattern =
    jira = https://acme.atlassian.net/browse/{}
    testcase = https://acme.testrail.io/index.php?/cases/view/{}
    owner = https://github.com/orgs/acme/teams/{}
    docs = https://wiki.acme.dev/testing
shell The same from the command line, added to the ini rather than replacing it
$ pytest --report-link-pattern 'jira=https://acme.atlassian.net/browse/{}' \
         --report-link-pattern 'testcase=https://acme.testrail.io/index.php?/cases/view/{}'

Naming a marker here also puts it in the JUnit xml, as a <property> on the testcase — which is the half Xray, Zephyr and TestRail actually read.

Parameters, fixtures and the docstring

NoteMarkers are badges on this tab rather than a column on Test Metrics, and marker text is in this tab's search index — so marker-based filtering works here. Typing regression into the rail's search narrows it to every test carrying that marker, at whatever scope it was written.
The head of a Test Steps pane: the test name, its file, the feature strip and a row of marker badges The head of a Test Steps pane: the test name, its file, the feature strip and a row of marker badges
The facts block above the tree — what the test is, where it was written, and the markers it carries.

Keeping the file down

Step trees are held outside the metrics table, so they are never swept into its search box or into the CSV, Excel and print exports. Two flags decide how much is kept at all.

--report-steps
all | failed | none default: all

Whose steps survive into the report. all rather than failed, unlike a log: the steps of a test that passed are what a later failure is read against, and they cost a line each.

ValueWhat is kept
allEvery test's steps. The default.
failedOnly FAIL and ERROR tests'.
noneNo steps — the phases and their timings stay, as they cost nothing.
--report-step-limit
integer default: 500

How many steps one test can record. A step inside a loop over ten thousand rows would otherwise write ten thousand lines into the page, and the tree stops being readable long before it stops being generated.

The cap is followed by a line of its own saying the rest were dropped — more steps not recorded - raise --report-step-limit to keep them — rather than the tree simply ending, because a tree that stops halfway reads as a test that stopped there.

ValueMeaning
500500 steps per test. The default.
<positive integer>Steps per test.
0Every one.
shell Both step caps together
$ pytest --html-report=./report --report-steps=failed --report-step-limit=100
NoteA retry reports the steps of the attempt that stuck — the failing attempt's tree beside a green test would describe a run that did not happen. An attempt that recorded none keeps what was already there, which is what --report-steps=failed leaves behind on a retry.

Where next