Testing Guide¶
This guide covers Recursivist's test suite and conventions. It's intended for contributors adding features or fixing bugs.
Framework¶
Recursivist uses pytest, with Hypothesis for property-based tests and Nox as the task runner. Tests run across Python 3.10–3.14 in isolated environments. Coverage reporting is enabled by default through addopts in pyproject.toml.
The suite exercises directory scanning, filtering, sorting, tree rendering, every export format, comparison, file statistics, Git status, colorization, and the CLI.
Running Tests¶
# Full suite across all supported Python versions
nox -s tests
# Pass arguments through to pytest
nox -s tests -- -v
nox -s tests -- tests/test_scanner.py
nox -s tests -- -k "pattern"
# Run pytest directly in the active environment
pytest
pytest -xvs tests/test_filtering.py
Coverage prints automatically — no extra flags are needed.
Markers¶
Two custom markers are defined:
integration: end-to-end tests that build real directory trees and invoke the CLI.property: Hypothesis property-based tests.
Test Organization¶
Tests mirror the package's modules:
tests/
├── conftest.py # Shared fixtures and configuration
├── helpers.py # Test helper utilities
├── strategies.py # Hypothesis strategies for property tests
├── test_cli.py # Command-line interface
├── test_flags.py # Flag resolution (DisplayOptions, command-line order)
├── test_models.py # FileEntry and positional coercion
├── test_scanner.py # Directory traversal
├── test_tree.py # Tree rendering
├── test_filtering.py # Exclusion, glob, and regex logic
├── test_sorting.py # File ordering and similarity grouping
├── test_metrics.py # Lines of code, size, mtime
├── test_colors.py # Per-extension colors
├── test_git_status.py # Git status lookup
├── test_exporters.py # Export formats
├── test_compare.py # Directory comparison
└── test_integration.py # End-to-end scenarios
Writing Tests¶
Directory Operations¶
Use the tmp_path fixture to build a structure on disk:
from recursivist.scanner import get_directory_structure
def test_get_directory_structure(tmp_path):
(tmp_path / "dir1").mkdir()
(tmp_path / "dir1" / "file1.txt").write_text("content")
(tmp_path / "dir2").mkdir()
(tmp_path / "dir2" / "file2.py").write_text("print('hello')")
structure, extensions = get_directory_structure(str(tmp_path))
assert "dir1" in structure
assert "file1.txt" in structure["dir1"]["_files"]
assert ".py" in extensions
CLI Commands¶
Use Typer's CliRunner:
from typer.testing import CliRunner
from recursivist.cli import app
def test_visualize_command(tmp_path):
(tmp_path / "test_file.txt").write_text("content")
result = CliRunner().invoke(app, ["visualize", str(tmp_path)])
assert result.exit_code == 0
assert "test_file.txt" in result.stdout
Export Formats¶
Construct an exporter via the factory and check the output:
from recursivist.scanner import get_directory_structure
from recursivist.exporters import get_exporter
def test_export_to_markdown(tmp_path):
(tmp_path / "test_file.txt").write_text("content")
output_path = tmp_path / "output.md"
structure, _ = get_directory_structure(str(tmp_path))
get_exporter("md", structure=structure, root_name=tmp_path.name).export(str(output_path))
content = output_path.read_text()
assert "# 📂" in content
assert "test_file.txt" in content
File Statistics¶
_files entries are FileEntry tuples with fields (name, path, loc, size, mtime). Read them by attribute (after normalizing with FileEntry.from_raw) or by index, since FileEntry subclasses tuple:
import os
from recursivist.scanner import get_directory_structure
from recursivist._models import FileEntry
def test_file_statistics(tmp_path):
py_file = tmp_path / "test.py"
py_file.write_text("line 1\nline 2\nline 3\n")
structure, _ = get_directory_structure(
str(tmp_path), sort_by_loc=True, sort_by_size=True, sort_by_mtime=True
)
assert structure["_loc"] == 3
assert structure["_size"] == os.path.getsize(str(py_file))
entry = FileEntry.coerce(structure["_files"][0])
assert entry.name == "test.py"
assert entry.loc == 3
assert isinstance(entry, tuple) # still a tuple
Parametrization¶
Cover several scenarios with one test body:
import pytest
from recursivist.scanner import get_directory_structure
@pytest.mark.parametrize(
"exclude_dirs, expected",
[
(["dir1"], ["file2.py"]),
(["dir2"], ["file1.txt"]),
([], ["file1.txt", "file2.py"]),
],
)
def test_exclude_directories(tmp_path, exclude_dirs, expected):
(tmp_path / "dir1").mkdir()
(tmp_path / "dir1" / "file1.txt").write_text("x")
(tmp_path / "dir2").mkdir()
(tmp_path / "dir2" / "file2.py").write_text("x")
structure, _ = get_directory_structure(str(tmp_path), exclude_dirs=exclude_dirs)
names = []
for key, value in structure.items():
if isinstance(value, dict):
names += [f if isinstance(f, str) else f[0] for f in value.get("_files", [])]
assert sorted(names) == sorted(expected)
Property-Based Tests¶
Property tests use Hypothesis with strategies defined in tests/strategies.py and are marked property. They assert invariants over many generated inputs (for example, that scanning never raises on arbitrary valid trees, or that a name always survives a round trip through FileEntry.from_raw). Mark new ones accordingly:
import pytest
from hypothesis import given
@pytest.mark.property
@given(...) # a strategy from tests/strategies.py
def test_invariant(value):
...
Fixtures and Mocking¶
Put shared setup in conftest.py as fixtures, and reuse utilities from helpers.py. Use monkeypatch or unittest.mock for filesystem edge cases:
import os
def test_permission_denied(tmp_path, monkeypatch):
def deny(_):
raise PermissionError("denied")
monkeypatch.setattr(os, "listdir", deny)
structure, extensions = get_directory_structure(str(tmp_path))
assert structure == {}
assert not extensions
Edge Cases¶
Always cover empty directories, nonexistent paths, permission errors, and binary files:
def test_binary_files(tmp_path):
(tmp_path / "binary.bin").write_bytes(b"\x00\x01\x02\x03")
structure, _ = get_directory_structure(str(tmp_path), sort_by_loc=True)
assert structure["_loc"] == 0 # binary files count as 0 lines
Debugging Failing Tests¶
# Stop at the first failure with verbose output
pytest -xvs tests/test_scanner.py::test_function
# Drop into the debugger on failure
pytest --pdb
You can also add breakpoint() inside a test to start an interactive session at that point.
Continuous Integration¶
Tests run automatically on every push and pull request via GitHub Actions, exercising the full suite across Python 3.10–3.14 with Nox. Push your branch and check the results — no local CI configuration is needed.
Best Practices¶
Keep tests independent and fast, focus each test on one behavior, use descriptive names, cover failure cases and edge cases alongside the happy path, and add tests for every new feature or bug fix.