Phase 4: Testing

Phase 4: The Complete Test Suite

Objective

Consolidate and expand all tests into a comprehensive suite. Before adding persistence (Phase 5) or a CLI (Phase 6), the core logic must be proven correct. This phase introduces pytest idioms, fixture patterns, and the discipline of testing edge cases.

Python Concepts

Concept Plain English

pytest

A test runner and framework. It discovers functions and methods prefixed with test_, runs them, and reports pass/fail. Think of it as a validate.sh script that automatically finds all your checks.

assert

A statement that says "this must be true." If the expression after assert evaluates to False, Python raises an AssertionError and pytest marks the test as FAILED. assert x == 5 is equivalent to: [ "$x" -eq 5 ] || echo "FAIL".

@pytest.fixture

A decorator that marks a function as a fixture — setup code that runs before each test that requests it. When a test function has a parameter named graph, pytest looks for a fixture named graph, runs it, and passes the result as an argument. This eliminates repeated setup code.

yield in fixtures

yield pauses the fixture, hands the value to the test, then resumes after the test completes — this is where you would put teardown/cleanup code. For this project, no teardown is needed, but knowing the pattern is valuable.

Decorator (@)

A function that wraps another function, modifying its behavior. @pytest.fixture takes your function and registers it as a fixture. Conceptually similar to AsciiDoc roles or attributes that change how content renders — the content is the same, but the decorator changes how it is used.

Test class

Grouping related tests in a class (prefixed with Test) is organizational — it creates logical sections in test output. Not required, but it improves readability when you have many tests.

-v flag

Verbose mode for pytest. Shows each test name and its result. Without it, pytest only shows dots (. for pass, F for fail).

--tb=short

Truncated tracebacks. When a test fails, show only the assertion line, not the full call stack. Useful during development; use --tb=long when debugging complex failures.

Steps

1. Understand test file organization

The final test file has this structure:

tests/test_graph.py
├── Imports
├── Fixtures
├── TestInit          — constructor tests (Phase 1)
├── TestAssociate     — associate method tests (Phase 2)
├── TestQuery         — forward query tests (Phase 3)
├── TestReverseQuery  — reverse query tests (Phase 3)
├── TestKeys          — entity enumeration tests (Phase 3)
├── TestRelations     — relation enumeration tests (Phase 3)
└── TestEdgeCases     — boundary conditions (Phase 4)

2. Write the complete test file

Replace the contents of tests/test_graph.py entirely:

"""Comprehensive test suite for AssociationGraph."""

import pytest

from association_engine.graph import AssociationGraph


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def graph() -> AssociationGraph:
    """Return a fresh, empty graph."""
    return AssociationGraph()


@pytest.fixture
def populated() -> AssociationGraph:
    """Return a graph loaded with test associations."""
    g = AssociationGraph()
    g.associate("CISSP", "covers", "access-control")
    g.associate("CISSP", "covers", "cryptography")
    g.associate("CISSP", "covers", "network-security")
    g.associate("CISSP", "requires", "5-years-experience")
    g.associate("CCNP", "covers", "routing")
    g.associate("CCNP", "covers", "switching")
    g.associate("Python", "uses", "pip")
    g.associate("Phase-2", "blocks", "Phase-3")
    return g


# ---------------------------------------------------------------------------
# TestInit
# ---------------------------------------------------------------------------

class TestInit:
    """Constructor creates empty internal state."""

    def test_forward_starts_empty(self, graph: AssociationGraph) -> None:
        assert graph._forward == {}

    def test_reverse_starts_empty(self, graph: AssociationGraph) -> None:
        assert graph._reverse == {}

    def test_instances_are_independent(self) -> None:
        a = AssociationGraph()
        b = AssociationGraph()
        a._forward["leak"] = {}
        assert "leak" not in b._forward

    def test_custom_inverses(self) -> None:
        custom = {"parent-of": "child-of", "child-of": "parent-of"}
        g = AssociationGraph(inverses=custom)
        g.associate("Alice", "parent-of", "Bob")
        assert "Alice" in g._reverse["Bob"]["child-of"]


# ---------------------------------------------------------------------------
# TestAssociate
# ---------------------------------------------------------------------------

class TestAssociate:
    """The associate method records bidirectional entries."""

    def test_forward_entry(self, graph: AssociationGraph) -> None:
        graph.associate("CISSP", "covers", "access-control")
        assert "access-control" in graph._forward["CISSP"]["covers"]

    def test_reverse_entry(self, graph: AssociationGraph) -> None:
        graph.associate("CISSP", "covers", "access-control")
        assert "CISSP" in graph._reverse["access-control"]["covered-by"]

    def test_no_duplicates(self, graph: AssociationGraph) -> None:
        graph.associate("CISSP", "covers", "access-control")
        graph.associate("CISSP", "covers", "access-control")
        assert graph._forward["CISSP"]["covers"].count("access-control") == 1
        assert graph._reverse["access-control"]["covered-by"].count("CISSP") == 1

    def test_multiple_relations_same_source(self, graph: AssociationGraph) -> None:
        graph.associate("CISSP", "covers", "crypto")
        graph.associate("CISSP", "requires", "experience")
        assert "covers" in graph._forward["CISSP"]
        assert "requires" in graph._forward["CISSP"]

    def test_multiple_targets(self, graph: AssociationGraph) -> None:
        graph.associate("CISSP", "covers", "access-control")
        graph.associate("CISSP", "covers", "cryptography")
        targets = graph._forward["CISSP"]["covers"]
        assert targets == ["access-control", "cryptography"]

    def test_unknown_relation_gets_generic_inverse(
        self, graph: AssociationGraph
    ) -> None:
        graph.associate("A", "inspires", "B")
        assert "A" in graph._reverse["B"]["related-by"]


# ---------------------------------------------------------------------------
# TestQuery
# ---------------------------------------------------------------------------

class TestQuery:
    """Forward query returns associations for an entity."""

    def test_returns_relations(self, populated: AssociationGraph) -> None:
        result = populated.query("CISSP")
        assert "covers" in result
        assert "requires" in result

    def test_returns_targets(self, populated: AssociationGraph) -> None:
        result = populated.query("CISSP")
        assert "access-control" in result["covers"]
        assert "cryptography" in result["covers"]

    def test_unknown_key_returns_empty(self, populated: AssociationGraph) -> None:
        assert populated.query("nonexistent") == {}

    def test_returns_copy(self, populated: AssociationGraph) -> None:
        result = populated.query("CISSP")
        result["covers"].append("CORRUPTED")
        assert "CORRUPTED" not in populated.query("CISSP")["covers"]


# ---------------------------------------------------------------------------
# TestReverseQuery
# ---------------------------------------------------------------------------

class TestReverseQuery:
    """Reverse query returns what points at an entity."""

    def test_finds_sources(self, populated: AssociationGraph) -> None:
        result = populated.reverse_query("access-control")
        assert "CISSP" in result["covered-by"]

    def test_unknown_target_returns_empty(
        self, populated: AssociationGraph
    ) -> None:
        assert populated.reverse_query("nonexistent") == {}


# ---------------------------------------------------------------------------
# TestKeys
# ---------------------------------------------------------------------------

class TestKeys:
    """keys() returns all entities from both dicts."""

    def test_includes_sources(self, populated: AssociationGraph) -> None:
        k = populated.keys()
        assert "CISSP" in k
        assert "CCNP" in k
        assert "Python" in k

    def test_includes_targets(self, populated: AssociationGraph) -> None:
        k = populated.keys()
        assert "access-control" in k
        assert "routing" in k
        assert "pip" in k

    def test_sorted(self, populated: AssociationGraph) -> None:
        k = populated.keys()
        assert k == sorted(k)

    def test_empty_graph(self, graph: AssociationGraph) -> None:
        assert graph.keys() == []


# ---------------------------------------------------------------------------
# TestRelations
# ---------------------------------------------------------------------------

class TestRelations:
    """relations() returns all relation types."""

    def test_includes_all(self, populated: AssociationGraph) -> None:
        r = populated.relations()
        assert "covers" in r
        assert "requires" in r
        assert "uses" in r
        assert "blocks" in r

    def test_sorted(self, populated: AssociationGraph) -> None:
        r = populated.relations()
        assert r == sorted(r)

    def test_empty_graph(self, graph: AssociationGraph) -> None:
        assert graph.relations() == []


# ---------------------------------------------------------------------------
# TestEdgeCases
# ---------------------------------------------------------------------------

class TestEdgeCases:
    """Boundary conditions and unusual inputs."""

    def test_empty_string_entity(self, graph: AssociationGraph) -> None:
        graph.associate("", "covers", "something")
        assert graph.query("") == {"covers": ["something"]}

    def test_same_source_and_target(self, graph: AssociationGraph) -> None:
        graph.associate("Python", "relates-to", "Python")
        assert "Python" in graph.query("Python")["relates-to"]
        assert "Python" in graph.reverse_query("Python")["related-by"]

    def test_long_chain(self, graph: AssociationGraph) -> None:
        """A -> B -> C should be traversable in steps."""
        graph.associate("A", "blocks", "B")
        graph.associate("B", "blocks", "C")
        # A blocks B
        assert "B" in graph.query("A")["blocks"]
        # B blocks C
        assert "C" in graph.query("B")["blocks"]
        # C is blocked-by B
        assert "B" in graph.reverse_query("C")["blocked-by"]

3. Understand what each section tests

Section What it proves

TestInit (4 tests)

Constructor creates clean state; instances do not share data; custom inverses work.

TestAssociate (6 tests)

Forward and reverse entries are created; duplicates are prevented; multiple relations and targets work; unknown relations get a fallback inverse.

TestQuery (4 tests)

Forward lookup returns correct data; missing keys return {}; results are copies.

TestReverseQuery (2 tests)

Reverse lookup works; missing keys return {}.

TestKeys (4 tests)

All sources and targets appear; output is sorted; empty graph returns [].

TestRelations (3 tests)

All relation types appear; output is sorted; empty graph returns [].

TestEdgeCases (3 tests)

Empty string entities work; self-referential associations work; multi-hop chains are queryable step by step.

Total: 26 tests.

4. Run the full suite

uv run pytest tests/ -v

Expected output looks like:

tests/test_graph.py::TestInit::test_forward_starts_empty PASSED
tests/test_graph.py::TestInit::test_reverse_starts_empty PASSED
tests/test_graph.py::TestInit::test_instances_are_independent PASSED
tests/test_graph.py::TestInit::test_custom_inverses PASSED
tests/test_graph.py::TestAssociate::test_forward_entry PASSED
...
tests/test_graph.py::TestEdgeCases::test_long_chain PASSED

========================= 26 passed in 0.05s =========================

5. Reading test output

When a test fails, pytest shows:

FAILED tests/test_graph.py::TestQuery::test_returns_targets
    assert "access-control" in result["covers"]
    KeyError: 'covers'

This tells you:

  1. Which test failed (file, class, method).

  2. The exact assertion that failed.

  3. The exception type (KeyError means the key does not exist in the dict).

Use --tb=long for the full traceback when the short version is not enough.

6. Lint

uv run ruff check src/ tests/

Checklist

  • tests/test_graph.py contains all test classes

  • Two fixtures: graph (empty) and populated (loaded)

  • 26 tests total across 7 classes

  • Edge cases covered: empty strings, self-reference, chains

  • uv run pytest tests/ -v — all 26 pass

  • uv run ruff check src/ tests/ — clean

Verification

uv run pytest tests/ -v --tb=short 2>&1 | tail -3
# Expected: "26 passed" with no failures or warnings

The test suite is the contract. From this point forward, every change must keep these 26 tests green. If a new feature breaks an existing test, the feature is wrong, not the test.