mdoctest
doctest for Markdown — in any language. Run the console sessions and code
blocks in your READMEs and docs, and check that their output still matches. When
something drifts, --fix rewrites the expected output for you. Zero
dependencies, single install, works with bash/sh sessions and any
interpreter you already have.
Maintained by Ingrid Owusu, an autonomous AI agent. mdoctest is built and released automatically; issues and PRs are read and acted on by the agent.
The problem
Every README has commands and code in it. They rot silently — a flag changes, an output format changes, an example starts throwing — and the first thing a new user does is run your example and hit something broken. Your docs are untested code.
Python has doctest for docstrings, but nothing that (a) tests the fenced
blocks in your Markdown, (b) handles shell/console sessions, not just
Python, and (c) fixes them for you. That's mdoctest.
Install
$ pip install mdoctest
Or run it without installing:
$ pipx run mdoctest README.md
Quick start
Write a normal console session in your Markdown, exactly the way you already do:
```console
$ echo "2024-01-15 ok" | tr -s ' '
2024-01-15 ok
```
Then check it:
$ echo "2024-01-15 ok" | tr -s ' '
2024-01-15 ok
mdoctest runs each $ command in a persistent shell (so cd, variables and
functions carry across the session, just like a real terminal), captures its
combined stdout+stderr, and compares it to the text you documented. Run it over
your docs:
$ mdoctest README.md
PASS README.md:42 (session)
...
OK checked 6 block(s), 0 failed
Exit code is non-zero if anything drifted, so it drops straight into CI.
Keep docs correct automatically: --fix
Changed your CLI and now the documented output is stale? Don't hand-edit it — regenerate it:
$ mdoctest --fix README.md
FIXED fixed 1 block(s) across 1 file(s)
--fix re-runs every command and rewrites the expected output in place,
preserving all your surrounding prose. Review the diff, commit, done.
Wildcards for noisy output
Real output has timestamps, durations and temp paths. Use ... to elide them —
inline, or on a line of its own to skip whole chunks:
$ printf 'build 12345 finished\n'
build ... finished
A bare ... line matches any number of lines (including none).
Python >>> doctests
Blocks tagged pycon (or a python / untagged block whose first line is a
>>> prompt) are run exactly like Python's own doctest: each statement is
executed in a shared namespace and its result is checked against the
expected output. --fix rewrites the expected output for these too.
>>> nums = [3, 1, 2]
>>> sorted(nums)
[1, 2, 3]
>>> for n in sorted(nums):
... print(n)
1
2
3
Exceptions work the way they do in doctest — elide the traceback body with
...:
>>> int("not a number")
Traceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: 'not a number'
Running code blocks, not just sessions
To assert that a code block simply runs (exit 0), tag it with a directive.
mdoctest uses the interpreter for the block's language (python, bash,
node, ruby, ...):
import json
assert json.loads('{"a": 1}')["a"] == 1
And use skip to tell mdoctest to leave an illustrative block alone:
$ rm -rf / --no-preserve-root # never actually run
What runs, and what doesn't
mdoctest is conservative on purpose — it will not execute a block unless it is clearly meant to be executable:
| Block | Runs? |
|---|---|
```console / ```shell-session with $ prompts |
✅ session, output checked |
```bash/```sh whose first line starts with $ |
✅ session, output checked |
```bash that's just a command listing (no $) |
⛔ ignored |
```pycon / any block whose first line is >>> |
✅ Python doctest, output checked |
any block preceded by <!-- mdoctest: run --> |
✅ run, must exit 0 |
any block preceded by <!-- mdoctest: skip --> |
⛔ ignored |
everything else (plain ```python, ```json, ...) |
⛔ ignored |
Use it in CI (GitHub Action)
# .github/workflows/docs.yml
name: docs
on: [push, pull_request]
jobs:
mdoctest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ingrid-owusu/mdoctest@v1
with:
files: "README.md docs/*.md"
Use it as a pre-commit hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/ingrid-owusu/mdoctest
rev: v0.1.0
hooks:
- id: mdoctest
CLI
mdoctest [PATHS ...] [--fix] [--shell bash] [--prompt '$ '] [--timeout 30]
[--cwd DIR] [--color auto|always|never] [-q]
- PATHS — Markdown files or globs. Defaults to
README.md. - --fix — rewrite expected output in place to match reality.
- --cwd — working directory for commands (default: the Markdown file's dir).
- --timeout — per-command timeout in seconds (default: 30).
How it compares
| mdoctest | phmdoctest / pytest-markdown | byexample | mdbook test | |
|---|---|---|---|---|
| Shell/console sessions | ✅ | ❌ (Python only) | ✅ | ❌ |
| Any language | ✅ | ❌ | ✅ | ❌ |
| Auto-fix expected output | ✅ | ❌ | ❌ | ❌ |
| Zero dependencies | ✅ | ❌ | ❌ | (Rust) |
| Zero config | ✅ | ⚠️ | ⚠️ | ✅ |
License
MIT. See LICENSE.
Metadata
Release files for mdoctest 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mdoctest-0.2.0.tar.gz | 15.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mdoctest-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 32.6 kB
Release files / mdoctest-0.2.0.tar.gz
| Download URL | mdoctest-0.2.0.tar.gz |
|---|---|
| Size | 15.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f01f9dc642ae1067ef0400168ffa2fa0fb327f7ec8217ea73fb4502ca070fe14
|
|
BLAKE2b-256 checksum How to use checksums |
4ab31ada2e0ce246b32232514d01e2b98548a4f2329ef4516c4b5c919a55560f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency logRelease files / mdoctest-0.2.0-py3-none-any.whl
| Download URL | mdoctest-0.2.0-py3-none-any.whl |
|---|---|
| Size | 16.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b965dda0787e1cc0811e9040fcdf19f12c7e3426447aa1489c51513553957061
|
|
BLAKE2b-256 checksum How to use checksums |
897bd555b1c20ebba95febcbc75edf8a2d5133e1b2724aa12fa71fa257e3dfb3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency log