Testing Guide¶
This guide covers Recursivist's test suite and conventions. It's intended for contributors adding features or fixing bugs; see the Development Guide for setting up an environment.
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, GitHub repository inputs, 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
├── 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 model
├── 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_github.py # GitHub URL parsing and repository checkout
├── test_exporters.py # Export formats
├── test_compare.py # Directory comparison
└── test_integration.py # End-to-end scenarios
Writing Tests¶
Write tests for all new features and bug fixes. Place them in the tests/ file that matches the module being tested (for example, tests/test_colors.py for recursivist/colors.py), and follow the style of the tests already there:
# tests/test_colors.py
from recursivist.colors import build_color_map
def test_build_color_map():
# Given
extensions = [".py", ".md"]
# When
color_map = build_color_map(extensions)
# Then
assert set(color_map) == set(extensions)
for color in color_map.values():
assert color.startswith("#")
assert len(color) == 7
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.subdirectories
assert [f.name for f in structure.subdirectories["dir1"].files] == ["file1.txt"]
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¶
A directory's files are FileEntry tuples with fields (name, path, loc, size, mtime). Read them by attribute or by index, since FileEntry subclasses tuple:
import os
from recursivist.scanner import get_directory_structure
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 = structure.files[0]
assert entry.name == "test.py"
assert entry.loc == 3
assert isinstance(entry, tuple) # FileEntry subclasses 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 subdirectory in structure.subdirectories.values():
names += [f.name for f in subdirectory.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 sorting always preserves the set of file names). 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. Use monkeypatch or unittest.mock for filesystem edge cases:
import os
from recursivist._models import Directory
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 == Directory()
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.