Python Testing with pytest: Fixtures, Parametrize, Mocking and Best Practices
pytest is the standard testing framework for Python. Its simple syntax, powerful fixtures, and rich plugin ecosystem make it far more pleasant to work with than the built-in unittest. This guide teaches you to write tests that are fast, readable, and actually catch real bugs.
Getting Started
bashpip install pytest pytest-cov pytest-asyncio pytest-mock
python-- test_math.py def add(a, b): return a + b def divide(a, b): if b == 0: raise ValueError("Cannot divide by zero") return a / b -- Tests def test_add_positive_numbers(): assert add(1, 2) == 3 def test_add_negative_numbers(): assert add(-1, -2) == -3 def test_divide_correctly(): assert divide(10, 2) == 5.0 def test_divide_by_zero_raises(): with pytest.raises(ValueError, match="Cannot divide by zero"): divide(10, 0)
Run tests:
bashpytest -- run all tests pytest test_math.py -- specific file pytest test_math.py::test_add -- specific test pytest -v -- verbose output pytest -k "divide" -- run tests matching pattern pytest --tb=short -- shorter traceback pytest -x -- stop on first failure pytest --cov=myapp --cov-report=html -- with coverage
Fixtures
Fixtures provide reusable setup/teardown for tests. pytest injects them by name:
pythonimport pytest from myapp.database import Database from myapp.models import User @pytest.fixture def db(): -- Setup database = Database(url="sqlite:///:memory:") database.create_tables() yield database -- provide to test -- Teardown (runs after test, even if test fails) database.drop_tables() database.close() @pytest.fixture def sample_user(db): -- Fixtures can depend on other fixtures user = User(username="alice", email="alice@example.com") db.save(user) return user def test_user_creation(db): user = User(username="bob", email="bob@example.com") db.save(user) assert db.get_user_by_email("bob@example.com") is not None def test_user_lookup(sample_user, db): found = db.get_user_by_email("alice@example.com") assert found.username == "alice"
Fixture Scopes
python@pytest.fixture(scope="function") -- default: new instance per test def temp_dir(tmp_path): return tmp_path / "test_data" @pytest.fixture(scope="class") -- shared within a test class def db_connection(): conn = create_connection() yield conn conn.close() @pytest.fixture(scope="module") -- shared within a test module (file) def expensive_setup(): data = load_large_dataset() yield data @pytest.fixture(scope="session") -- shared across entire test session def app_client(): app = create_app(testing=True) with app.test_client() as client: yield client
Use wider scopes for expensive operations. session-scoped fixtures like database connections speed up test suites dramatically.
conftest.py
Place shared fixtures in conftest.py β pytest automatically discovers them:
python-- tests/conftest.py import pytest from myapp import create_app from myapp.database import db as _db @pytest.fixture(scope="session") def app(): app = create_app("testing") return app @pytest.fixture(scope="session") def client(app): return app.test_client() @pytest.fixture(scope="function") def db(app): with app.app_context(): _db.create_all() yield _db _db.session.remove() _db.drop_all()
Parametrize
Run the same test with multiple inputs β eliminates duplicate test functions:
pythonimport pytest @pytest.mark.parametrize("a, b, expected", [ (1, 2, 3), (-1, -2, -3), (0, 0, 0), (100, -50, 50), ]) def test_add(a, b, expected): assert add(a, b) == expected -- More complex parametrize with IDs @pytest.mark.parametrize("email,valid", [ ("alice@example.com", True), ("bob.smith@co.uk", True), ("not-an-email", False), ("missing@", False), ("@nodomain.com", False), ], ids=["valid_simple", "valid_subdomain", "no_at", "no_domain", "no_local"]) def test_email_validation(email, valid): assert is_valid_email(email) == valid -- Nested parametrize (cartesian product) @pytest.mark.parametrize("role", ["admin", "editor", "viewer"]) @pytest.mark.parametrize("method", ["GET", "POST", "DELETE"]) def test_permissions(role, method): -- Tests all 9 combinations result = check_permission(role, method) assert isinstance(result, bool)
Mocking
unittest.mock (built-in)
pythonfrom unittest.mock import Mock, MagicMock, patch, AsyncMock def test_send_email_on_registration(mock_email_service): -- Create a mock email_service = Mock() email_service.send.return_value = {"status": "sent"} user_service = UserService(email_service=email_service) user_service.register("alice@example.com", "password123") -- Verify the mock was called correctly email_service.send.assert_called_once() call_args = email_service.send.call_args assert call_args.kwargs["to"] == "alice@example.com" assert "Welcome" in call_args.kwargs["subject"] -- patch as decorator: replaces the target for the duration of the test @patch("myapp.services.user_service.send_email") def test_registration_sends_email(mock_send_email): mock_send_email.return_value = None register_user("bob@example.com", "pass123") mock_send_email.assert_called_once_with( to="bob@example.com", subject="Welcome to our platform!" ) -- patch as context manager def test_api_call(): with patch("myapp.services.requests.get") as mock_get: mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = {"users": []} result = fetch_users() assert result == []
pytest-mock (cleaner syntax)
python-- pytest-mock provides the mocker fixture def test_send_notification(mocker): mock_send = mocker.patch("myapp.notifications.send_sms") mock_send.return_value = True send_order_confirmation(order_id=42, phone="+1234567890") mock_send.assert_called_once_with( phone="+1234567890", message=mocker.ANY -- don't care about exact message content ) -- Mock a class method def test_payment_processing(mocker): mock_charge = mocker.patch.object(StripeClient, "charge") mock_charge.return_value = {"id": "ch_123", "status": "succeeded"} result = process_payment(amount=9999, currency="usd") assert result["success"] is True mock_charge.assert_called_once_with(amount=9999, currency="usd")
Testing Async Code
pythonimport pytest import asyncio -- Mark test as async @pytest.mark.asyncio async def test_async_fetch(): result = await fetch_user_async(user_id=1) assert result["id"] == 1 -- Async fixtures @pytest.fixture async def async_client(): async with AsyncClient(app=app, base_url="http://test") as client: yield client @pytest.mark.asyncio async def test_create_user_api(async_client): response = await async_client.post("/users", json={ "username": "testuser", "email": "test@example.com", }) assert response.status_code == 201 -- Mock async functions @pytest.mark.asyncio async def test_async_email(mocker): mock_send = mocker.AsyncMock(return_value={"status": "sent"}) mocker.patch("myapp.email.send_async", mock_send) await register_user_async("alice@example.com") mock_send.assert_awaited_once()
Test Organization and Best Practices
codetests/ βββ conftest.py -- session/module fixtures βββ unit/ β βββ test_models.py β βββ test_services.py β βββ test_utils.py βββ integration/ β βββ test_api.py β βββ test_database.py βββ e2e/ βββ test_flows.py
Markers
python-- conftest.py def pytest_configure(config): config.addinivalue_line("markers", "slow: marks tests as slow") config.addinivalue_line("markers", "integration: marks integration tests") -- Use markers on tests @pytest.mark.slow def test_large_data_processing(): ... @pytest.mark.integration def test_database_write(): ... -- Run only specific markers -- pytest -m "not slow" -- pytest -m "integration"
AAA Pattern
pythondef test_order_total_with_discount(): -- Arrange cart = Cart() cart.add_item(Product(name="Widget", price=10.00), quantity=3) coupon = Coupon(code="SAVE20", discount_pct=20) -- Act total = cart.calculate_total(coupon=coupon) -- Assert assert total == 24.00 -- 30.00 - 20%
Common Interview Questions
Q: What is the difference between a mock and a stub?
A stub provides canned responses to calls made during the test β it replaces a dependency with a simplified version that returns predetermined data. A mock also records calls made to it and allows assertions about how it was called (which methods, with what arguments, how many times). In Python's unittest.mock, Mock objects are mocks (they also work as stubs). Most testing uses the terms interchangeably.
Q: What fixture scope would you use for a database connection and why?
session scope if the tests only read from the database (one connection shared across all tests β fastest). function scope if each test writes to the database β each test gets a fresh state, preventing test interdependence. A common pattern: session-scoped connection, function-scoped transaction that is rolled back after each test without committing, preserving the empty state.
Q: What is the difference between @patch and mocker.patch?
@patch is from unittest.mock and works as a decorator or context manager. mocker.patch is from pytest-mock and integrates with pytest's fixture system β it automatically undoes patches at the end of the test without needing a context manager or decorator. mocker.patch is generally cleaner in pytest tests.
Practice Python on Froquiz
Testing knowledge is increasingly expected in Python developer interviews. Test your Python skills on Froquiz β covering OOP, async, decorators, and core Python concepts.
Summary
- pytest discovers tests by naming convention: files
test_*.py, functionstest_* - Fixtures provide setup/teardown β inject by name, use
yieldto separate setup from teardown - Fixture scope:
function(default),class,module,sessionβ wider scope = less setup overhead conftest.pyholds shared fixtures available to all tests in the directory@pytest.mark.parametrizeruns one test function with multiple input setsunittest.mock.patchandpytest-mock'smocker.patchreplace real dependencies with controllable fakes- Use
@pytest.mark.asynciofor async tests;mocker.AsyncMockfor async function mocks - Follow AAA (Arrange, Act, Assert) β one concept per test, descriptive test names