Write Effective Tests
Problem
Python’s dynamic nature makes testing critical for catching bugs that static typing would prevent in other languages. unittest provides basic testing but requires verbose boilerplate. pytest offers powerful features but requires understanding fixtures, parametrization, and assertion introspection.
This guide shows effective testing patterns in Python.
pytest Basics
Simple Test Functions
def add(a, b):
return a + b
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -2) == -3
def test_add_zero():
assert add(5, 0) == 5
def test_add_floats():
result = add(0.1, 0.2)
assert result == pytest.approx(0.3), f"Expected 0.3, got {result}"Running tests:
pytest
pytest test_calculator.py
pytest test_calculator.py::test_add_positive_numbers
pytest -v
pytest -sTest Classes
class TestUser:
def test_create_user(self):
user = User("alice@example.com")
assert user.email == "alice@example.com"
def test_user_validation(self):
with pytest.raises(ValueError):
User("invalid-email")
def test_user_age_validation(self):
user = User("bob@example.com")
user.age = -1
with pytest.raises(ValueError):
user.validate()Fixtures
Basic Fixtures
import pytest
@pytest.fixture
def user():
return User("alice@example.com", age=25)
def test_user_email(user):
assert user.email == "alice@example.com"
def test_user_age(user):
assert user.age == 25
@pytest.fixture
def database():
db = Database()
db.connect()
yield db # Test runs here
db.disconnect() # Cleanup
def test_query_users(database):
users = database.query("SELECT * FROM users")
assert len(users) > 0Fixture Scope
@pytest.fixture
def user():
return User("test@example.com")
@pytest.fixture(scope="class")
def database():
db = Database()
db.connect()
yield db
db.disconnect()
@pytest.fixture(scope="module")
def app_config():
return load_config("test_config.yaml")
@pytest.fixture(scope="session")
def browser():
driver = webdriver.Chrome()
yield driver
driver.quit()conftest.py for Shared Fixtures
import pytest
@pytest.fixture
def db_connection():
"""Database connection used by all tests"""
conn = create_connection()
yield conn
conn.close()
@pytest.fixture
def sample_user():
"""Sample user for testing"""
return User("test@example.com", age=25)
def test_save_user(db_connection, sample_user):
# Fixtures automatically available
db_connection.save(sample_user)
assert db_connection.find_by_email("test@example.com") is not NoneParametrized Tests
Basic Parametrization
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(10, -5, 5),
])
def test_add(a, b, expected):
assert add(a, b) == expected
@pytest.mark.parametrize("email,valid", [
("alice@example.com", True),
("invalid.email", False),
("", False),
("test@", False),
("@example.com", False),
])
def test_email_validation(email, valid):
assert is_valid_email(email) == valid
@pytest.mark.parametrize("age", [16, 17, -1, 0])
@pytest.mark.parametrize("email", ["valid@example.com", ""])
def test_user_validation(age, email):
# Runs 8 tests (4 ages × 2 emails)
if age < 18 or not email:
with pytest.raises(ValidationError):
User(email, age)Parametrize with IDs
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("World", "WORLD"),
("123", "123"),
], ids=["lowercase", "mixed", "numbers"])
def test_uppercase(input, expected):
assert input.upper() == expectedMocking
unittest.mock Basics
from unittest.mock import Mock, patch, MagicMock
def test_api_call():
mock_api = Mock()
mock_api.get_user.return_value = {"name": "Alice", "age": 25}
service = UserService(mock_api)
user = service.fetch_user("123")
assert user.name == "Alice"
mock_api.get_user.assert_called_once_with("123")
def test_retry_logic():
mock_api = Mock()
# First call fails, second succeeds
mock_api.fetch_data.side_effect = [ConnectionError(), {"data": "success"}]
service = Service(mock_api)
result = service.fetch_with_retry()
assert result == {"data": "success"}
assert mock_api.fetch_data.call_count == 2
@patch('myapp.send_email')
def test_user_registration(mock_send_email):
register_user("alice@example.com", "password")
mock_send_email.assert_called_once()
args, kwargs = mock_send_email.call_args
assert "alice@example.com" in args
def test_external_api():
with patch('requests.get') as mock_get:
mock_response = Mock()
mock_response.json.return_value = {"data": "test"}
mock_get.return_value = mock_response
result = fetch_data_from_api()
assert result == {"data": "test"}
mock_get.assert_called_once()Mock Classes and Methods
@patch('myapp.database.Database')
def test_save_user(MockDatabase):
mock_db_instance = MockDatabase.return_value
mock_db_instance.save.return_value = True
service = UserService()
result = service.create_user("alice@example.com")
assert result is True
mock_db_instance.save.assert_called_once()
def test_user_method():
user = User("test@example.com")
with patch.object(user, 'send_email') as mock_send:
user.register()
mock_send.assert_called_once()Testing Exceptions
Asserting Exceptions
import pytest
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_invalid_email():
with pytest.raises(ValueError, match="Invalid email"):
User("invalid-email")
def test_validation_error():
with pytest.raises(ValidationError) as exc_info:
validate_age(-5)
assert "age" in str(exc_info.value)
assert exc_info.value.field == "age"
def test_valid_input():
# No assertion needed - test passes if no exception
process_data({"valid": "data"})Test Organization
File Structure
tests/
├── conftest.py # Shared fixtures
├── test_models.py # Model tests
├── test_services.py # Service tests
├── test_api.py # API tests
└── integration/
├── conftest.py # Integration fixtures
└── test_database.py # Integration testsMarking Tests
import pytest
@pytest.mark.slow
def test_large_dataset():
# Long-running test
pass
@pytest.mark.integration
def test_database_connection():
pass
@pytest.mark.skipif(sys.platform == "win32", reason="Unix only")
def test_unix_specific():
pass
@pytest.mark.xfail(reason="Known bug #123")
def test_known_issue():
passRun specific marks:
pytest -m "not slow"
pytest -m integration
pytest -m "slow or integration"Coverage
Running with Coverage
pip install pytest-cov
pytest --cov=myapp
pytest --cov=myapp --cov-report=html
pytest --cov=myapp --cov-report=term-missing
pytest --cov=myapp --cov-fail-under=80Coverage Configuration
[coverage:run]
omit =
*/tests/*
*/migrations/*
*/venv/*
[coverage:report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:Summary
Effective Python testing centers on pytest’s powerful features over unittest’s verbose class-based approach. Test functions start with test_, use plain assert statements, and pytest’s introspection provides helpful failure messages without boilerplate.
Fixtures provide test setup and teardown with automatic dependency injection. Define fixtures with @pytest.fixture, use yield for cleanup, and control scope (function, class, module, session) based on sharing needs. conftest.py makes fixtures available across test files.
Parametrized tests eliminate duplication when testing multiple inputs. @pytest.mark.parametrize supplies test data as tuples, pytest generates separate tests for each input set. Use ids parameter for readable test names.
Mocking with unittest.mock isolates code under test from dependencies. Mock() creates mock objects, patch() replaces functions or classes, side_effect simulates multiple calls or exceptions. Verify interactions with assert_called_once(), assert_called_with(), and call_count.
Exception testing uses pytest.raises context manager. Assert exceptions are raised, check exception messages with match parameter, inspect exception attributes through exc_info. Missing exception causes test failure.
Test organization follows project structure with tests/ directory mirroring source layout. Mark tests with @pytest.mark for selective execution - slow, integration, skip conditions. Run subsets with -m flag.
Coverage measurement with pytest-cov shows which code paths tests execute. Generate reports identifying untested lines, fail builds below coverage thresholds. Configure omit patterns to exclude test code and generated files.
Pytest’s simple syntax, powerful fixtures, parametrization, and assertion introspection make tests easier to write and maintain than unittest. Combined with coverage measurement and marking, pytest enables comprehensive, organized test suites.