Recommended ratio:
Why? Speed matters for the feedback loop:
If you change one line in find_intersection():
This is backwards! Same colors, inverted shape — every layer becomes a liability.
1. Slow feedback:
2. Hard to debug:
3. Brittle:
4. Vicious cycle:
Why do you think developers create test cones?
Common reasons:
Today's goal: Make unit testing so easy you'll prefer it!
Now that we know WHY unit tests matter, let's learn HOW to write them.
Key topics:
Definition:
A unit test verifies that a single unit (function, method, class) works correctly in isolation.
Key characteristics:
Example: Test find_intersection() without starting Dash app
def test_find_intersection_normal_angle():
"""Test that find_intersection() works with a normal downward angle."""
# Arrange: Set up test data
x_road = np.array([0, 10, 20, 30])
y_road = np.array([0, 2, 3, 4])
angle = -10.0
# Act: Call the function
x, y, dist = find_intersection(x_road, y_road, angle)
# Assert: Verify the result
assert x is not None, "Should find an intersection"
assert dist > 0, "Distance should be positive"
That's it! Simple, focused, fast.
Every good unit test follows this structure:
def test_example():
# ARRANGE: Set up the test data and conditions
input_data = prepare_test_data()
expected_result = calculate_expected()
# ACT: Call the function being tested
actual_result = function_under_test(input_data)
# ASSERT: Verify the result matches expectations
assert actual_result == expected_result
Why this pattern? Clear structure makes tests readable, easy to see what's being tested, easy to debug when tests fail.
def test_find_intersection_finds_intersection_for_normal_angle():
# ARRANGE: Create simple road going upward
x_road = np.array([0, 10, 20, 30], dtype=np.float64)
y_road = np.array([0, 2, 4, 6], dtype=np.float64)
angle = -10.0
camera_x = 0.0
camera_y = 10.0 # Camera above road
# ACT: Find intersection
x, y, dist = find_intersection(x_road, y_road, angle, camera_x, camera_y)
# ASSERT: Should find an intersection
assert x is not None
assert dist > 0
assert 0 <= x <= 30 # Within road bounds
Question: Can't we just write tests as regular Python functions?
# Without a testing framework:
def manual_test():
x, y, dist = find_intersection(x_road, y_road, -10.0)
if x is None:
print("
FAILED: x should not be None")
return False
if dist <= 0:
print("
FAILED: distance should be positive")
return False
print("
PASSED")
return True
# Run test manually
manual_test()
Manual testing has many issues:
Enter pytest: The industry standard testing framework
# Automatic test discovery - finds all test_*.py files
$ uv run pytest
# Run with detailed output
$ uv run pytest -v
# Run specific test file
$ uv run pytest tests/test_geometry.py
# Run tests matching a pattern
$ uv run pytest -k "intersection"
# Stop at first failure
$ uv run pytest -x
What makes pytest the industry standard:
assert (no special methods)test_*.py files automatically$ uv run pytest tests/test_geometry.py -v
tests/test_geometry.py::test_normal_angle PASSED [ 20%]
tests/test_geometry.py::test_empty_array FAILED [ 60%]
def test_empty_array():
> x, y, dist = find_intersection(x_road, y_road, -10.0)
E IndexError: index 0 is out of bounds
1 failed, 4 passed in 0.12s
Shows exactly which test failed, the error, and pass/fail count
Let's write a test together for find_intersection()
Goal: Test the normal downward angle case
Step 1: Create test file structure
$ mkdir tests
$ touch tests/__init__.py
$ touch tests/test_geometry.py
Step 2: Write the test
Step 3: Run it and see it pass!
# tests/test_geometry.py
import numpy as np
from road_profile_viewer.geometry import find_intersection
def test_find_intersection_finds_intersection_for_normal_angle():
# Arrange
x_road = np.array([0, 10, 20, 30], dtype=np.float64)
y_road = np.array([0, 2, 4, 6], dtype=np.float64)
# Act
x, y, dist = find_intersection(x_road, y_road, -10.0, 0.0, 10.0)
# Assert
assert x is not None
assert dist > 0
$ uv run pytest tests/test_geometry.py -v
============================= test session starts ==============================
collected 1 item
tests/test_geometry.py::test_find_intersection_finds_intersection_for_normal_angle PASSED [100%]
============================== 1 passed in 0.05s ===============================
Your first unit test passes!
Congratulations! You've written your first pytest unit test.
Interactive exercise: Let's write a test for angle = 90° (vertical angle)
What should happen?
None, None, None (can't find intersection for vertical ray)Your turn: What would the test look like?
Hint: Use the AAA pattern!
Question: Why can't we just test every possible input?
Let's do the math for find_intersection():
def find_intersection(x_road, y_road, angle_degrees,
camera_x=0, camera_y=1.5):
"""Find intersection between camera ray and road profile."""
# ...
Parameters:
angle_degrees: float (-180° to 180°)camera_x: floatcamera_y: floatx_road, y_road: arrays (variable length and values)Just angle_degrees (a float):
Total time to test just angle_degrees:
1,000,000 tests × 0.001 seconds = 1,000 seconds ≈ 17 minutes
Not too bad! But we have 3 float parameters...
Testing ALL combinations of 3 float parameters:
1,000,000³ = 1 quintillion tests
At 1ms per test = 32 BILLION YEARS
Age of the universe: ~14 billion years
Conclusion: Exhaustive testing is literally impossible
Since exhaustive testing is impossible, we need smart testing strategies:
Today: We'll learn the fundamentals (AAA pattern, pytest, clean code)
Next time: We'll learn smart testing strategies (equivalence classes, boundaries)
Three key principles from Robert C. Martin's "Clean Code":
test_[function]_[scenario]_[expected]()Why? When a test fails, you immediately know what's broken.
Bad: Testing multiple concepts
def test_find_intersection_everything():
x, y, dist = find_intersection(x_road, y_road, -10.0, 0.0, 10.0)
assert x is not None # Concept 1: downward angle
x2, y2, dist2 = find_intersection(x_road, y_road, 90.0)
assert x2 is None # Concept 2: vertical angle
x3, y3, dist3 = find_intersection(np.array([]), np.array([]), -10.0)
assert x3 is None # Concept 3: empty arrays
Problem: Which concept failed?
Good: Three separate tests
def test_find_intersection_
returns_intersection_
for_downward_angle():
x, y, dist = find_intersection(
x_road, y_road,
-10.0, 0.0, 10.0)
assert x is not None
def test_find_intersection_
returns_none_for_vertical_angle():
x, y, dist = find_intersection(
x_road, y_road, 90.0)
assert x is None
Third test:
def test_find_intersection_
returns_none_for_empty_arrays():
x, y, dist = find_intersection(
np.array([]),
np.array([]),
-10.0)
assert x is None
Benefits:
Ideally, each test should have ONE assert statement.
But there are reasonable exceptions!
When ONE assert is ideal:
def test_find_intersection_returns_positive_distance():
"""Test that distance is always positive when intersection found."""
x_road = np.array([0, 10, 20], dtype=np.float64)
y_road = np.array([0, 2, 4], dtype=np.float64)
x, y, dist = find_intersection(x_road, y_road, -10.0, 0.0, 10.0)
assert dist > 0 # One concept: distance is positive
Multiple asserts OK when testing SAME concept:
def test_find_intersection_returns_valid_coordinates():
x_road = np.array([0, 10, 20, 30], dtype=np.float64)
y_road = np.array([0, 2, 4, 6], dtype=np.float64)
x, y, dist = find_intersection(x_road, y_road, -10.0, 0.0, 10.0)
# All verify SAME concept: valid coordinate bounds
assert x is not None
assert y is not None
assert 0 <= x <= 30
assert 0 <= y <= 6
Acceptable: All asserts test coordinate validity (one concept)
Bad: Different concepts
def test_find_intersection_
coordinates_and_distance():
x, y, dist = find_intersection(
x_road, y_road,
-10.0, 0.0, 10.0)
assert x > 0 # Concept 1
assert dist > 0 # Concept 2
Problem: Testing two different concepts in one test
Good: Separate tests
def test_find_intersection_
returns_positive_x():
x, y, dist = find_intersection(
x_road, y_road,
-10.0, 0.0, 10.0)
assert x > 0
def test_find_intersection_
returns_positive_distance():
x, y, dist = find_intersection(
x_road, y_road,
-10.0, 0.0, 10.0)
assert dist > 0
Test names should describe:
Pattern: test_[function]_[scenario]_[expected_behavior]()
Bad Test Names:
def test_1(): # What does this test?
def test_intersection(): # Too vague
def test_angle(): # Which angle? What about it?
def test_edge_case(): # Which edge case?
Good Test Names:
def test_find_intersection_returns_none_for_empty_arrays():
"""Clear: function + scenario + expected result"""
def test_find_intersection_finds_intersection_for_downward_angle():
"""You know exactly what this tests"""
def test_find_intersection_returns_none_when_ray_misses_road():
"""Scenario and outcome are explicit"""
def test_find_intersection_handles_vertical_angle_gracefully():
"""Clear edge case handling"""
Test output when failure occurs:
FAILED test_1
# What does this tell you? Nothing!
FAILED test_find_intersection_returns_none_for_empty_arrays
# Immediately clear: empty array handling is broken
You immediately know what's broken without reading any code!
What would you name a test for this scenario?
Test that find_intersection() returns None when the camera is below the road and the ray points downward.
Think about it:
find_intersectionWhat's your test name?
| Principle | Guideline | Why It Matters |
|---|---|---|
| One Concept Per Test | Each test verifies ONE behavior | When test fails, you immediately know what's broken |
| One Assert Per Test | Ideally one; multiple OK if same concept | Failures are unambiguous |
| Descriptive Names | test_[function]_[scenario]_[expected]() |
Test output is self-documenting |
| AAA Pattern | Arrange-Act-Assert | Tests are readable and consistent |
These principles make your tests easy to read, debug, and maintain.
Key Takeaways:
Chapter 03 (Boundary Analysis) will cover:
Chapter 03 (TDD and CI) will cover:
But first: Practice what you learned today!