Writing Effective Unit Tests in Python with pytest
Good tests are as important as good code. pytest makes writing and running tests enjoyable. Here's how to write tests that actually catch bugs.
Getting Started
Installation and Setup
bashpip install pytest pytest-cov pytest-mock # pytest.ini [pytest] testpaths = tests python_files = test_*.py python_functions = test_* addopts = -v --tb=short
Project Structure
textproject/ ├── src/ │ └── myapp/ │ ├── __init__.py │ ├── users.py │ └── database.py ├── tests/ │ ├── __init__.py │ ├── conftest.py # Shared fixtures │ ├── test_users.py │ └── integration/ │ └── test_database.py └── pytest.ini
Basic Test Structure
Simple Tests
python# tests/test_users.py from myapp.users import User, validate_email def test_user_creation(): user = User(name="John", email="john@example.com") assert user.name == "John" assert user.email == "john@example.com" def test_validate_email_valid(): assert validate_email("test@example.com") is True def test_validate_email_invalid(): assert validate_email("not-an-email") is False assert validate_email("") is False
Testing Exceptions
pythonimport pytest from myapp.users import User, ValidationError def test_user_invalid_email_raises(): with pytest.raises(ValidationError) as exc_info: User(name="John", email="invalid") assert "Invalid email" in str(exc_info.value) def test_user_empty_name_raises(): with pytest.raises(ValidationError, match="Name cannot be empty"): User(name="", email="john@example.com")
Fixtures
Fixtures provide reusable test data and setup.
Basic Fixtures
python# tests/conftest.py import pytest from myapp.users import User from myapp.database import Database @pytest.fixture def sample_user(): return User(name="Test User", email="test@example.com") @pytest.fixture def admin_user(): return User(name="Admin", email="admin@example.com", role="admin") @pytest.fixture def database(): db = Database(":memory:") db.create_tables() yield db # Cleanup after test db.close()
Using Fixtures
python# tests/test_users.py def test_user_full_name(sample_user): assert sample_user.full_name == "Test User" def test_admin_permissions(admin_user): assert admin_user.can_delete_users() is True def test_user_save(database, sample_user): database.save(sample_user) loaded = database.get_user(sample_user.id) assert loaded.email == sample_user.email
Fixture Scopes
python@pytest.fixture(scope="function") # Default: new for each test def user(): return User() @pytest.fixture(scope="class") # Shared within a test class def database(): return Database() @pytest.fixture(scope="module") # Shared within a module def api_client(): return APIClient() @pytest.fixture(scope="session") # Shared across all tests def config(): return load_config()
Factory Fixtures
python@pytest.fixture def create_user(): created_users = [] def _create_user(name="Test", email=None, **kwargs): email = email or f"{name.lower()}@example.com" user = User(name=name, email=email, **kwargs) created_users.append(user) return user yield _create_user # Cleanup for user in created_users: user.delete() def test_multiple_users(create_user): user1 = create_user("Alice") user2 = create_user("Bob", role="admin") assert user1.email == "alice@example.com" assert user2.role == "admin"
Parametrization
Test multiple inputs with single test function.
Basic Parametrization
pythonimport pytest @pytest.mark.parametrize("email,expected", [ ("test@example.com", True), ("user@domain.org", True), ("invalid", False), ("@nodomain.com", False), ("spaces in@email.com", False), ("", False), ]) def test_validate_email(email, expected): assert validate_email(email) == expected
Multiple Parameters
python@pytest.mark.parametrize("a,b,expected", [ (1, 2, 3), (0, 0, 0), (-1, 1, 0), (100, 200, 300), ]) def test_add(a, b, expected): assert add(a, b) == expected
Parametrize with IDs
python@pytest.mark.parametrize("input,expected", [ pytest.param("hello", "HELLO", id="lowercase"), pytest.param("WORLD", "WORLD", id="already-upper"), pytest.param("MiXeD", "MIXED", id="mixed-case"), ]) def test_uppercase(input, expected): assert input.upper() == expected
Mocking
Using pytest-mock
pythondef test_user_sends_welcome_email(mocker, sample_user): # Mock the email service mock_send = mocker.patch("myapp.users.send_email") sample_user.register() mock_send.assert_called_once_with( to=sample_user.email, subject="Welcome!", body=mocker.ANY # Don't care about exact body ) def test_user_fetches_from_api(mocker): mock_response = {"id": 1, "name": "John"} mocker.patch("myapp.users.requests.get").return_value.json.return_value = mock_response user = User.fetch_from_api(1) assert user.name == "John"
Mock Return Values and Side Effects
pythondef test_retry_on_failure(mocker): mock_api = mocker.patch("myapp.client.api_call") mock_api.side_effect = [ ConnectionError("Failed"), ConnectionError("Failed again"), {"success": True} # Third call succeeds ] result = client.fetch_with_retry(max_retries=3) assert result == {"success": True} assert mock_api.call_count == 3 def test_mock_context_manager(mocker): mock_file = mocker.mock_open(read_data="file content") mocker.patch("builtins.open", mock_file) result = read_config_file("config.txt") assert result == "file content"
Test Organization
Using Classes
pythonclass TestUserValidation: def test_valid_email(self): assert validate_email("test@example.com") def test_invalid_email(self): assert not validate_email("invalid") class TestUserPermissions: def test_admin_can_delete(self, admin_user): assert admin_user.can_delete_users() def test_regular_user_cannot_delete(self, sample_user): assert not sample_user.can_delete_users()
Markers for Test Categories
pythonimport pytest @pytest.mark.slow def test_large_data_processing(): # Takes a long time pass @pytest.mark.integration def test_database_connection(): # Requires database pass @pytest.mark.skip(reason="Not implemented yet") def test_future_feature(): pass @pytest.mark.skipif(sys.platform == "win32", reason="Unix only") def test_unix_permissions(): pass
bash# Run only fast tests pytest -m "not slow" # Run only integration tests pytest -m integration
Testing Async Code
pythonimport pytest @pytest.mark.asyncio async def test_async_fetch(): result = await fetch_data("https://api.example.com") assert result["status"] == "ok" @pytest.fixture async def async_client(): client = AsyncClient() await client.connect() yield client await client.disconnect() @pytest.mark.asyncio async def test_with_async_fixture(async_client): result = await async_client.get("/users") assert len(result) > 0
Coverage
bash# Run with coverage pytest --cov=myapp --cov-report=html # Fail if coverage below threshold pytest --cov=myapp --cov-fail-under=80
ini# .coveragerc [run] source = src/myapp omit = */tests/* */__init__.py [report] exclude_lines = pragma: no cover raise NotImplementedError if TYPE_CHECKING:
Best Practices
- Test behavior, not implementation: Focus on what the code does, not how
- One assertion per test (when practical): Makes failures clear
- Use descriptive names:
test_user_with_expired_token_cannot_login - Keep tests fast: Mock external dependencies
- Test edge cases: Empty inputs, null values, boundaries
- Don't test private methods: They're implementation details
- Use fixtures for setup: Keep tests focused on assertions
Conclusion
pytest's flexibility makes it easy to write maintainable tests. Use fixtures for setup, parametrization for multiple cases, and mocking for isolation. Good tests give you confidence to refactor and catch bugs early.