17.23 Testing Geometry Algorithms
Geometry code fails in ways that ordinary examples rarely expose.
17.23 Testing Geometry Algorithms
Problem
Geometry code fails in ways that ordinary examples rarely expose.
A convex hull may work for a random cloud of points but fail when all points are collinear. A segment intersection routine may pass crossing cases but fail on shared endpoints. A point-in-polygon test may classify ordinary points correctly but break when the query point lies exactly on a vertex.
Testing geometry algorithms requires deliberate coverage of:
degenerate cases
boundary cases
large coordinates
small coordinates
near-degenerate floating-point cases
randomized inputs
algorithmic invariants
The goal is to test both the result and the assumptions behind the result.
Solution
Build a layered test strategy.
Use:
small hand-written examples
boundary-case tests
brute-force reference implementations
property-based tests
metamorphic tests
random stress tests
Do not rely only on visual inspection. Geometry can look correct when the topology is wrong.
Start with Deterministic Examples
For a segment intersection function, write explicit tests for each case in the specification.
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
Example tests:
def test_segments_cross():
a = Point(0, 0)
b = Point(4, 4)
c = Point(0, 4)
d = Point(4, 0)
assert segments_intersect(a, b, c, d)
def test_segments_share_endpoint():
a = Point(0, 0)
b = Point(4, 0)
c = Point(4, 0)
d = Point(8, 0)
assert segments_intersect(a, b, c, d)
def test_segments_parallel_disjoint():
a = Point(0, 0)
b = Point(4, 0)
c = Point(0, 1)
d = Point(4, 1)
assert not segments_intersect(a, b, c, d)
def test_segments_collinear_overlap():
a = Point(0, 0)
b = Point(6, 0)
c = Point(2, 0)
d = Point(8, 0)
assert segments_intersect(a, b, c, d)
These tests correspond directly to proof cases.
Test Empty and Small Inputs
Many geometry algorithms have special behavior for small inputs.
Convex hull examples:
def test_hull_empty():
assert convex_hull([]) == []
def test_hull_one_point():
p = Point(1, 2)
assert convex_hull([p]) == [p]
def test_hull_two_points():
a = Point(0, 0)
b = Point(1, 1)
assert convex_hull([b, a]) == [a, b]
Closest pair:
def test_closest_pair_empty():
assert closest_pair([]) is None
def test_closest_pair_one_point():
assert closest_pair([Point(0, 0)]) is None
These cases often reveal assumptions hidden in loops and indexing logic.
Test Degenerate Inputs
Degenerate inputs are geometrically valid but structurally special.
Examples:
duplicate points
all points collinear
zero-length segments
zero-area rectangles
polygon vertices repeated
horizontal and vertical edges
Convex hull tests:
def test_hull_all_collinear():
points = [
Point(0, 0),
Point(1, 1),
Point(2, 2),
Point(3, 3),
]
assert convex_hull(points) == [
Point(0, 0),
Point(3, 3),
]
def test_hull_duplicate_points():
points = [
Point(0, 0),
Point(0, 0),
Point(1, 0),
Point(0, 1),
]
assert set(convex_hull(points)) == {
Point(0, 0),
Point(1, 0),
Point(0, 1),
}
For point-in-polygon, test boundary classification explicitly.
def test_point_on_polygon_edge():
polygon = [
Point(0, 0),
Point(4, 0),
Point(4, 4),
Point(0, 4),
]
assert classify_point_in_polygon(
Point(2, 0),
polygon
) == "boundary"
Use Brute Force as an Oracle
Optimized geometry algorithms are often hard to inspect directly.
A brute-force algorithm can act as a reference for small inputs.
For closest pair:
def closest_pair_brute(points):
if len(points) < 2:
return None
best = None
best_distance = None
for i in range(len(points)):
for j in range(i + 1, len(points)):
d = distance_squared(points[i], points[j])
if best_distance is None or d < best_distance:
best_distance = d
best = (points[i], points[j])
return best, best_distance
Then compare the optimized algorithm against it:
def test_closest_pair_against_brute(points):
expected = closest_pair_brute(points)
actual = closest_pair(points)
assert actual[1] == expected[1]
This approach is especially effective with randomized small inputs.
Randomized Stress Testing
Generate many small inputs and compare optimized algorithms to brute-force versions.
import random
def random_points(count, low=-10, high=10):
return [
Point(
random.randint(low, high),
random.randint(low, high)
)
for _ in range(count)
]
Stress test:
def test_closest_pair_random():
for _ in range(1000):
points = random_points(20)
expected = closest_pair_brute(points)
actual = closest_pair(points)
if expected is None:
assert actual is None
else:
assert actual[1] == expected[1]
Random testing is valuable because geometry has many configurations that are easy to miss by hand.
Property-Based Tests
Some properties should always hold, even when you do not know the exact answer.
For convex hull:
every input point lies inside or on the hull
every hull vertex comes from the input
the hull is convex
Example checks:
def is_convex_polygon(points):
if len(points) <= 2:
return True
signs = []
for i in range(len(points)):
a = points[i]
b = points[(i + 1) % len(points)]
c = points[(i + 2) % len(points)]
value = orientation(a, b, c)
if value != 0:
signs.append(value > 0)
return all(sign == signs[0] for sign in signs)
Then:
def test_hull_is_convex():
for _ in range(1000):
points = random_points(30)
hull = convex_hull(points)
assert is_convex_polygon(hull)
This does not fully prove correctness, but it catches many errors.
Metamorphic Testing
A metamorphic test transforms the input in a way that should preserve the answer structure.
Examples:
translate all points
scale all coordinates
shuffle input order
duplicate input points
rotate a point set
For convex hull, shuffling input order should not change the hull set.
def test_hull_input_order_does_not_matter():
points = random_points(50)
shuffled = points[:]
random.shuffle(shuffled)
assert set(convex_hull(points)) == set(convex_hull(shuffled))
Translation should preserve shape:
def translate(points, dx, dy):
return [
Point(p.x + dx, p.y + dy)
for p in points
]
Test:
def test_hull_translation():
points = random_points(50)
moved = translate(points, 100, -200)
hull = convex_hull(points)
moved_hull = convex_hull(moved)
assert set(translate(hull, 100, -200)) == set(moved_hull)
Metamorphic tests are useful when computing an exact expected answer is inconvenient.
Test Numeric Extremes
Integer geometry should be tested near overflow boundaries.
def test_large_coordinates_orientation():
a = Point(10**9, 10**9)
b = Point(2 * 10**9, 10**9)
c = Point(10**9, 2 * 10**9)
assert orientation(a, b, c) > 0
Floating-point geometry should include near-degenerate cases:
def test_nearly_collinear_points():
a = Point(0.0, 0.0)
b = Point(1e9, 1e9)
c = Point(2e9, 2e9 + 1e-6)
result = orientation_sign(a, b, c)
assert result in (-1, 0, 1)
The expected classification depends on your tolerance policy. The key is to document and test that policy.
Test Event Ordering
Sweep-line algorithms are sensitive to same-coordinate events.
For rectangle union:
def test_touching_rectangles_do_not_double_count():
rectangles = [
Rectangle(0, 0, 2, 2),
Rectangle(2, 0, 4, 2),
]
assert rectangle_union_area(rectangles) == 8
For overlapping rectangles:
def test_overlapping_rectangles_count_overlap_once():
rectangles = [
Rectangle(0, 0, 4, 4),
Rectangle(2, 2, 6, 6),
]
assert rectangle_union_area(rectangles) == 28
These cases test boundary semantics directly.
Test Against Known Identities
Some geometric formulas have identities that can be checked.
For polygon area:
area should be unchanged by translation
area should change by factor s² under scaling by s
reversing vertex order should negate signed area
Example:
def test_signed_area_reversal():
polygon = [
Point(0, 0),
Point(4, 0),
Point(4, 3),
Point(0, 3),
]
assert signed_area(polygon) == -signed_area(list(reversed(polygon)))
This style catches mistakes in orientation and vertex ordering.
Common Pitfalls
Testing Only Random Inputs
Random inputs rarely include exact degeneracies.
Testing Only Hand-Written Inputs
Hand-written tests rarely cover enough configurations.
Ignoring Boundary Semantics
Tests should explicitly cover edge, vertex, and touching cases.
Comparing Floating Values Too Strictly
Use tolerance-aware comparisons for measurements.
Using the Same Bug in the Oracle
A reference implementation should be structurally different from the optimized implementation.
Practical Test Suite Structure
A useful geometry test suite usually contains:
test_predicates.py
test_segments.py
test_polygons.py
test_hulls.py
test_sweep.py
test_spatial_index.py
test_numeric_robustness.py
Predicates should be tested first because many algorithms depend on them.
When a higher-level algorithm fails, a passing predicate suite narrows the search.
Discussion
Testing geometry algorithms means testing mathematics, implementation, and specification at the same time. Small deterministic cases confirm the intended boundary behavior. Brute-force oracles validate optimized algorithms. Random and property-based tests broaden coverage. Metamorphic tests verify invariants that should survive transformations.
The strongest test suites mirror the correctness proof. If the proof splits a problem into cases, the test suite should contain those cases. If the proof relies on an invariant, the test suite should check that invariant directly where possible.