Markdown-driven testing in Python
Project description
testdown
Markdown-driven testing in Python. Write test scenarios as readable .md files
with named fenced code blocks, then extract and exercise those blocks in your
pytest suite.
Overview
testdown lets you embed structured test data directly in Markdown files. Each
fenced code block carries a language tag and a name, and
extract_blocks() returns a collection you can query, iterate, and convert in
your test code.
This approach keeps test inputs, expected outputs, and documentation together in a single human-readable file — making scenario-based test suites easy to read, review, and extend.
Installation
pip install testdown
For DataFrame conversion support (pandas, polars, dftxt):
pip install "testdown[data]"
Block Naming Convention
Named fenced code blocks use the format <language> <name> on the opening fence
line:
```python setup
x = 1 + 1
{ "status": "ok" }
name,score
Alice,95
Bob,87
Names can be any whitespace-free string. A common convention for scenario files
is `expected_<category>_<metric>` so blocks can be discovered with
`find_all("expected_*_*")` — mirroring how parametrized test suites verify
multiple output categories per scenario.
## Quick Start
Given a Markdown scenario file `tests/scenarios/my_feature.md` with the named
blocks above, extract and use them in a test:
```python
import testdown
blocks = testdown.extract_blocks("tests/scenarios/my_feature.md")
# Run Python setup code and access its module namespace
setup = blocks["setup"].exec_python_code()
assert setup.threshold == 0.5
# Convert a JSON block to a dict
result = blocks["expected_result"].to_dict()
assert result["status"] == "ok"
# Check which blocks are present
assert "sample_data" in blocks
# Find all blocks matching a wildcard pattern
expected_blocks = blocks.find_all("expected_*")
API Reference
extract_blocks(markdown_contents)
Parses a Markdown string or file path and returns a MarkdownBlocks collection.
import pathlib
import testdown
# From a file path
blocks = testdown.extract_blocks(pathlib.Path("scenarios/my_test.md"))
# From an inline string
blocks = testdown.extract_blocks(markdown_string)
MarkdownBlocks
A dict-like collection of extracted blocks.
| Method / Operation | Description |
|---|---|
blocks["name"] |
Get a block by name |
"name" in blocks |
Check if a block exists |
del blocks["name"] |
Remove a block |
len(blocks) |
Number of blocks |
iter(blocks) |
Iterate over block names |
blocks.keys() |
All block names |
blocks.values() |
All MarkdownBlock instances |
blocks.items() |
Name/block pairs |
blocks.get("name", default) |
Get with optional default |
blocks.find_all("pattern_*") |
Wildcard search (fnmatch) |
MarkdownBlock
Represents a single extracted code block with attributes name, language,
index, and contents.
| Method | Description |
|---|---|
block.to_dict() |
Parse json, yaml, or yml block to dict |
block.to_dict(safe_load=False) |
Parse YAML with yaml.full_load |
block.exec_python_code(**kwargs) |
Execute python block, returns a module |
block.to_pandas_frame(csv_options) |
Convert csv or df block to pd.DataFrame |
block.to_frame(csv_options) |
Convert csv or df block to pl.DataFrame |
block.to_polars_frame(csv_options) |
Alias for to_frame() |
to_pandas_frame and to_frame/to_polars_frame require the data extras.
df blocks use the dftxt
column-typed text format.
Usage Patterns
Parametrized scenario tests
The most common pattern — mirror what's shown in example/:
import pathlib
import pytest
import testdown
_SCENARIOS_DIR = pathlib.Path(__file__).parent / "scenarios"
_SCENARIOS = [f.name for f in _SCENARIOS_DIR.glob("*.md")]
@pytest.mark.parametrize("scenario_name", _SCENARIOS)
def test_my_feature(scenario_name):
blocks = testdown.extract_blocks(_SCENARIOS_DIR / scenario_name)
# Run setup code defined in the scenario
setup = blocks["setup"].exec_python_code()
# Verify each expected_* block
for block in blocks.find_all("expected_*"):
expected = block.to_dict()
observed = run_my_feature(setup)
assert observed == expected
Executable setup blocks
blocks = testdown.extract_blocks("scenario.md")
# Pass variables into the execution context
module = blocks["setup"].exec_python_code(env="staging")
config = module.configuration
DataFrame assertions (requires data extras)
blocks = testdown.extract_blocks("scenario.md")
# polars
expected_df = blocks["expected_output"].to_frame()
# pandas
expected_df = blocks["expected_output"].to_pandas_frame()
# Pass options to the underlying CSV reader
df = blocks["data"].to_pandas_frame(csv_options={"sep": "|"})
df = blocks["data"].to_frame(csv_options={"separator": "|"})
Wildcard block discovery
blocks = testdown.extract_blocks("scenario.md")
# Find all blocks whose names match a pattern
for block in blocks.find_all("expected_actual_*"):
category = block.name.split("_", 2)[2]
assert run_actual(category) == block.to_dict()
Development
Setup
# Install all dependencies including dev and data extras
uv sync --all-extras
Linting and formatting
# Check for lint errors
uvx ruff check .
# Auto-fix lint errors where possible
uvx ruff check --fix .
# Check formatting
uvx ruff format --check .
# Apply formatting
uvx ruff format .
# Check non-Python file formatting (JSON, YAML, Markdown, etc.)
npx prettier --check .
# Apply Prettier formatting
npx prettier --write .
Type checking
uv run mypy testdown
Tests and coverage
# Run tests with coverage report (fails below 80%)
uv run pytest
# Run a specific test file
uv run pytest tests/test_testdown.py
# Run a specific test by name
uv run pytest -k test_extract_blocks_from_path
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 testdown-1.0.1.tar.gz.
File metadata
- Download URL: testdown-1.0.1.tar.gz
- Upload date:
- Size: 15.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5a206ac974cd5c3565665fc5d9fa60b4c9036bfba88302acbbe3466114ab4b75
|
|
| MD5 |
a12e990051a9e5af63c4978e74793c84
|
|
| BLAKE2b-256 |
4ab9069cf1e5a282fb25fc6c512769fc4ef96b2524cdc379d6d1d6f9ab3bdd2b
|
Provenance
The following attestation bundles were made for testdown-1.0.1.tar.gz:
Publisher:
release.yaml on rocketboosters/testdown
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
testdown-1.0.1.tar.gz -
Subject digest:
5a206ac974cd5c3565665fc5d9fa60b4c9036bfba88302acbbe3466114ab4b75 - Sigstore transparency entry: 1004893363
- Sigstore integration time:
-
Permalink:
rocketboosters/testdown@24727eecae11176d97ac67478d5186d4af0ca282 -
Branch / Tag:
refs/tags/v1.0.1 - Owner: https://github.com/rocketboosters
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yaml@24727eecae11176d97ac67478d5186d4af0ca282 -
Trigger Event:
push
-
Statement type:
File details
Details for the file testdown-1.0.1-py3-none-any.whl.
File metadata
- Download URL: testdown-1.0.1-py3-none-any.whl
- Upload date:
- Size: 14.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 |
3c3e8841cb8048c0206a88145aac8f6d7d8463f0cf154c82e6effda7be6e4550
|
|
| MD5 |
be14aa1d56612593726beca8ba03782a
|
|
| BLAKE2b-256 |
77aa3e10dd81e09e9fc954419e0f66c044bef440ec28421af8b3fe22a042b0c1
|
Provenance
The following attestation bundles were made for testdown-1.0.1-py3-none-any.whl:
Publisher:
release.yaml on rocketboosters/testdown
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
testdown-1.0.1-py3-none-any.whl -
Subject digest:
3c3e8841cb8048c0206a88145aac8f6d7d8463f0cf154c82e6effda7be6e4550 - Sigstore transparency entry: 1004893367
- Sigstore integration time:
-
Permalink:
rocketboosters/testdown@24727eecae11176d97ac67478d5186d4af0ca282 -
Branch / Tag:
refs/tags/v1.0.1 - Owner: https://github.com/rocketboosters
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yaml@24727eecae11176d97ac67478d5186d4af0ca282 -
Trigger Event:
push
-
Statement type: