SKELETON → DEVELOPMENT Test Coverage Uplift
Write test suites for packages with zero test coverage to move them from SKELETON to DEVELOPMENT maturity.
When to Use
- Maturity matrix shows SKELETON packages (source files but 0 tests)
- Issue calls for test coverage on untested packages
- Overnight batch prompt targets coverage uplift
Process
1. Read ALL source files first
Read every .py file in the package before writing tests. Understand:
- What classes/functions exist and their signatures
- What external dependencies are required (matplotlib, Flask, etc.)
- Whether modules are importable (some are raw scripts with undefined globals)
- What dataclasses/Pydantic models exist for validation tests
2. Check what already exists
Before creating files, check if test directories/files already exist:
git status --short tests/{package_name}/
The test files may already be tracked but have bugs (wrong arg names, etc.). Fix rather than overwrite.
3. Categorize modules by testability
| Category | Strategy |
|---|---|
| Pure functions (math, dataclasses) | Direct unit tests with known inputs/outputs |
| Classes with optional deps | pytest.importorskip("dep") at module level |
| Raw scripts (undefined globals) | Source analysis tests (read file, check patterns) |
| Web/Flask modules | Source pattern analysis without Flask runtime |
| Modules with external I/O | Mock everything; test structure not behavior |
4. Write tests following this priority
- Import tests — verify package is importable
- Class instantiation — create objects with valid params
- Config/schema validation — if Pydantic/dataclass models exist
- Pure function tests — known-input/known-output for math functions
- Edge cases — invalid inputs, zero/negative values, missing keys
- Source analysis — when module can't be imported, read source and verify patterns
Key Pitfalls
Optional dependencies cause collection errors
If a test file has import matplotlib at top level and matplotlib isn't in the venv, the ENTIRE test module fails to collect (not just individual tests).
Fix: Use pytest.importorskip() instead of bare imports:
# BAD — fails entire module if missing
import matplotlib
matplotlib.use("Agg")
# GOOD — skips entire module gracefully
matplotlib = pytest.importorskip("matplotlib")
matplotlib.use("Agg")
Wrong function signatures in tests
Read the actual function signature before writing tests. Common mistakes:
current_velocity_m_svscurrent_velocity_ms(underscore convention)- Missing required params (e.g.,
water_depth_mfor monopile scour) - Wrong keyword names from docstring vs actual parameter names
Fix: Always read the source, don't guess from naming conventions.
Sub-repos vs workspace repos
The workspace-hub pattern has sub-repos (e.g., digitalmodel/) that are gitignored in the parent. You must:
cdinto the sub-repo to commit- Use
git pull origin mainfrom inside the sub-repo git add+git commitfrom inside the sub-repo, not the parent
from __future__ import annotations breaks dataclass import via importlib
When a script uses from __future__ import annotations AND @dataclass, importing it via importlib.util.spec_from_file_location() fails on Python 3.11 with AttributeError: 'NoneType' object has no attribute '__dict__' in dataclasses._is_type(). This happens because the module isn't registered in sys.modules during dynamic import, so deferred annotation evaluation can't resolve type hints.
Fix: In scripts that will be dynamically imported by tests:
- Remove
from __future__ import annotations - Use
from typing import Dict, List, Optionalwith explicitList[str],Dict[str, int],Optional[int]instead oflist[str],dict[str, int],int | None - Or register the module in
sys.modulesbeforeexec_module():
spec = importlib.util.spec_from_file_location("my_module", path)
mod = importlib.util.module_from_spec(spec)
sys.modules["my_module"] = mod # <-- fixes the issue
spec.loader.exec_module(mod)
Finding actual collection errors in noisy pytest output
When running pytest --co on a large monorepo, finding the ACTUAL collection errors is hard because:
grep "ERROR"matches hundreds of test names containing "error" (e.g.,test_invalid_units_raises_value_error)- The
short test summary infosection withERROR tests/path/file.pylines may not appear in stdout due to capture bugs - The INTERNALERROR traceback at the end can mask the real error section
- Error counts vary between runs when
pytest-randomlyis active
Reliable technique:
# Step 1: Disable noisy plugins, disable capture to get full output
uv run pytest --co -p no:randomly -p no:sugar -p no:capture tests/ 2>&1 > /tmp/co.txt
# Step 2: Find the short test summary (the only reliable error list)
grep "^ERROR tests/" /tmp/co.txt
# Step 3: Find INTERNALERROR lines (plugin bugs, not test errors)
grep "^INTERNALERROR>" /tmp/co.txt | head -5
# Step 4: Get the summary line
grep "collected.*error" /tmp/co.txt
Key: -p no:capture prevents the ValueError: I/O operation on closed file crash that hides the error section. The ^ERROR tests/ pattern with anchored regex avoids matching test names.
Scoping pitfall: In monorepos with nested sub-repos (e.g., digitalmodel/ inside workspace-hub/), pytest --co from the sub-repo may collect BOTH sub-repo AND parent tests via conftest.py chaining. Always pass the explicit tests/ path argument.
Per-package pytest for large monorepos
Running pytest digitalmodel/tests/ on a large monorepo with 6000+ tests and 150+ collection errors fails — it hits --maxfail=50 and stops, or with --maxfail=0 --continue-on-collection-errors it crashes with ValueError: I/O operation on closed file. No individual test results are produced.
Fix: Run pytest per subdirectory to isolate collection errors:
for sub in sorted(tests_dir.iterdir()):
if not sub.is_dir() or sub.name.startswith("_"):
continue
result = subprocess.run(
[sys.executable, "-m", "pytest", str(sub), "-v", "--tb=no", "-q"],
capture_output=True, text=True, timeout=120,
)
# Parse the summary line from result.stdout
This gives per-package pass/fail/skip/error counts even when some packages have collection errors. The monolithic run approach is unreliable for repos with many optional dependencies.
Sandbox overlay vs mounted filesystem paths
When working with repos on mounted paths (e.g., /mnt/local-analysis/workspace-hub/digitalmodel/), write_file and patch tools write to the sandbox overlay, NOT the actual mount. Git sees the mount's version. Two approaches:
- Direct
open()inexecute_code— bypasses sandbox, writes to the real mount:
from hermes_tools import terminal
with open("/mnt/local-analysis/.../file.py", 'w') as f:
f.write(content)
- Do NOT use
read_fileoutput as file content — it includes line number prefixes (1|""") that corrupt the file. If you need to copy content between overlay and mount, use rawopen()for both reading and writing withinexecute_code.
Pitfall sequence: write_file creates clean file in overlay → pytest sees overlay and passes → git add sees mount (old file) → commit captures wrong content → __init__.py import fails at runtime with IndentationError because line numbers are embedded.
Safe workflow for mounted repos:
- Use
write_file/patchfor editing (tests run against overlay fine) - Before
git commit, verify mount has correct content:head -3 /mnt/.../file.py - If corrupted, rewrite via
execute_codewith rawopen()
init.py import chains break ALL package tests
When package/__init__.py eagerly imports from submodules (e.g., from .sn_curves import ...), and ANY submodule requires an uninstalled dependency, EVERY test that touches the package fails — even tests that only import a different submodule. The error is a ModuleNotFoundError during __init__.py collection.
Diagnosis: The traceback will show:
tests/fatigue/test_sn_library_api.py:14: in <module>
from digitalmodel.fatigue.sn_library_api import ...
src/digitalmodel/fatigue/__init__.py:18: in <module>
from .sn_curves import get_sn_curve, DNV_CURVES
ModuleNotFoundError: No module named 'pylife'
Fixes (pick one):
- Install the missing dep:
uv pip install pylife— preferred if it's a legitimate dependency - Lazy import in init.py: wrap optional imports in try/except
- Direct submodule import in tests:
from digitalmodel.fatigue.sn_library import ...instead of going through__init__.py(fragile — breaks if other code imports the package normally)
Key insight: Before running tests on a package for the first time, check __init__.py imports and verify all deps are installed. One missing dep blocks the entire package test suite.
Bytecode compilation timeout on first run
The first uv run pytest in a session compiles ~32K bytecode files (2+ minutes). Set timeout=300 for the first pytest invocation. Subsequent runs take ~20-25s. If a test run times out, just re-run — the compilation is cached.
Real fixture data may vary from assumptions
When using real spec.yml files as test fixtures (e.g., L00 validation cases), don't hard-assert a single expected value for polymorphic fields like analysis_type. Some cases use diffraction, others full_qtf. Use assert x in (expected_set) instead.
Non-importable modules (raw scripts) — refactoring to importable
Some files (e.g., reservoir/stratigraphic.py) use undefined global variables like WELL1, df_logs. These cannot be imported. When refactoring these:
- Wrap all logic in functions with proper signatures
- Add input validation that raises
ValueErroron bad inputs - Add
if __name__ == '__main__':guard - Write tests using
sys.modulesmock injection for missing deps
Mocking matplotlib when not installed — use autouse fixture to inject mocks into sys.modules AND invalidate cached imports of the target module so it re-imports with mocks:
@pytest.fixture(autouse=True)
def mock_matplotlib(monkeypatch):
"""Inject mock matplotlib when it's not installed in the test venv."""
import sys
mock_mpl = MagicMock()
mock_pyplot = MagicMock()
mock_colors = MagicMock()
mock_colors.ListedColormap.return_value = MagicMock()
mock_mpl.pyplot = mock_pyplot
mock_mpl.colors = mock_colors
monkeypatch.setitem(sys.modules, "matplotlib", mock_mpl)
monkeypatch.setitem(sys.modules, "matplotlib.pyplot", mock_pyplot)
monkeypatch.setitem(sys.modules, "matplotlib.colors", mock_colors)
# CRITICAL: invalidate cached imports of the module under test
for key in list(sys.modules.keys()):
if "stratigraphic" in key:
monkeypatch.delitem(sys.modules, key, raising=False)
return mock_pyplot
Pitfall: After this mock, patch.object(module.plt, "subplots", ...) won't work because module.plt IS the mock. Instead, configure the mock directly:
# BAD — patch.object on an already-mocked attribute
with patch.object(stratigraphic.plt, "subplots", return_value=(mock_fig, mock_axes)):
...
# GOOD — configure the mock's return value directly
stratigraphic.plt.subplots.return_value = (mock_fig, mock_axes)
stratigraphic.plt.Normalize.return_value = MagicMock()
fig = stratigraphic.create_cross_section(wells_list, df_logs, statdata)
Web/Flask packages without Flask installed
When Flask is not in the venv (69 source files, 0 testable at runtime):
Strategy: Source pattern analysis tests
- Verify directory structure (all blueprint dirs exist, have init.py)
- Read source files and check for Blueprint declarations, route patterns
- Use
@pytest.mark.parametrizeover all expected blueprints for coverage - Check for auth patterns, CRUD operations, template references
This generated 102 tests from 5 files for a 69-file Flask app with zero Flask dependency:
EXPECTED_BLUEPRINTS = ["todorestful", "BuoySALM", "GoMFields", ...]
@pytest.mark.parametrize("bp_name", EXPECTED_BLUEPRINTS)
def test_blueprint_directory_exists(self, bp_name):
bp_dir = os.path.join(_dtf_root(), bp_name)
assert os.path.isdir(bp_dir)
Shared conftest.py for mocked external APIs
When testing modules that depend on licensed/unavailable APIs (OrcFxAPI, ANSYS, etc.), create a shared conftest.py with:
- Fake type classes (FakeLine, FakeModel, etc.) that mimic real API objects
- A fixture that injects fake module into
sys.modulesviamonkeypatch.setitem - A fixture that returns a pre-built fake model instance
This lets pytest auto-discover fixtures across all test files without duplication:
# tests/orcaflex/conftest.py
@pytest.fixture
def fake_orcfxapi(monkeypatch):
fake = types.SimpleNamespace(Line=FakeLine, Model=FakeModel, oeEndA=1)
monkeypatch.setitem(sys.modules, "OrcFxAPI", fake)
return fake
@pytest.fixture
def fake_model():
return FakeModel()
YAML safe_load parses timestamps as datetime objects
yaml.safe_load() converts ISO 8601 timestamps (e.g., 2026-04-01T12:00:00Z) to datetime objects, NOT strings. Tests that compare parsed YAML timestamps must handle both types:
# BAD — fails because safe_load returns datetime, not str
assert health["last_completed_at"] == "2026-04-01T12:00:00Z"
# GOOD — convert to string for comparison
last = str(health["last_completed_at"])
assert "2026-04-01" in last and "12:00:00" in last
HTML class counting includes CSS style blocks
When asserting HTML content, html.count("section-card") counts occurrences everywhere — including CSS .section-card { ... } rules. Use the full attribute selector:
# BAD — counts CSS references too
assert html.count("section-card") == 8
# GOOD — counts only actual element class attributes
div_count = html.count('class="section-card"')
Route matching patterns
Flask routes may include methods=["GET"] which changes the string match:
# Source has: @dtf.route("/", methods=["GET"])
# This assertion FAILS:
assert '@dtf.route("/")' in src
# This assertion PASSES:
assert '@dtf.route("/", methods=["GET"])' in src
Test file template
# ABOUTME: Tests for digitalmodel.{package} — SKELETON → DEVELOPMENT uplift.
"""Tests for {package} — imports, calculations, edge cases."""
import os
import pytest
class TestPackageImport:
def test_import_package(self):
import digitalmodel.{package}
assert digitalmodel.{package} is not None
class TestModuleExistence:
def _package_dir(self) -> str:
import digitalmodel.{package}
return os.path.dirname(digitalmodel.{package}.__file__)
def test_expected_module_exists(self):
assert os.path.isfile(os.path.join(self._package_dir(), "module.py"))
Acceptance criteria (from maturity rules)
SKELETON → DEVELOPMENT requires:
- ≥1 test file per package
- ≥3 test functions recommended (import + instantiation + edge case minimum)
- All tests pass without external resources
- Tests are in subdirectory format:
tests/{package_name}/test_{package}.py