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 |
|---|---|
|
A test runner and framework.
It discovers functions and methods prefixed with |
|
A statement that says "this must be true."
If the expression after |
|
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 |
|
|
Decorator ( |
A function that wraps another function, modifying its behavior.
|
Test class |
Grouping related tests in a class (prefixed with |
|
Verbose mode for pytest.
Shows each test name and its result.
Without it, pytest only shows dots ( |
|
Truncated tracebacks.
When a test fails, show only the assertion line, not the full call stack.
Useful during development; use |
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 |
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:
-
Which test failed (file, class, method).
-
The exact assertion that failed.
-
The exception type (
KeyErrormeans 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.pycontains all test classes -
Two fixtures:
graph(empty) andpopulated(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.