Still just pytest
Irondocs turns marked blocks into tests and gets out of the way. Your pytest setup and your markdown stay yours.
Plain pytest items
Every block is collected as an ordinary pytest item:
-k, node ids, markers, plugins and reports work as they do for your test modules.Real assertion rewriting
Blocks use plain
assert, rewritten by pytest, and a failure points at the very line of the.mdfile.Opt in per file
List your docs under
irondocs_filesand they run with the rest of the suite. See Configuration.
pyproject.toml
docs/
├─ install.md
└─ usage.md
tests/
└─ test_core.py
$ pytestUntagged code blocks render as usual and never run.
Doctests, the way you wished they worked
Lean defaults, no setup beyond the plugin, full control when you need it.
Zero boilerplate
Doctest bodies are pure assertions: expect is injected for vitest and jest, and pytest rewrites a plain assert.
Every major runner
One vite plugin for vitest and vite-plus, a jest transformer and a pytest plugin — the same fence and the same names in each.
AST-aware imports
Imports inside a block are hoisted and shared across the file through a real parser — ts-morph or Python's ast, no regex foot-guns.
Markdown stays markdown
The test marker lives in the fence info string, invisible to readers. Your docs site renders normal code blocks.
Per-block options
Use name="...", skip, or only inside the fence to override naming and focus or skip individual doctests.
Docs that cannot lie
If a snippet stops compiling or asserting, your CI breaks. Docs and code stay in sync by construction.
Write a doctest. Get a test.
Tag a python fence with test and its body runs as a pytest test named after the nearest heading.
## Adds two numbers
```python test
from mylib import add
assert add(1, 2) == 3
```
## Handles negatives
```python test
assert add(-1, -2) == -3
```
$ pytest docs/usage.md -v
docs/usage.md::Adds two numbers PASSED [ 50%]
docs/usage.md::Handles negatives PASSED [100%]
============ 2 passed in 0.02s ============
Test names come from the nearest heading, suffixed #1, #2 on repeats.
Imports hoist across the file; everything else stays local to its block.
Runs with pytest, filtered and reported as usual.
Stop writing docs that lie. Make them tests.
Install, mark a fence, run your usual test command. That's it.