Python Testing with pytest
Mentor's Note: Testing is not about finding bugs โ it's about proving your code works correctly, every time, forever. A test suite is your safety net for fearless refactoring. ๐งช
The Scenario: The Bridge Inspectorโ
Imagine you're building a bridge.
- Without Testing: You build the whole bridge, walk across it, and hope it holds. If it breaks, you don't know which beam failed.
- With Testing: You test each beam separately (unit tests), then sections (integration tests), then the whole bridge (end-to-end test). Every part is verified before the next is added.
- The Result: Safe bridges built faster, with confidence. โ
Why Test?โ
| Benefit | Why It Matters |
|---|---|
| Catch regressions | A change in one place breaks something elsewhere โ tests catch it immediately. |
| Documentation | Tests show exactly how your code is supposed to be used. |
| Refactor with confidence | You can rewrite large sections knowing tests will catch mistakes. |
| Better design | Code that's hard to test is usually poorly designed. Testing forces clean interfaces. |
| Automated verification | CI runs tests on every commit โ you never ship broken code. |
Getting Started with pytestโ
pip install pytest
Your First Testโ
Create a file named test_example.py:
# test_example.py
def test_addition():
result = 2 + 2
assert result == 4
def test_string_uppercase():
result = "hello".upper()
assert result == "HELLO"
def test_list_contains():
items = [1, 2, 3]
assert 2 in items
Run the tests:
pytest
Output:
==================== test session starts ====================
collected 3 items
test_example.py ... [100%]
===================== 3 passed in 0.02s =====================
With verbose output:
pytest -v
The assert Statementโ
pytest uses Python's built-in assert โ no special API needed. When an assertion fails, pytest gives you detailed output showing the actual vs expected values.
def test_compare():
assert 10 > 5 # Simple comparison
assert "python" in "python is fun" # Membership
assert [1, 2, 3] == [1, 2, 3] # Equality
assert not False # Negation
def test_failure_demo():
x = 10
y = 20
# pytest shows you exactly what x and y are
assert x == y # AssertionError: assert 10 == 20
Testing Functions with Different Inputsโ
# calculator.py
def add(a: float, b: float) -> float:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def is_even(n: int) -> bool:
return n % 2 == 0
# test_calculator.py
from calculator import add, divide, is_even
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, 1) == 0
def test_add_float():
assert add(0.1, 0.2) == pytest.approx(0.3)
def test_is_even_true():
assert is_even(4) is True
def test_is_even_false():
assert is_even(7) is False
Parametrize Testsโ
Test the same logic with multiple inputs using @pytest.mark.parametrize:
import pytest
def multiply(a: int, b: int) -> int:
return a * b
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 6),
(0, 5, 0),
(-2, 3, -6),
(10, 10, 100),
(1, 999, 999),
])
def test_multiply(a, b, expected):
assert multiply(a, b) == expected
This generates 5 separate test cases โ if one fails, you know exactly which input caused it.
Testing Exceptionsโ
Use pytest.raises to verify your code raises the right exceptions:
import pytest
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
# Verify the error message
assert str(exc_info.value) == "Cannot divide by zero"
def test_divide_normal():
assert divide(10, 2) == 5.0
Fixturesโ
Fixtures provide setup and teardown for tests โ reusable test data, database connections, or configuration.
import pytest
# A simple fixture
@pytest.fixture
def sample_data():
return {"name": "Alice", "age": 30, "scores": [85, 92, 78]}
@pytest.fixture
def empty_list():
return []
# Use fixtures as function parameters
def test_sample_data_name(sample_data):
assert sample_data["name"] == "Alice"
def test_sample_data_average(sample_data):
scores = sample_data["scores"]
average = sum(scores) / len(scores)
assert average == 85.0
def test_empty_list_append(empty_list):
empty_list.append(1)
assert len(empty_list) == 1
Fixture with teardown (yield)โ
import pytest
import tempfile
import os
@pytest.fixture
def temp_file():
# Setup: create a temp file
f = tempfile.NamedTemporaryFile(delete=False)
f.write(b"test data")
f.close()
yield f.name # Test runs here
# Teardown: clean up
os.unlink(f.name)
def test_temp_file_read(temp_file):
with open(temp_file) as f:
content = f.read()
assert content == "test data"
Organizing Testsโ
A recommended project structure:
project/
โโโ src/
โ โโโ calculator.py
โ โโโ utils.py
โโโ tests/
โ โโโ test_calculator.py
โ โโโ test_utils.py
โ โโโ conftest.py # Shared fixtures go here
โโโ requirements.txt
โโโ pyproject.toml
Run all tests from the project root:
pytest # Discover and run all tests
pytest -v # Verbose
pytest tests/ # Run specific directory
pytest tests/test_calculator.py # Run specific file
pytest -k "multiply" # Run tests matching name
pytest -x # Stop on first failure
pytest --maxfail=3 # Stop after 3 failures
Test Coverageโ
Coverage measures how much of your code is executed by tests. High coverage means low risk of hidden bugs.
pip install pytest-cov
# Run tests with coverage report
pytest --cov=src tests/
# Generate HTML report (open in browser)
pytest --cov=src --cov-report=html tests/
----------- coverage: platform linux, python 3.11 -----------
Name Stmts Miss Cover
------------------------------------------
src/calculator.py 12 0 100%
src/utils.py 20 3 85%
------------------------------------------
TOTAL 32 3 91%
Practical Example: Testing a Calculator Moduleโ
# src/calculator.py
def add(a: float, b: float) -> float:
return a + b
def subtract(a: float, b: float) -> float:
return a - b
def multiply(a: float, b: float) -> float:
return a * b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def power(base: float, exp: float) -> float:
return base ** exp
def factorial(n: int) -> int:
if n < 0:
raise ValueError("Factorial not defined for negative numbers")
if n == 0:
return 1
return n * factorial(n - 1)
# tests/test_calculator.py
import pytest
from calculator import add, subtract, multiply, divide, power, factorial
class TestCalculator:
def test_add(self):
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0.1, 0.2) == pytest.approx(0.3)
def test_subtract(self):
assert subtract(10, 3) == 7
assert subtract(0, 5) == -5
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 6), (0, 5, 0), (-2, 3, -6), (10, 0, 0)
])
def test_multiply(self, a, b, expected):
assert multiply(a, b) == expected
def test_divide(self):
assert divide(10, 2) == 5.0
assert divide(7, 2) == 3.5
def test_divide_by_zero(self):
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
@pytest.mark.parametrize("n, expected", [
(0, 1), (1, 1), (5, 120), (10, 3628800)
])
def test_factorial(self, n, expected):
assert factorial(n) == expected
def test_factorial_negative(self):
with pytest.raises(ValueError):
factorial(-1)
def test_power(self):
assert power(2, 3) == 8
assert power(5, 0) == 1
assert power(4, 0.5) == 2.0
Visual Logic: Testing Workflowโ
Sample Dry Runโ
Scenario: Running tests for calculator.py
| Step | Command | Output |
|---|---|---|
| 1 | pytest tests/test_calculator.py -v | Runs 12 test cases |
| 2 | All pass | 12 passed โ
|
| 3 | Change divide to a / (b + 1) (introduce bug) | 1 failed, 11 passed โ |
| 4 | pytest shows the failing line | assert divide(10, 2) == 5.0 got 3.333 |
| 5 | Fix the bug and rerun | 12 passed โ
|
| 6 | pytest --cov=src tests/ | Coverage: 98% |
Pro Tipsโ
- Name test files
test_*.pyand test functionstest_*โ pytest discovers them automatically. - One assertion per test is ideal โ it pinpoints exactly what failed.
- Use
conftest.pyfor shared fixtures โ they're automatically available to all tests in that directory. - Don't test implementation details โ test behavior and outputs.
- Run tests frequently โ ideally before every commit.
Interview Tipโ
"Interviewers ask: 'What's the difference between unit tests, integration tests, and end-to-end tests?' Unit tests verify single functions in isolation. Integration tests verify that components work together (e.g., function + database). End-to-end tests simulate real user workflows through the entire system."