Write shell tests in markdown with executable console code fences.
Inspired by Python's Cram and doctest, mdtest turns CLI documentation into tests. Features include temp directory per test file, persistent shell context across blocks, rich pattern matching (wildcards, regex, named captures), and Bun test runner integration (experimental - see ROADMAP.md for known issues).
1. Write a test (example.test.md):
# My CLI Tests
```console
$ echo "Hello, mdtest!"
Hello, mdtest!
```
```console
$ date +"%Y"
/\d{4}/
```2. Run tests:
mdtest example.test.md # Standalone CLI (recommended)Alternative: Bun test runner (currently limited - see Bun Integration):
// tests/md.test.ts
import { registerMdTests } from "@beorn/mdtest/bun";
await registerMdTests("tests/**/*.test.md");bun test tests/md.test.ts- Temp directory - Each test file runs in fresh temp dir,
$ROOTpoints to source tree - Persistent context - Environment, cwd, bash functions persist across blocks
- Helper files - Create files from code fences using
file=syntax - Pattern matching - Wildcards
*, regex/pattern/, ellipsis[...], named captures{{name:*}} - Exit codes & stderr - Test failures
[N], stderr with!prefix - Lifecycle hooks -
beforeAll,afterAll,beforeEach,afterEach - Snapshot updates -
--updateflag to refresh expected output
bun add -d mdtestRun .test.md files with markdown-formatted output:
mdtest tests/example.test.md # Single file
mdtest tests/**/*.test.md # Multiple files (glob pattern)
mdtest --update tests/example.test.md # Update snapshotsCLI Options:
--update- Replace expected output with actual output (snapshot mode)--hide-body- Hide markdown body text in output (body shown by default)--no-trunc- Disable truncation of long lines (truncates at 70 chars by default)- File patterns - Glob patterns or file paths
Output: Markdown format with headings, ✓/✗ marks, colored diffs, and body text
Debug Mode:
DEBUG='mdtest:*' mdtest tests/example.test.md # All debug output
DEBUG='mdtest:runner' mdtest tests/example.test.md # Test execution only
DEBUG='mdtest:files' mdtest tests/example.test.md # File creation only
DEBUG='mdtest:session' mdtest tests/example.test.md # Session state onlyUses the debug package. Available namespaces:
mdtest:runner- Test file discovery, parsing, and executionmdtest:files- Helper file creation fromfile=blocksmdtest:session- Session state management (env, cwd, functions)
Bun.spawn() subprocess regression (empty stdout/stderr when run inside bun test). Tests worked previously but stopped working (likely Bun version regression or environment change). Use the standalone CLI instead. See ROADMAP.md for investigation status and workarounds.
Run .test.md through bun test for mixed .ts/.md suites:
Setup: Create tests/md.test.ts:
import { registerMdTests } from "@beorn/mdtest/bun";
await registerMdTests("tests/**/*.test.md");Run:
bun test # All tests (.ts + .md)
bun test tests/md.test.ts # Just .md tests
bun test --test-name-pattern="init" # Filter by test namePlanned Benefits: Uses Bun's reporters, works with --watch, --coverage, --bail (when Bun.spawn() issue resolved)
mdtest builds on ideas from existing markdown shell testing tools:
| Tool | Format | Temp Dir | Context Persistence | Test Framework Integration | Pattern Matching |
|---|---|---|---|---|---|
| mdtest | console fences |
Auto | Yes (env, cwd, functions) | Bun (Jest/Vitest planned) | Wildcards, regex, ellipsis, named captures |
| Cram | Indented blocks | Manual (cd $CRAMTMP) |
Limited (env only) | Standalone only | Basic wildcards, ellipsis |
| mdsh | bash fences |
None | Yes (single shell session) | None (documentation focus) | None (exact matching) |
| mdsh + Cram | Mixed | Manual | Partial | Standalone only | Basic wildcards |
Key differences:
- Automatic temp dir - Tests run in fresh temp dir by default (ADR-004)
- Bun integration - Mixed
.test.ts+.test.mdsuites (Jest/Vitest planned) - Rich patterns - Named captures, regex, capture reuse
- Lifecycle hooks -
beforeAll,afterAll,beforeEach,afterEach
Use mdtest for: CLI testing with framework integration, mixed suites, rich assertions Use Cram for: Simple Python-based testing, minimal dependencies Use mdsh for: Executable documentation, literate programming
Commands start with $ and can be single-line or multi-line:
```console
$ echo "single line"
Hello
```
```console
$ node --eval "
> console.log('multi-line command');
> console.log('use > for continuation');
> "
multi-line command
use > for continuation
```
```console
$ cat <<EOF
> Line 1
> Line 2
> EOF
Line 1
Line 2
```Multi-line rules:
- First line starts with
$ - Continuation lines start with
> - Expected output comes after the command completes
- Works with any shell command (heredocs, pipes, node --eval, etc.)
Match dynamic output with patterns instead of exact strings:
```console
$ date +"%Y-%m-%d"
/\d{4}-\d{2}-\d{2}/
```
```console
$ echo "UUID: $(uuidgen)"
UUID: {{uuid:/[0-9A-F-]{36}/}}
```
```console
$ echo "Saved as: {{uuid}}"
Saved as: {{uuid}}
```Available patterns:
[...]or...- Ellipsis (matches 0+ lines when alone, or inline text)/regex/- Regular expression{{name:*}}- Named capture (wildcard){{name:/regex/}}- Named capture (regex){{name}}- Reuse captured value
Both [...] and ... work identically as a universal wildcard pattern:
On separate line (matches 0+ lines):
```console
$ ls -1
[...]
README.md
[...]
```
```console
$ echo -e "Start\nMiddle\nEnd"
Start
...
End
```Inline (matches text within a line):
```console
$ echo "Prefix: some-random-id-12345 Suffix"
Prefix: [...] Suffix
```
```console
$ echo "User: $USER, Time: $(date +%s)"
User: ..., Time: ...
```
```console
$ echo "A: value1 B: value2 C: value3"
A: [...] B: ... C: [...]
```With brackets (JSON/arrays - use [...] for clarity):
```console
$ echo '["item1", "item2", "item3"]'
[[...]]
```Indented (preserves indentation):
```console
$ cat structure.json
{
[...]
"key": "value"
}
```Usage notes:
- When alone on a line (trimmed): matches zero or more lines
- When inline: matches text (equivalent to regex
.+) - Both
[...]and...are functionally identical - Use
[...]when...might be ambiguous (e.g., in prose) - Multiple wildcards per line:
A: ... B: ... C: ...
```console
$ false
[1]
```
```console
$ echo "error" >&2
! error
```
```console
$ nonexistent-command
! command not found: nonexistent-command
[127]
```Configure test blocks via fence info string:
```console cwd=/tmp
$ pwd
/tmp
```
```console env=DEBUG=1
$ echo $DEBUG
1
```
```console timeout=5000
$ sleep 10
! Command timed out after 5000ms
[124]
```
```console reset
$ # Fresh context (env/cwd reset)
```Define setup/teardown as bash functions:
```console
$ beforeAll() {
> mkdir -p test-data
> }
$ afterAll() {
> rm -rf test-data
> }
```Available hooks: beforeAll, afterAll, beforeEach, afterEach
Create files in the test temp directory using file= in fence info:
```bash file=helpers.sh
greet() {
echo "Hello, $1!"
}
export API_URL="http://localhost:3000"
```
```console
$ source helpers.sh
$ greet "mdtest"
Hello, mdtest!
``````typescript file=config.ts
export const config = {
timeout: 5000,
retries: 3,
};
```
```console
$ cat config.ts
export const config = {
timeout: 5000,
retries: 3
}
```How it works:
- Files are created in test temp directory before any tests run
- Available to all test commands in that file
- Bash helper files can be sourced with
source filename - Any language fence can use
file=(bash, typescript, json, etc.) - File path is relative to temp directory (
$PWD)
Use cases:
- Shared bash functions across multiple test blocks
- Configuration files for CLI tools
- Mock data files (JSON, YAML, etc.)
- Test fixtures
- Markdown parsing: Extracts
consolecode fences using remark - Per-command matching: Each
$ commandgets its own expected output (not shared across block) - Persistent context: Shell state (env, cwd, bash functions) saved to temp files between blocks
- Serial execution: Tests run sequentially to preserve state
- Shell execution: Uses custom shell adapter wrapping bash (runtime-portable design)
Register individual files instead of glob pattern:
import { registerMdTestFile } from "@beorn/mdtest/bun";
await registerMdTestFile("tests/specific.test.md");For planned features, known issues, and development priorities, see ROADMAP.md.