Pytest plugin for runs tests directly from Markdown files
Project description
markdown-pytest
A pytest plugin that collects and executes Python code blocks from Markdown
files, so your documentation examples are always tested.
By the way: this README is itself tested by
markdown-pytest. Every Python code block below is a real test that runs on every CI build.
Quick start
Install with pip:
pip install markdown-pytest
Place an HTML comment with a name key directly above a python code
fence. The plugin collects it as a test:
assert 2 + 2 == 4
Run it:
$ pytest -v README.md
Sample output:
README.md::test_quick_start PASSED
README.md::test_example PASSED
README.md::test_hidden_demo PASSED
That is the only requirement. Everything below is optional and lets you handle progressively more complex scenarios.
Comment syntax
The comment format uses colon-separated key-value pairs, separated by semicolons. The trailing semicolon is optional:
<!-- key1: value1; key2: value2 -->
Comments can span multiple lines:
<!--
name: test_name;
fixtures: tmp_path, monkeypatch
-->
Both two-dash and three-dash variants are supported. All of the following are parsed identically:
<!-- name: test_name -->
<!--- name: test_name --->
<!-- name: test_name --->
<!--- name: test_name -->
Available comment parameters:
name(required) — the test name. Must start withtestby default (see Configuration to change the prefix).case— marks the block as a subtest (see Subtests).fixtures— comma-separated list of pytest fixtures to inject (see Fixtures).
Fixture lists can be written in several ways:
<!-- name: test_foo; fixtures: tmp_path, monkeypatch, capsys -->
<!--
name: test_foo;
fixtures: tmp_path,
monkeypatch,
capsys
-->
<!--
name: test_foo;
fixtures: tmp_path;
fixtures: monkeypatch;
fixtures: capsys
-->
Code blocks without a name comment are silently ignored — regular
documentation examples keep working as before.
Code split
You can split a test across several code blocks by giving them the same
name. The blocks are combined into a single test, preserving source line
numbers for accurate tracebacks.
This block performs import:
from itertools import chain
chain usage example:
assert list(chain(range(2), range(2))) == [0, 1, 0, 1]
The split blocks do not need to be consecutive. Blocks with the same name are combined even when separated by other tests:
value = 42
assert True
assert value == 42
Subtests
Add case: case_name to run a block as a subtest. Shared setup code goes
in a block without a case, and each subsequent case block runs as an
independent subtest:
from collections import Counter
counter = Counter()
counter["foo"] += 1
assert counter["foo"] == 1
The pytest-subtests package is installed automatically as a dependency.
Fixtures
You can request pytest fixtures by adding fixtures: name1, name2 to the
comment. Any standard pytest fixture (tmp_path, monkeypatch, capsys,
request, etc.) or custom fixtures defined in conftest.py can be used.
The requested fixtures are available as variables in the code block.
Single fixture
p = tmp_path / "hello.txt"
p.write_text("hello")
assert p.read_text() == "hello"
Multiple fixtures
import os
monkeypatch.setenv("DATA_DIR", str(tmp_path))
assert os.environ["DATA_DIR"] == str(tmp_path)
Multiline fixture lists
Fixtures can span multiple lines using comma continuation:
import os
monkeypatch.setenv("ML_DIR", str(tmp_path))
assert os.environ["ML_DIR"] == str(tmp_path)
Or as separate semicolon-delimited entries — values for duplicate keys are merged automatically:
p = tmp_path / "out.txt"
p.write_text("ok")
print(p.read_text())
captured = capsys.readouterr()
assert captured.out.strip() == "ok"
Fixtures with split blocks
Only the first block needs the fixtures: declaration. All blocks with the
same name share the namespace:
p = tmp_path / "data.txt"
p.write_text("hello")
assert p.read_text() == "hello"
You can also declare different fixtures in different blocks — they are merged together:
p = tmp_path / "output.txt"
p.write_text("merged")
print(p.read_text())
captured = capsys.readouterr()
assert captured.out.strip() == "merged"
Fixtures with subtests
Fixtures and subtests (case:) can be freely combined:
data_file = tmp_path / "data.txt"
data_file.write_text("hello world")
assert data_file.exists()
assert data_file.read_text() == "hello world"
Hidden code blocks
A code block placed inside the comment is invisible to readers but runs before the visible block. This is useful for imports, boilerplate, or test data preparation:
assert init_value == 123
Readers see only assert init_value == 123 — the assignment is hidden in
the HTML comment.
Hidden setup, visible demo, hidden assertions
You can combine hidden blocks and split blocks to create documentation that reads like a tutorial: the setup and assertions are invisible, and the reader sees only the interesting part. This is the recommended pattern for polished documentation examples.
The following example demonstrates a CSV parser. The reader sees only the parsing logic — file creation and assertions are hidden:
rows = []
for line in csv_file.read_text().strip().split("\n")[1:]:
name, role = line.split(",")
rows.append({"name": name, "role": role})
Readers see only the for loop that parses CSV rows. The file creation
(using tmp_path) and the assertions are both hidden inside HTML comments.
Fixtures are available inside hidden blocks, so you can perform invisible
setup using any requested fixture.
Mixing with other languages
Markdown files often contain non-Python code blocks. The plugin safely
skips any fenced block that is not tagged as python — including
```bash, ```json, ```yaml, bare ``` blocks, and
four-backtick ( ````) fences:
```bash
echo "this is ignored by markdown-pytest"
```
```json
{"this": "is also ignored"}
```
```
Bare fences without a language tag are ignored too.
```
Only blocks explicitly tagged ```python and preceded by a
<!-- name: ... --> comment are collected as tests.
Indented code blocks
Code blocks inside HTML elements like <details> may be indented.
The plugin strips the leading indentation automatically:
Indented example
<!-- name: test_indented -->
```python
assert True
```
Configuration
Test prefix
By default only blocks whose name starts with test are collected.
Use --md-prefix to change the prefix:
$ pytest --md-prefix=check README.md
The prefix can also be set permanently in pyproject.toml:
[tool.pytest.ini_options]
addopts = "--md-prefix=check"
Or in pytest.ini / setup.cfg:
[pytest]
addopts = --md-prefix=check
Supported environments
- Python >= 3.10 (CPython and PyPy)
- Both
.mdand.markdownfile extensions are collected automatically - The plugin auto-registers via the
pytest11entry point — no configuration is needed beyondpip install - Tracebacks from failing tests preserve the original Markdown line numbers, so you can jump straight to the source
Why test Markdown code blocks?
Documentation with code examples tends to rot. APIs change, function signatures evolve, and the snippets in your README silently become wrong. Readers copy-paste broken examples, file issues, and lose trust in the project.
The usual workaround is to maintain a parallel test suite that duplicates the
same logic. Now you have two copies that can drift apart. markdown-pytest
removes that duplication: the documentation is the test.
By running pytest against your .md files you get:
- Guaranteed accuracy — every code sample in your docs is executed on every CI run.
- Zero duplication — there is only one copy of the example code, living in the prose where readers expect it.
- Natural documentation — Markdown is rendered on GitHub, PyPI, and documentation sites. Your tests look like ordinary code examples to readers.
Why HTML comments?
Markdown has no built-in way to attach metadata to a code block. The
markdown-pytest plugin uses HTML comments because they are:
- Invisible to readers — rendered Markdown hides HTML comments, so the test markers never clutter the documentation.
- Part of the Markdown spec — every parser preserves them, unlike non-standard directive syntaxes.
- Easy to type —
<!-- name: test_foo -->is short and obvious. - Extensible — semicolon-separated key-value pairs allow adding
parameters (like
caseorfixtures) without inventing a new format.
The comment must be placed directly above the code fence it annotates.
The name key is required; blocks without it are silently ignored so that
regular documentation examples keep working as before.
Best practices
Keep examples self-contained. Each test should make sense on its own. A reader scanning the documentation should not have to scroll back through several sections to understand what a code block does.
Use split blocks for narrative flow. When an example naturally spans
several steps (import, setup, usage), split it into multiple blocks with the
same name and add explanatory prose between them.
Use hidden blocks for boilerplate. If a test needs imports or setup that would distract the reader, put them in a hidden code block inside the comment. The rendered documentation stays clean while the test remains complete.
Use fixtures instead of mocking by hand. Requesting tmp_path,
monkeypatch, or capsys via the fixtures: parameter keeps tests short
and idiomatic. Custom conftest.py fixtures work too — use them to share
setup across multiple Markdown files.
Prefer subtests for variations. When you have several related assertions,
use case: blocks. Pytest reports each subtest independently, so a failure
in one does not hide the others.
Run the docs in CI. Add your .md files to the pytest invocation in
your CI pipeline. Stale examples will break the build before they reach
users.
Split tests into focused files. Rather than putting all tests in a single
huge Markdown file, organize them by topic — for example
tests/fixtures.md, tests/splitting.md, and so on. This makes failures
easier to locate and keeps each file readable.
Name tests descriptively. The test name appears in pytest output. A name
like test_csv_parsing is more helpful than test_example_1 when something
fails.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file markdown_pytest-0.5.0.tar.gz.
File metadata
- Download URL: markdown_pytest-0.5.0.tar.gz
- Upload date:
- Size: 43.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c2b397fcc2a55659b42deee417dc8f7c6dd432e0d071c2435bed46868a7241a9
|
|
| MD5 |
89908352547af010d21953a7e31d2ca6
|
|
| BLAKE2b-256 |
67ff7bdd5e2aeed5ebedeca97cea9648b580ac112cd044e884b9dc07fdf0aa78
|
Provenance
The following attestation bundles were made for markdown_pytest-0.5.0.tar.gz:
Publisher:
publish.yml on mosquito/markdown-pytest
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
markdown_pytest-0.5.0.tar.gz -
Subject digest:
c2b397fcc2a55659b42deee417dc8f7c6dd432e0d071c2435bed46868a7241a9 - Sigstore transparency entry: 976152560
- Sigstore integration time:
-
Permalink:
mosquito/markdown-pytest@4daf01fb56989099a05b2375f74c6b4a4af5bd01 -
Branch / Tag:
refs/tags/0.5.0 - Owner: https://github.com/mosquito
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4daf01fb56989099a05b2375f74c6b4a4af5bd01 -
Trigger Event:
release
-
Statement type:
File details
Details for the file markdown_pytest-0.5.0-py3-none-any.whl.
File metadata
- Download URL: markdown_pytest-0.5.0-py3-none-any.whl
- Upload date:
- Size: 13.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
312feff206b83f4c3b900aea0fb940127ceca5df414d729aeda6a61acab64f4f
|
|
| MD5 |
65feaa9fe3d3066d0d14a00419a2b21d
|
|
| BLAKE2b-256 |
30608a771ec6b18834643ea7412beb459ddf1b6236ecd04cd5b97bd06d4f0db3
|
Provenance
The following attestation bundles were made for markdown_pytest-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on mosquito/markdown-pytest
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
markdown_pytest-0.5.0-py3-none-any.whl -
Subject digest:
312feff206b83f4c3b900aea0fb940127ceca5df414d729aeda6a61acab64f4f - Sigstore transparency entry: 976152562
- Sigstore integration time:
-
Permalink:
mosquito/markdown-pytest@4daf01fb56989099a05b2375f74c6b4a4af5bd01 -
Branch / Tag:
refs/tags/0.5.0 - Owner: https://github.com/mosquito
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4daf01fb56989099a05b2375f74c6b4a4af5bd01 -
Trigger Event:
release
-
Statement type: