Testing Fundamentals
Unit vs integration tests, the Arrange-Act-Assert pattern, and why tests matter
Explanation
A test is code that calls your code and verifies the output is what you expected. Tests catch bugs before users do — and let you refactor with confidence.
Unit vs integration tests:
| Type | What it tests | Speed | Dependencies | |---|---|---|---| | Unit | One function or class in isolation | Fast (ms) | Mocked | | Integration | Multiple components together (e.g. route + DB) | Slower | Real |
Start with unit tests — they run in milliseconds and pinpoint exactly what broke.
The Arrange-Act-Assert (AAA) pattern:
python def test_add_two_numbers(): # Arrange — set up the inputs a, b = 3, 4 # Act — call the unit under test result = add(a, b) # Assert — verify the output assert result == 7 `` Keep each test focused on one behavior. If a test covers three things, it becomes hard to tell what broke.
pytest basics — writing and running tests:
python # test_math.py def add(a, b): return a + b def test_add_positive(): assert add(2, 3) == 5 def test_add_negative(): assert add(-1, 1) == 0 def test_add_zero(): assert add(0, 0) == 0 Run with: pytest test_math.py`
Rules pytest uses to discover tests: - Files named test_*.py or *_test.py - Functions starting with test_ - Classes starting with Test (no __init__)
Testing for exceptions:
python import pytest def divide(a, b): if b == 0: raise ValueError("Cannot divide by zero") return a / b def test_divide_by_zero(): with pytest.raises(ValueError, match="Cannot divide by zero"): divide(10, 0) pytest.raises` asserts that the block raises that specific exception — the test fails if it *doesn't* raise.
Assertion messages — helpful failures:
python def test_user_is_active(): user = get_user(1) assert user.active, f"Expected user {user.id} to be active, got {user.active}" `` The string after the comma is printed when the assertion fails — saves time debugging.
Examples
AAA pattern on a real function
Each test is named for the behavior it verifies; the "happy path" and error path are separate tests
# src/cart.py
def apply_discount(price: float, pct: float) -> float:
"""Apply a percentage discount. pct is 0–100."""
if pct < 0 or pct > 100:
raise ValueError(f"Invalid discount: {pct}")
return price * (1 - pct / 100)
# tests/test_cart.py
import pytest
from src.cart import apply_discount
def test_ten_percent_off():
# Arrange
price, pct = 100.0, 10.0
# Act
result = apply_discount(price, pct)
# Assert
assert result == 90.0
def test_zero_discount():
assert apply_discount(50.0, 0) == 50.0
def test_invalid_discount_raises():
with pytest.raises(ValueError):
apply_discount(100.0, 110)Unit test vs integration test boundary
Unit tests are fast and run constantly; integration tests are slower and typically run in CI
# Unit test — tests only the logic, no DB, no network
def test_format_username():
assert format_username("Alice Smith") == "alice_smith"
assert format_username(" bob ") == "bob"
# Integration test — tests the route + real DB together
# (usually kept in a separate test file/folder)
def test_create_user_endpoint(client, db_session):
response = client.post("/users", json={"name": "Alice"})
assert response.status_code == 201
assert db_session.query(User).count() == 1How well did you understand this?
Next in Testing
pytest: Fixtures & Parametrize