Skip to content

Testing

Running Tests

# Run the full test suite with coverage
pytest

# Run a specific tier
pytest tests/unit/
pytest tests/integration/
pytest tests/e2e/

# Run with verbose output
pytest -v

# Run a single file
pytest tests/unit/test_scoring_service.py

Coverage Target

Minimum: 80% across all source files.

Coverage configuration in pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=src/portfolio_manager --cov-report=term-missing --cov-fail-under=80"

[tool.coverage.run]
source = ["src/portfolio_manager"]
omit = ["*/views/*", "*/controllers/*", "*/__main__.py"]

Views and controllers are excluded from coverage since Tkinter GUI code cannot be reliably tested headlessly.


Test Tiers

graph TD
    subgraph "Unit Tests — fast, isolated"
        U1[test_models.py — dataclass construction and validation]
        U2[test_scoring_service.py — algorithm and strategy]
        U3[test_week_service.py — week key computation]
        U4[test_plan_service.py — Markdown rendering pipeline]
        U5[test_date_utils.py — ISO week helpers]
    end

    subgraph "Integration Tests — real SQLite in-memory"
        I1[test_project_repository.py — CRUD + cascade]
        I2[test_session_repository.py — CRUD + count_by_status]
        I3[test_milestone_repository.py — toggle + count]
        I4[test_project_service.py — service + repo + event bus]
    end

    subgraph "E2E Tests — headless"
        E1[test_app_startup.py — full startup smoke test]
    end

Shared Fixtures (tests/conftest.py)

Fixture Type Description
reset_singletons autouse Resets DatabaseConnection and EventBus before/after each test
in_memory_db DatabaseConnection Fully migrated in-memory SQLite connection
test_config Settings Settings pointing at :memory: database
sample_project Project A persisted active project with plan_content
sample_sessions list[Session] Three sessions (planned, completed, cancelled) for sample_project

Writing Unit Tests

Unit tests use fake repositories — plain Python objects that satisfy the interface without hitting a database:

class _FakeProjectRepo:
    def __init__(self, plan_content=""):
        from portfolio_manager.models.project import Project
        self._project = Project(id=1, name="Test", slug="test", plan_content=plan_content)

    def get(self, project_id):
        return self._project

    def update_plan(self, project_id, content):
        self._project.plan_content = content

Writing Integration Tests

Integration tests use the in_memory_db fixture and create real repository instances:

@pytest.fixture
def repo(in_memory_db):
    return ProjectRepository(in_memory_db)

def test_create_assigns_id(repo):
    p = repo.create(Project(name="Novel", slug="novel", status="active"))
    assert p.id > 0

E2E / GUI Testing

Tkinter tests are minimised because GUI testing is fragile and platform-dependent.

The headless startup test uses Tk.withdraw() to suppress the window:

def test_builds_without_error(headless_settings):
    try:
        window = build_app(settings=headless_settings)
        window.withdraw()
        window.update()
        window.destroy()
    except tk.TclError as exc:
        pytest.skip(f"No display available: {exc}")

On CI without a display the test is automatically skipped rather than failing.