Tutorial 4 — Subtests¶
A subtest is an independent mini-test that shares setup with its siblings. If one subtest fails, the others still run — unlike a single test where the first failure stops execution. This is perfect for documenting a function with many different inputs side by side.
pytest 9.0 ships subtests as a built-in feature. No extra packages are needed.
Prerequisites¶
Completed Tutorial 3 — Using fixtures. You should know how split blocks work.
The problem without subtests¶
Suppose you want to document three edge cases for a string sanitiser:
<!-- name: test_sanitise_empty -->
```python
from mylib import sanitise
assert sanitise("") == ""
```
<!-- name: test_sanitise_spaces -->
```python
from mylib import sanitise # imported again
assert sanitise(" hello ") == "hello"
```
<!-- name: test_sanitise_special -->
```python
from mylib import sanitise # imported again
assert sanitise("a<b>c") == "abc"
```
Three separate tests — three imports. If sanitise needs expensive
setup, you repeat it three times. With subtests you set up once and vary
the assertion:
Step 1 — Add case: to each variation¶
The first block (no case:) is the shared setup. Each subsequent block
with a case: key is an independent subtest.
<!-- name: test_sanitise -->
```python
def sanitise(s):
return s.strip().replace("<", "").replace(">", "")
```
<!-- name: test_sanitise; case: empty_string -->
```python
assert sanitise("") == ""
```
<!-- name: test_sanitise; case: strips_whitespace -->
```python
assert sanitise(" hello ") == "hello"
```
<!-- name: test_sanitise; case: removes_angle_brackets -->
```python
assert sanitise("a<b>c") == "abc"
```
When run:
pytest guide.md -v
guide.md::test_sanitise PASSED
test_sanitise/empty_string PASSED
test_sanitise/strips_whitespace PASSED
test_sanitise/removes_angle_brackets PASSED
Three sub-results, one test entry in the summary.
Step 2 — One failure does not stop the rest¶
Change the second case to a wrong expected value:
<!-- name: test_sanitise; case: strips_whitespace -->
```python
assert sanitise(" hello ") == "HELLO" # wrong
```
Run again:
guide.md::test_sanitise FAILED
test_sanitise/empty_string PASSED
test_sanitise/strips_whitespace FAILED
test_sanitise/removes_angle_brackets PASSED
strips_whitespace failed, but removes_angle_brackets still ran and
passed. Without subtests, execution would have stopped at the first failure.
Fix the assertion before continuing.
Step 3 — Add fixtures to subtests¶
Fixtures work the same way as with regular split blocks. Declare them on
the setup block (no case:); they are available in all case blocks:
<!-- name: test_file_cases; fixtures: tmp_path -->
```python
base = tmp_path / "data"
base.mkdir()
```
<!-- name: test_file_cases; case: file_creation -->
```python
f = base / "a.txt"
f.write_text("hello")
assert f.exists()
```
<!-- name: test_file_cases; case: file_content -->
```python
f = base / "a.txt"
f.write_text("hello")
assert f.read_text() == "hello"
```
tmp_path is requested once and shared across all cases. Each case
receives its own fresh tmp_path value because pytest re-runs the
function-scoped fixture for each subtest.
Step 4 — Prose between case blocks¶
You can add explanatory text between case: blocks just like with regular
split blocks:
<!-- name: test_counter -->
```python
from collections import Counter
c = Counter()
```
Start by verifying the counter starts at zero:
<!-- name: test_counter; case: initial_value -->
```python
assert c["missing_key"] == 0
```
Increment a key and check it:
<!-- name: test_counter; case: after_increment -->
```python
c["x"] += 1
assert c["x"] == 1
```
Multiple increments accumulate:
<!-- name: test_counter; case: accumulation -->
```python
c["x"] += 1
c["x"] += 1
assert c["x"] == 2
```
The prose between blocks is documentation — it is stripped during collection.
Step 5 — Case names in output¶
Case names appear in the test output and in failure messages. Choose names that describe the scenario, not the implementation:
Good case names
case: empty_input, case: negative_number, case: unicode_string,
case: max_value
Unhelpful case names
case: test1, case: case_a, case: foo
Spaces are allowed in case names: case: strips leading spaces — they
appear as-is in output.
How subtests work internally¶
When the plugin collects a test with case: blocks, it wraps each case in
a with subtests.test(msg='...') context manager. The subtests fixture
is automatically added to the test’s fixture list. If a case raises an
exception, the context manager catches it, records the failure, and
execution continues with the next case.
Common mistakes¶
Forgetting the setup block
If every block has a case:, there is no shared setup. Each case gets its
own fresh namespace — variables defined in one case are not visible in
others. Add one block without case: to define shared state.
Setup block also has case:
The block without case: is the setup. If you accidentally add case: to
the setup block, it becomes a case and there is no shared setup.
Expecting a subtests import
You do not need to import anything. The subtests fixture is injected
automatically by the plugin.
What you learned¶
Add
case: nameto mark a block as a subtest.The block without
case:is the shared setup.All cases run even when one fails.
Fixtures are available in all case blocks.
Next steps¶
Tutorial 5 — Hidden code blocks covers how to write documentation that looks polished to readers — with setup and assertions hidden in HTML comments — while still being fully tested.