Write test suites for SKELETON packages (zero test coverage) to uplift them to DEVELOPMENT maturity. Handles non-importable modules, optional dependencies, separate sub-repos, and Flask/web packages without runtime.
Scanned 9/9/2026
Install to Claude Code
npx -y skills add vamseeachanta/workspace-hub --skill skeleton-test-coverage-uplift --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Skeleton Test Coverage Uplift?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/vamseeachanta-skeleton-test-coverage-uplift-workspace-hub)More formats (shields.io, HTML) on the badges page.
---
name: skeleton-test-coverage-uplift
description: Write test suites for SKELETON packages (zero test coverage) to uplift them to DEVELOPMENT maturity. Handles non-importable modules, optional dependencies, separate sub-repos, and Flask/web packages without runtime.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [testing, coverage, skeleton, maturity, TDD]
related_skills: [repo-architecture-analysis, test-driven-development]
---
# 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:
```bash
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
1. **Import tests** — verify package is importable
2. **Class instantiation** — create objects with valid params
3. **Config/schema validation** — if Pydantic/dataclass models exist
4. **Pure function tests** — known-input/known-output for math functions
5. **Edge cases** — invalid inputs, zero/negative values, missing keys
6. **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:
```python
# 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_s` vs `current_velocity_ms` (underscore convention)
- Missing required params (e.g., `water_depth_m` for 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:
- `cd` into the sub-repo to commit
- Use `git pull origin main` from inside the sub-repo
- `git add` + `git commit` from 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, Optional` with explicit `List[str]`, `Dict[str, int]`, `Optional[int]` instead of `list[str]`, `dict[str, int]`, `int | None`
- Or register the module in `sys.modules` before `exec_module()`:
```python
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:
1. `grep "ERROR"` matches hundreds of test names containing "error" (e.g., `test_invalid_units_raises_value_error`)
2. The `short test summary info` section with `ERROR tests/path/file.py` lines may not appear in stdout due to capture bugs
3. The INTERNALERROR traceback at the end can mask the real error section
4. Error counts vary between runs when `pytest-randomly` is active
**Reliable technique:**
```bash
# 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:
```python
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:
1. **Direct `open()` in `execute_code`** — bypasses sandbox, writes to the real mount:
```python
from hermes_tools import terminal
with open("/mnt/local-analysis/.../file.py", 'w') as f:
f.write(content)
```
2. **Do NOT use `read_file` output as file content** — it includes line number prefixes (` 1|"""`) that corrupt the file. If you need to copy content between overlay and mount, use raw `open()` for both reading and writing within `execute_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`/`patch` for editing (tests run against overlay fine)
- Before `git commit`, verify mount has correct content: `head -3 /mnt/.../file.py`
- If corrupted, rewrite via `execute_code` with raw `open()`
### __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):**
1. **Install the missing dep:** `uv pip install pylife` — preferred if it's a legitimate dependency
2. **Lazy import in __init__.py:** wrap optional imports in try/except
3. **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:
1. Wrap all logic in functions with proper signatures
2. Add input validation that raises `ValueError` on bad inputs
3. Add `if __name__ == '__main__':` guard
4. Write tests using `sys.modules` mock 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:
```python
@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:
```python
# 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.parametrize` over 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:
```python
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.modules` via `monkeypatch.setitem`
- A fixture that returns a pre-built fake model instance
This lets pytest auto-discover fixtures across all test files without duplication:
```python
# 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:
```python
# 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:
```python
# 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:
```python
# 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
```python
# 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`
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!