9.24 Testing Graph Code
You need confidence that a graph algorithm handles real inputs, edge cases, and malformed assumptions correctly.
9.24 Testing Graph Code
Problem
You need confidence that a graph algorithm handles real inputs, edge cases, and malformed assumptions correctly.
Graph code is easy to get almost right. A DFS may work on connected graphs but fail on disconnected ones. A cycle detector may work for directed graphs but be wrong for undirected graphs. A shortest-path implementation may pass small examples but fail when vertices are isolated, labels are missing, or edge weights are negative.
You need a testing strategy that checks both algorithmic correctness and graph-model correctness.
Solution
Test graph code at three levels:
- Representation tests
- Algorithm tests
- Property tests
Representation tests verify that the graph is built correctly.
Algorithm tests verify known examples.
Property tests verify invariants that must always hold.
Good graph testing begins before the algorithm runs.
Discussion
Most graph bugs come from one of four sources:
| Bug Type | Example |
|---|---|
| Representation error | Missing sink or isolated vertex |
| Direction error | Treating directed edges as undirected |
| Traversal error | Forgetting visited state |
| Assumption error | Using BFS on weighted edges |
A good test suite should expose all four categories.
Do not test only the happy path. Graphs have many boundary cases, and algorithms often depend on subtle structure.
Test the Builder First
Suppose input arrives as edge pairs.
edges = [
("A", "B"),
("B", "C"),
]
For a directed graph, the expected adjacency list is:
{
"A": ["B"],
"B": ["C"],
"C": [],
}
A builder test:
def test_directed_builder():
graph = build_directed_graph([
("A", "B"),
("B", "C"),
])
assert graph == {
"A": ["B"],
"B": ["C"],
"C": [],
}
The important part is the empty list for C.
If sink vertices are lost, later algorithms may fail or silently produce incomplete results.
Test Undirected Symmetry
For undirected graphs, every edge must appear in both directions.
def assert_undirected(graph):
for vertex, neighbors in graph.items():
for neighbor in neighbors:
assert vertex in graph[neighbor]
Example test:
def test_undirected_builder():
graph = build_undirected_graph([
("A", "B"),
("A", "C"),
])
assert_undirected(graph)
This catches a common mistake:
graph[u].append(v)
without:
graph[v].append(u)
Test Isolated Vertices
An isolated vertex has no edges but still belongs to the graph.
def test_isolated_vertex_preserved():
vertices = ["A", "B", "C"]
edges = [("A", "B")]
graph = build_graph(vertices, edges)
assert "C" in graph
assert graph["C"] == []
This matters for:
- Component counting
- Connectivity testing
- Topological sorting
- Graph coloring
- Euler checks
Omitting isolated vertices changes the problem.
Test Empty Graphs
Many algorithms should define behavior on empty graphs.
Examples:
assert connected_components({}) == []
assert is_connected({}) is True
assert topological_sort({}) == []
An empty graph is usually considered connected by convention only in some contexts, so document your choice.
The important point is consistency.
Test Single-Vertex Graphs
Single-vertex graphs expose off-by-one assumptions.
graph = {
"A": []
}
Useful checks:
assert connected_components(graph) == [["A"]]
assert has_cycle_directed(graph) is False
assert topological_sort(graph) == ["A"]
For Euler paths, a graph with one vertex and no edges often has a trivial Euler traversal.
Make the convention explicit.
Test Disconnected Graphs
Algorithms that launch from a single starting vertex often miss disconnected regions.
Example:
graph = {
"A": ["B"],
"B": ["A"],
"C": ["D"],
"D": ["C"],
}
Connected components should return two components.
components = connected_components(graph)
assert normalize_components(components) == [
["A", "B"],
["C", "D"],
]
A helper makes comparison stable:
def normalize_components(components):
return sorted(
sorted(component)
for component in components
)
Without normalization, tests may fail because traversal order differs.
Test Traversal Without Assuming Order
DFS and BFS output order depends on adjacency-list order.
Avoid brittle tests unless order is part of the contract.
Instead of:
assert dfs_order(graph, "A") == [
"A",
"B",
"C",
]
prefer:
assert set(dfs_order(graph, "A")) == {
"A",
"B",
"C",
}
For BFS distance, the distance map is a better assertion than visit order.
assert bfs_distance(graph, "A") == {
"A": 0,
"B": 1,
"C": 1,
"D": 2,
}
Distances are stable even when same-level ordering changes.
Test Directed Cycle Detection
Use both cyclic and acyclic examples.
def test_directed_cycle():
graph = {
"A": ["B"],
"B": ["C"],
"C": ["A"],
}
assert has_cycle_directed(graph) is True
Acyclic:
def test_directed_acyclic():
graph = {
"A": ["B"],
"B": ["C"],
"C": [],
}
assert has_cycle_directed(graph) is False
Also test self-loops:
def test_directed_self_loop():
graph = {
"A": ["A"],
}
assert has_cycle_directed(graph) is True
Self-loops are cycles.
Test Undirected Cycle Detection
Undirected graphs require parent tracking.
def test_undirected_no_cycle():
graph = {
"A": ["B"],
"B": ["A", "C"],
"C": ["B"],
}
assert has_cycle_undirected(graph) is False
Cycle:
def test_undirected_cycle():
graph = {
"A": ["B", "C"],
"B": ["A", "C"],
"C": ["A", "B"],
}
assert has_cycle_undirected(graph) is True
This pair catches implementations that mistake the parent edge for a cycle.
Test Topological Sort by Verifying Edges
Do not require one exact topological ordering unless the graph has only one valid order.
Given:
graph = {
"A": ["C"],
"B": ["C"],
"C": [],
}
Both are valid:
["A", "B", "C"]
["B", "A", "C"]
Test the property instead:
def assert_topological_order(graph, order):
position = {
vertex: i
for i, vertex in enumerate(order)
}
assert len(order) == len(graph)
for source, neighbors in graph.items():
for target in neighbors:
assert position[source] < position[target]
Then:
order = topological_sort(graph)
assert_topological_order(graph, order)
This test matches the algorithmic contract.
Test Shortest Paths with Known Distances
For shortest paths, assert distances and reconstructed paths.
graph = {
"A": [("B", 5), ("C", 2)],
"B": [("D", 3)],
"C": [("D", 1)],
"D": [],
}
Expected distances from A:
{
"A": 0,
"B": 5,
"C": 2,
"D": 3,
}
Test:
distance, parent = dijkstra(graph, "A")
assert distance["D"] == 3
assert reconstruct(parent, "D") == ["A", "C", "D"]
If multiple shortest paths exist, check cost rather than exact path.
Test MSTs by Cost and Size
MST algorithms may produce different valid trees when equal weights exist.
Do not require an exact edge set unless the MST is unique.
Instead check:
mst = kruskal(vertices, edges)
assert len(mst) == len(vertices) - 1
assert total_weight(mst) == expected_cost
assert is_tree(vertices, mst)
Helper:
def total_weight(edges):
return sum(weight for _, _, weight in edges)
For unique examples, exact edge tests are fine.
For non-unique examples, property checks are better.
Property-Based Testing Ideas
For random small graphs, compare optimized algorithms against slower simple versions.
Examples:
| Optimized Algorithm | Slow Checker |
|---|---|
| Articulation points | Remove each vertex and count components |
| Bridges | Remove each edge and count components |
| MST | Enumerate small spanning trees |
| Shortest paths | Floyd-Warshall on tiny graphs |
| Bipartite test | Try both-color assignments for tiny graphs |
This approach is powerful because optimized graph algorithms often have subtle invariants.
A slow checker gives a reliable reference.
Example: Testing Bridges Against a Slow Checker
def test_bridges_random_small(graph):
fast = {
tuple(sorted(edge))
for edge in bridges(graph)
}
slow = {
tuple(sorted(edge))
for edge in bridges_slow(graph)
}
assert fast == slow
Run this on many small random graphs.
The slow version may be too expensive for production, but it is excellent for tests.
Test Invalid Inputs
Graph code should either handle invalid inputs or reject them clearly.
Examples:
- Edge points to missing vertex
- Negative weight given to Dijkstra
- Duplicate edge when unsupported
- Self-loop when unsupported
- Non-numeric weight
- Directed graph passed to undirected-only algorithm
Example validation:
def validate_nonnegative_weights(graph):
for source, edges in graph.items():
for target, weight in edges:
if weight < 0:
raise ValueError(
"negative edge weight"
)
Test:
def test_dijkstra_rejects_negative_weight():
graph = {
"A": [("B", -1)],
"B": [],
}
try:
dijkstra(graph, "A")
assert False
except ValueError:
assert True
Test Large but Simple Graphs
Performance bugs often appear on simple large structures.
Use:
Long chain
0 -- 1 -- 2 -- 3 -- ... -- n
Tests recursion depth and linear behavior.
Star graph
1
|
2 -- 0 -- 3
|
4
Tests high-degree vertices.
Complete graph
Every vertex connects to every other vertex.
Tests dense behavior.
Empty-neighbor graph
Many isolated vertices.
Tests component logic.
These structures are easy to generate and expose many defects.
Common Pitfalls
Do not test traversal order unless order is the contract.
Do not ignore disconnected graphs.
Do not forget self-loops and isolated vertices.
Do not test only one graph shape.
Do not compare MST edge sets when multiple MSTs are valid.
Do not use weighted tests for unweighted assumptions without being explicit.
Do not let invalid inputs fail silently.
Takeaway
Testing graph code requires more than checking one example. Start by testing graph construction, especially directionality, isolated vertices, and sink vertices. Then test algorithms using known examples and property-based assertions. When outputs are not unique, validate the contract rather than an arbitrary order. For subtle algorithms, compare optimized implementations against slow reference checkers on small random graphs. This approach catches the representation, traversal, and assumption errors that cause most graph bugs.