Testing Python Code
Write reliable tests with pytest and unittest, use fixtures and parametrize, mock dependencies, and measure coverage.
pytest Basics
pytest discovers tests automatically — any file matching test_*.py or *_test.py, and any function starting with test_.
pip install pytest
pytest # run all tests
pytest tests/ # run tests in directory
pytest tests/test_auth.py # run specific file
pytest -v # verbose output
pytest -k "test_login" # run tests matching name
pytest -x # stop after first failure
pytest --tb=short # shorter tracebacks
Writing Tests
# tests/test_math.py
def add(a, b):
return a + b
def test_add_integers():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, 1) == 0
def test_add_floats():
result = add(0.1, 0.2)
assert abs(result - 0.3) < 1e-9 # float comparison
# Or:
import pytest
assert result == pytest.approx(0.3)
Testing Exceptions
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)
def test_divide_normal():
assert divide(10, 2) == 5.0
Fixtures
Fixtures provide reusable setup and teardown. They’re injected by name into test functions.
import pytest
from myapp.database import Database, User
@pytest.fixture
def db():
"""Provide a fresh in-memory database for each test."""
database = Database(":memory:")
database.migrate()
yield database
database.close() # runs after the test
@pytest.fixture
def sample_user(db):
"""Create a user for tests that need one."""
user = db.create_user(name="Alice", email="alice@example.com")
return user
def test_user_creation(db):
user = db.create_user(name="Bob", email="bob@example.com")
assert user.id is not None
assert user.name == "Bob"
def test_user_lookup(db, sample_user):
found = db.find_user(sample_user.id)
assert found.email == "alice@example.com"
Fixture Scopes
@pytest.fixture(scope="session") # once per test session
def client():
return TestClient(app)
@pytest.fixture(scope="module") # once per test module
def db_connection():
conn = connect()
yield conn
conn.close()
@pytest.fixture(scope="function") # default — once per test
def fresh_cache():
return {}
conftest.py
Fixtures in conftest.py are automatically available to all tests in the same directory and below — no import needed.
# tests/conftest.py
import pytest
from myapp import create_app
@pytest.fixture(scope="session")
def app():
app = create_app(testing=True)
return app
@pytest.fixture
def client(app):
return app.test_client()
Parametrize
Run the same test with multiple inputs:
import pytest
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("world", "WORLD"),
("", ""),
("Python 3", "PYTHON 3"),
])
def test_uppercase(input, expected):
assert input.upper() == expected
# Multiple parameters
@pytest.mark.parametrize("a,b,result", [
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
(100, -50, 50),
])
def test_add(a, b, result):
assert a + b == result
Mocking with unittest.mock
from unittest.mock import Mock, patch, MagicMock
import pytest
# Mock an object
def send_notification(user, message, emailer):
emailer.send(user.email, message)
def test_send_notification():
user = Mock()
user.email = "alice@example.com"
emailer = Mock()
send_notification(user, "Hello!", emailer)
emailer.send.assert_called_once_with("alice@example.com", "Hello!")
patch() as a Decorator
# myapp/orders.py
import requests
def get_order(order_id):
response = requests.get(f"https://api.example.com/orders/{order_id}")
return response.json()
# tests/test_orders.py
from unittest.mock import patch
@patch("myapp.orders.requests.get")
def test_get_order(mock_get):
mock_get.return_value.json.return_value = {"id": 1, "item": "book"}
result = get_order(1)
assert result == {"id": 1, "item": "book"}
mock_get.assert_called_once_with("https://api.example.com/orders/1")
patch() as a Context Manager
from unittest.mock import patch
def test_file_processing():
with patch("builtins.open", mock_open(read_data="line1\nline2")):
result = process_file("any_path.txt")
assert result == ["line1", "line2"]
Coverage
pip install pytest-cov
pytest --cov=myapp --cov-report=term-missing
pytest --cov=myapp --cov-report=html # generates htmlcov/index.html
.coveragerc to configure:
[run]
source = myapp
omit =
*/migrations/*
*/tests/*
*/conftest.py
[report]
fail_under = 80
unittest (for Completeness)
import unittest
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calc = Calculator()
def tearDown(self):
pass
def test_add(self):
self.assertEqual(self.calc.add(2, 3), 5)
def test_divide_by_zero(self):
with self.assertRaises(ValueError):
self.calc.divide(10, 0)
def test_approximate(self):
self.assertAlmostEqual(self.calc.sqrt(2), 1.41421, places=4)
if __name__ == "__main__":
unittest.main()
Testing Async Code
import pytest
import asyncio
@pytest.mark.asyncio
async def test_async_function():
result = await fetch_data("https://example.com")
assert result["status"] == "ok"
# Install: pip install pytest-asyncio
# Configure in pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto"
Test Organization
myapp/
├── src/
│ └── myapp/
│ ├── auth.py
│ └── orders.py
└── tests/
├── conftest.py
├── unit/
│ ├── test_auth.py
│ └── test_orders.py
└── integration/
└── test_api.py Frequently Asked Questions
Should I use pytest or unittest?
pytest is the industry standard for new projects. It's more expressive, has better output, and works seamlessly with unittest-style tests. Use unittest only when you can't add dependencies.
What's the difference between a mock and a stub?
A stub returns canned data. A mock also tracks calls — you can assert it was called with specific arguments. Python's unittest.mock.Mock does both.
How much test coverage should I aim for?
100% coverage doesn't mean bug-free. Aim for 80-90% coverage on critical paths. Coverage is a floor, not a ceiling — focus on testing behavior, not lines.