Skip to content

Markdown report

The Markdown report is a run's scenarios as plain Markdown: one section per scenario, with its steps, attachments, parameter table and error. It reads well in a terminal, shows up as a readable diff in a pull request, and costs an AI agent a fraction of the tokens of the test code it narrates. The full example at the end of this page shows one.

Writing it

pytest --given-md              # print it to stdout
pytest --given-md=report.md    # write it to a file

Printed to stdout, the report sits between <!-- pytest-given:md:start --> and <!-- pytest-given:md:end --> markers, which separate it from pytest's own output. The report covers whatever the run selected, so pytest's -k, --lf and node IDs narrow it as usual.

To render it from a saved JSON report instead, without running the tests, use the standalone CLI: pytest-given report report-data.json --format md.

Reading it

  • The heading is the scenario title, led by its status: ✓ passed, ✗ failed, ○ skipped, ⊗ expected failure. A parametrized scenario adds its number of cases.
  • The line below it points at the test as file.py:line::test_name, then lists the scenario's tags and any skip or xfail reason. The examples are rendered with given_md_lines = false, so their anchors carry no line (see Diffs).
  • Steps are a list, each led by its phase in bold. Steps recorded inside another step, like those of a helper function, are nested under it. A term ref shows in guillemets, like «room».
  • Attachments follow their step after a 📎. A short one sits on the same line, and a longer one goes in a code block below it.
  • A parametrized scenario narrates its cases once, with {placeholders} where they differ, and then a parameter table. The table leads with a case ID column only when some case has a custom ID. It ends with a status column only when the cases differ in status. A failed case's error and any attachment too long for its cell follow the table, keyed by the row.
  • An error is a quote: the first line of the message, then the innermost frame of your code.

The Markdown report holds only scenarios. The glossary and the domain stories appear in the HTML report and the JSON, along with source links and full tracebacks.

Diffs

Commit the Markdown report, and a pull request shows what changed in the narration next to the code that changed it. This project commits its own self-report for that reason.

By default, every anchor carries a line, so inserting one test changes the anchor of every scenario below it in the file. Set given_md_lines = false (or pass --no-given-md-lines) to leave the lines out. When diffing two reports rendered by the CLI, pass --no-lines to both.

Reviewing narration

Add --given-md-source to end each scenario with its test function, each line under its number in the file. Step text and step body then sit side by side, which makes it quick to check that the narration says what the code does. Only the test function is shown, not the step fixtures or helpers it uses.

All Markdown flags are listed under pytest options.

Example

The Coffeeshop example's report, as committed in examples/coffeeshop/coffeeshop.md, where GitHub also shows it rendered. The Examples page links every example's Markdown report.

# pytest-given — Coffee Shop Example

## ✓ Buying a coffee takes one from the machine
`examples/coffeeshop/test_coffeeshop.py::test_buy_coffee` · checkout

- **given** a coffee machine with 10 coffees
- **when** I buy a coffee
- **then** the machine has 9 coffees left
    - 📎 Machine state:
        ```
        {
          "coffees": 9,
          "price": 2,
          "water_ml": 1000
        }
        ```

## ✓ Plain text attachment
`examples/coffeeshop/test_coffeeshop.py::test_text_attachment`

- **given** a coffee machine with 10 coffees
- **given** a printed receipt
    - 📎 Receipt:
        ```
        Coffee x1     $2.00
        ----------------
        Total:        $2.00
        ```
- **when** the total line is read back
- **then** it shows the \$2.00 total

## ✓ Generator fixture with `yield` and *teardown*
`examples/coffeeshop/test_coffeeshop.py::test_generator_fixture`

- **given** a database connection
- **when** I run a query
- **then** the connection is **open** and the query was logged

## ⊗ Parametrized test (renders as a parameter table) · 4 cases
`examples/coffeeshop/test_coffeeshop.py::test_pricing` · pricing — expected to fail: happy-hour pricing not implemented yet

- **given** a coffee machine with 10 coffees
- **when** I insert \${euros}
- **then** the purchase is allowed: {expect}

| case ID | euros | expect | |
|---|---|---|---|
| a-euro-short | 1 | False | ✓ |
| 2-True | 2 | True | ✓ |
| 3-True | 3 | True | ✓ |
| happy-hour | 1.5 | True | ⊗ |

- **happy-hour** — expected failure:
    > assert False == True
    > test_coffeeshop.py:87 in test_pricing

## ✓ Parametrize value surfaced as a given (Annotated) · 2 cases
`examples/coffeeshop/test_coffeeshop.py::test_annotated_given_label`

- **given** a coffee machine with 10 coffees
- **given** an order for a {cup_size} ml cup
- **when** I brew the cup
- **then** the machine has one fewer coffee

| cup_size |
|---|
| 200 |
| 350 |

## ✓ T-string with a non-parametrize value (neutral highlight)
`examples/coffeeshop/test_coffeeshop.py::test_neutral_highlight`

- **given** a coffee machine with 10 coffees
- **given** I have some coins in hand
- **when** I insert \$5
- **then** the machine has 9 coffees left

## ✓ Brew {cup_size} ml (templated scenario title) · 2 cases
`examples/coffeeshop/test_coffeeshop.py::test_brew`

- **given** a coffee machine with 10 coffees
- **when** I brew a {cup_size} ml cup
- **then** the tank is down {cup_size} ml and the machine has one fewer coffee

| cup_size |
|---|
| 200 |
| 300 |

## ✓ Serve a 200 ml cup (one scenario per case) \[200\]
`examples/coffeeshop/test_coffeeshop.py::test_serve`

- **given** a coffee machine with 10 coffees
- **when** I order a 200 ml cup
- **then** the tank is down 200 ml and the machine has one fewer coffee

## ✓ Serve a 400 ml cup (one scenario per case) \[400\]
`examples/coffeeshop/test_coffeeshop.py::test_serve`

- **given** a coffee machine with 10 coffees
- **given** the barista reaches for a takeaway cup
- **when** I order a 400 ml cup
- **then** the tank is down 400 ml and the machine has one fewer coffee

## ✓ Brew a {flavor} coffee (per-case columns) · 2 cases
`examples/coffeeshop/test_coffeeshop.py::test_flavor_columns` · pricing

- **given** a coffee machine with 10 coffees
- **given** the machine is primed for {flavor}
    - 📎 brew log — *see parameter table*
- **when** I brew a {flavor} coffee
- **then** the drink costs {price} euros

| flavor | brew log | price |
|---|---|---|
| vanilla | brew log | 2 |
| mocha | brew log | 3 |

- **vanilla** — brew log:
    ```
    00:00  purge vanilla line, 40 ml at 92.8C
    00:04  dose 18.5 g, grind 12, hopper: vanilla
    00:09  pre-infuse at 3.0 bar for 4.0 s
    00:13  ramp to 9.0 bar
    00:27  stop at 36.0 g yield, ratio 1:1.95
    00:31  purge group head, vanilla residue cleared
    ```

- **mocha** — brew log:
    ```
    00:00  purge mocha line, 40 ml at 92.8C
    00:04  dose 18.5 g, grind 12, hopper: mocha
    00:09  pre-infuse at 3.0 bar for 4.0 s
    00:13  ramp to 9.0 bar
    00:27  stop at 36.0 g yield, ratio 1:1.95
    00:31  purge group head, mocha residue cleared
    ```

## ✓ Helper functions can record their own steps
`examples/coffeeshop/test_coffeeshop.py::test_buy_with_validation` · checkout, validation, ticket/CS-42

- **given** a coffee machine with 10 coffees
- **when** I insert \$2
    - **when** the coin is validated for \$2
    - **when** the balance is updated
- **then** the coin is accepted
- **then** a coffee is dispensed
    - **then** the machine state is consistent
        - 📎 Final state:
            ```
            {
              "coffees": 9,
              "price": 2,
              "water_ml": 1000
            }
            ```

## ✓ Top-level `given` block and deeply nested steps
`examples/coffeeshop/test_coffeeshop.py::test_complex_order` · checkout, loyalty, discounts, ticket/CS-7

- **given** a coffee machine with 10 coffees
- **given** a loyalty card with 5 points
- **when** I place a large order
    - **when** I select 3 coffees
    - **when** I apply loyalty discount
        - **when** the loyalty card is validated
        - **when** the discount is calculated
- **then** the order is processed correctly
    - **then** the coffee count is updated
    - **then** the loyalty points are deducted
        - **then** the remaining points are valid
            - 📎 Loyalty state:
                ```
                {
                  "points": 2
                }
                ```
- **then** the machine state is consistent
    - 📎 Final machine state:
        ```
        {
          "coffees": 7,
          "price": 2,
          "water_ml": 1000
        }
        ```

## ✓ An expected error, narrated as when + then (when_then)
`examples/coffeeshop/test_coffeeshop.py::test_sold_out_is_rejected` · checkout, validation, ticket/CS-42

- **given** a coffee machine with 10 coffees
- **given** a machine that has sold its last coffee
- **when** a customer tries to buy a coffee
- **then** the machine reports it is sold out

## ✓ Many tags (the report collapses them behind a +N pill)
`examples/coffeeshop/test_coffeeshop.py::test_discounted_purchase` · checkout, loyalty, discounts, pricing, inventory

- **given** a coffee machine with 10 coffees
- **given** a loyalty card good for a \$1 discount
- **when** I buy a coffee with the discount
- **then** I pay \$1
- **then** a coffee is dispensed

## ✗ Failure rendering (intentionally failing)
`examples/coffeeshop/test_coffeeshop.py::test_failing`

- **given** a coffee machine with 10 coffees
- **then** the machine has 20 coffees

> assert 10 == 20
> test_coffeeshop.py:277 in test_failing

## ○ Skipped scenario rendering · skipped
`examples/coffeeshop/test_coffeeshop.py::test_skipped` — reason: demonstrates skipped status


## ⊗ Loyalty card earns a free coffee (planned feature) · expected failure
`examples/coffeeshop/test_coffeeshop.py::test_loyalty_card_free_coffee` · loyalty — expected to fail: loyalty cards not implemented yet

- **given** a coffee machine with 10 coffees
- **given** a loyalty card with nine stamps
- **when** I buy a coffee with the card

> the machine does not read loyalty cards yet
> test_coffeeshop.py:288 in pay_with_loyalty_card

## ○ All cases skipped · skipped
`examples/coffeeshop/test_coffeeshop.py::test_parametrized_all_skipped` — reason: awaiting fixture


| n |
|---|
| 1 |
| 2 |