Technical previewShare feedback
Irondocs
IrondocsTest codeblocks in Markdown

Search

Search documentation

Open source · MIT License

Docs that run
in your test suite.

Mark a code block in any .md file with test. Irondocs runs it as a real pytest, vitest or jest test — no separate spec file, no boilerplate, no drift between docs and code.

$ pytest docs/ -v

docs/install.md::Installation PASSED          [ 20%]
docs/usage.md::Adds two numbers PASSED        [ 40%]
docs/usage.md::Handles negatives PASSED       [ 60%]
docs/usage.md::Reads a file #1 PASSED         [ 80%]
docs/usage.md::Reads a file #2 SKIPPED        [100%]

============ 4 passed, 1 skipped in 0.04s ============

Wired in with one line

pyproject.toml
[tool.pytest.ini_options]
irondocs_files = ["docs/*.md"]

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 .md file.

  • Opt in per file

    List your docs under irondocs_files and they run with the rest of the suite. See Configuration.

mylib
pyproject.toml
docs/
├─ install.md
└─ usage.md
tests/
└─ test_core.py

$ pytest

Untagged 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.

docs/usage.md
## Adds two numbers

```python test
from mylib import add

assert add(1, 2) == 3
```

## Handles negatives

```python test
assert add(-1, -2) == -3
```
terminal
$ 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.