πŸŽ‰ 75% of content is free forever β€” Unlock Premium from $10/mo β†’
CW
Search courses…
πŸ’Ό Servicesℹ️ Aboutβœ‰οΈ ContactView Pricing Plansfrom $10

Testing: pytest, fixtures, mock, patch, parametrize, coverage

PythonTesting & Quality⭐ Premium

Advertisement

Google, Meta & Amazon Interview

Testing: pytest, fixtures, mock, patch, parametrize, coverage

Advanced testing patterns for production code

Interview Question

"How do you write effective unit tests in Python? Explain pytest fixtures, mocking, patching, and parametrize. How do you achieve high test coverage while maintaining test quality?"

Difficulty: Medium | Frequently asked at Google, Meta, Amazon


Theoretical Foundation

Why Test?

# Example: Untested code with bugs
def calculate_discount(price: float, discount_percent: float) -> float:
    """Calculate discounted price."""
    # Bug 1: No validation
    # Bug 2: Wrong calculation
    return price - discount_percent  # Should be price * (1 - discount_percent/100)

# Without tests, bugs go unnoticed
assert calculate_discount(100, 20) == 80  # Fails! Returns 80.0 but for wrong reason
assert calculate_discount(100, 0) == 100  # Passes
assert calculate_discount(100, 110) == -10  # Should validate!

ℹ️

Key Concept: Tests catch bugs early, document behavior, and enable safe refactoring.


pytest Basics

First Test

# test_calculator.py
import pytest

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

# Basic test functions
def test_add_positive():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, -1) == -2

def test_add_zero():
    assert add(0, 0) == 0

def test_divide_normal():
    assert divide(10, 2) == 5.0

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

# Run with: pytest test_calculator.py -v

Assertions

import pytest

def test_assertions_example():
    # Equality
    assert 1 + 1 == 2
    
    # Identity
    a = [1, 2, 3]
    b = a
    assert a is b
    
    # Membership
    assert 2 in [1, 2, 3]
    
    # Boolean
    assert True
    assert not False
    
    # Approximate (for floats)
    assert 0.1 + 0.2 == pytest.approx(0.3)
    
    # Exception
    with pytest.raises(ZeroDivisionError):
        1 / 0
    
    # Custom message
    assert 1 + 1 == 2, "Math is broken!"

pytest Fixtures

Basic Fixtures

import pytest
from typing import List

# Simple fixture
@pytest.fixture
def sample_data() -> List[int]:
    """Provide sample data for tests."""
    return [1, 2, 3, 4, 5]

# Fixture with cleanup
@pytest.fixture
def temp_file(tmp_path):
    """Create temporary file for testing."""
    file = tmp_path / "test_data.txt"
    file.write_text("line1\nline2\nline3")
    yield file
    # Cleanup happens automatically

# Fixture with setup/teardown
@pytest.fixture
def database():
    """Database fixture with setup/teardown."""
    # Setup
    db = {"users": [], "connected": True}
    print("Database connected")
    
    yield db
    
    # Teardown
    db.clear()
    print("Database disconnected")

# Usage in tests
def test_sample_data(sample_data):
    assert len(sample_data) == 5
    assert sum(sample_data) == 15

def test_temp_file(temp_file):
    content = temp_file.read_text()
    assert "line1" in content

def test_database(database):
    database["users"].append({"id": 1, "name": "Alice"})
    assert len(database["users"]) == 1

Fixture Scopes

import pytest
import time

# Function scope (default) - runs once per test function
@pytest.fixture(scope="function")
def function_fixture():
    print("\nSetting up function fixture")
    yield
    print("\nTearing down function fixture")

# Class scope - runs once per test class
@pytest.fixture(scope="class")
def class_fixture():
    print("\nSetting up class fixture")
    yield
    print("\nTearing down class fixture")

# Module scope - runs once per module
@pytest.fixture(scope="module")
def module_fixture():
    print("\nSetting up module fixture")
    yield
    print("\nTearing down module fixture")

# Session scope - runs once per test session
@pytest.fixture(scope="session")
def session_fixture():
    print("\nSetting up session fixture")
    yield
    print("\nTearing down session fixture")

# Usage
class TestWithFixtures:
    def test_one(self, function_fixture, class_fixture):
        pass
    
    def test_two(self, function_fixture, class_fixture):
        pass

Parametrized Fixtures

import pytest

# Parametrized fixture
@pytest.fixture(params=["sqlite", "mysql", "postgresql"])
def database(request):
    """Provide different database backends."""
    if request.param == "sqlite":
        db = {"type": "sqlite", "connected": True}
    elif request.param == "mysql":
        db = {"type": "mysql", "connected": True}
    else:
        db = {"type": "postgresql", "connected": True}
    
    yield db
    
    db.clear()

# Fixture with indirect parametrize
@pytest.fixture
def user(request):
    """Create user with parametrized role."""
    role = request.param
    return {"name": "Alice", "role": role}

# Usage - runs test 3 times (once for each database)
def test_database_connection(database):
    assert database["connected"] is True
    print(f"Testing {database['type']}")

# Indirect parametrize
@pytest.mark.parametrize("user", ["admin", "user", "guest"], indirect=True)
def test_user_permissions(user):
    if user["role"] == "admin":
        assert "delete" in get_permissions(user["role"])
    elif user["role"] == "user":
        assert "read" in get_permissions(user["role"])

def get_permissions(role):
    permissions = {
        "admin": ["read", "write", "delete"],
        "user": ["read", "write"],
        "guest": ["read"]
    }
    return permissions.get(role, [])

πŸ’‘

Interview Tip: Explain fixture scopes: function (default), class, module, session. Higher scopes are faster for expensive setup.


Mocking and Patching

unittest.mock

from unittest.mock import Mock, MagicMock, patch, call
import pytest

# Basic Mock
def test_basic_mock():
    mock_obj = Mock()
    mock_obj.method.return_value = 42
    
    result = mock_obj.method()
    assert result == 42
    mock_obj.method.assert_called_once()

# Mock with side effects
def test_mock_side_effects():
    mock_obj = Mock()
    mock_obj.method.side_effect = [1, 2, 3, Exception("Error")]
    
    assert mock_obj.method() == 1
    assert mock_obj.method() == 2
    assert mock_obj.method() == 3
    
    with pytest.raises(Exception, match="Error"):
        mock_obj.method()

# Mock calls tracking
def test_mock_calls():
    mock_obj = Mock()
    mock_obj.method(1, 2)
    mock_obj.method(3, 4)
    mock_obj.other("test")
    
    # Check calls
    assert mock_obj.method.call_count == 2
    assert mock_obj.method.call_args_list == [call(1, 2), call(3, 4)]
    assert mock_obj.other.called

Patching

from unittest.mock import patch, MagicMock
import requests
import pytest

# Module to test
def get_user_data(user_id: int) -> dict:
    """Fetch user data from API."""
    response = requests.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status()
    return response.json()

# Test with patching
class TestGetUserData:
    @patch('requests.get')
    def test_get_user_success(self, mock_get):
        # Configure mock
        mock_response = Mock()
        mock_response.json.return_value = {"id": 1, "name": "Alice"}
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response
        
        # Call function
        result = get_user_data(1)
        
        # Assertions
        assert result == {"id": 1, "name": "Alice"}
        mock_get.assert_called_once_with("https://api.example.com/users/1")
    
    @patch('requests.get')
    def test_get_user_not_found(self, mock_get):
        # Configure mock to raise exception
        mock_get.side_effect = requests.exceptions.HTTPError("404 Not Found")
        
        # Call function and expect exception
        with pytest.raises(requests.exceptions.HTTPError):
            get_user_data(999)

Mocking Classes

from unittest.mock import Mock, patch, MagicMock
import pytest

# Class to test
class UserService:
    def __init__(self, db, cache):
        self.db = db
        self.cache = cache
    
    def get_user(self, user_id: int) -> dict:
        # Check cache first
        cached = self.cache.get(f"user:{user_id}")
        if cached:
            return cached
        
        # Fetch from database
        user = self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
        if user:
            self.cache.set(f"user:{user_id}", user, ttl=300)
        return user

# Test with mocked dependencies
class TestUserService:
    @pytest.fixture
    def mock_db(self):
        return Mock()
    
    @pytest.fixture
    def mock_cache(self):
        return Mock()
    
    @pytest.fixture
    def service(self, mock_db, mock_cache):
        return UserService(mock_db, mock_cache)
    
    def test_get_user_from_cache(self, service, mock_cache):
        # Configure cache hit
        mock_cache.get.return_value = {"id": 1, "name": "Alice"}
        
        result = service.get_user(1)
        
        assert result == {"id": 1, "name": "Alice"}
        mock_cache.get.assert_called_once_with("user:1")
        service.db.query.assert_not_called()
    
    def test_get_user_from_db(self, service, mock_db, mock_cache):
        # Configure cache miss
        mock_cache.get.return_value = None
        mock_db.query.return_value = {"id": 1, "name": "Alice"}
        
        result = service.get_user(1)
        
        assert result == {"id": 1, "name": "Alice"}
        mock_db.query.assert_called_once()
        mock_cache.set.assert_called_once_with("user:1", {"id": 1, "name": "Alice"}, ttl=300)

⚠️

Common Mistake: Forgetting to patch the right location. Always patch where the object is used, not where it's defined.


Parametrize

Basic Parametrize

import pytest

def is_palindrome(s: str) -> bool:
    """Check if string is palindrome."""
    s = s.lower().replace(" ", "")
    return s == s[::-1]

# Single parameter
@pytest.mark.parametrize("input_str,expected", [
    ("racecar", True),
    ("hello", False),
    ("A man a plan a canal Panama", True),
    ("", True),
    ("ab", False),
])
def test_is_palindrome(input_str, expected):
    assert is_palindrome(input_str) == expected

# Multiple parameters
def add(a: int, b: int) -> int:
    return a + b

@pytest.mark.parametrize("a,b,expected", [
    (1, 2, 3),
    (-1, 1, 0),
    (0, 0, 0),
    (100, 200, 300),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

# Parametrize with IDs
@pytest.mark.parametrize("input_val", [
    pytest.param(1, id="positive"),
    pytest.param(-1, id="negative"),
    pytest.param(0, id="zero"),
])
def test_abs(input_val):
    assert abs(input_val) >= 0

Advanced Parametrize

import pytest
from typing import List, Tuple

# Matrix testing
@pytest.mark.parametrize("matrix,expected", [
    ([[1, 2], [3, 4]], 10),  # Sum
    ([[0, 0], [0, 0]], 0),
    ([[1, 1], [1, 1]], 4),
])
def test_matrix_sum(matrix: List[List[int]], expected: int):
    assert sum(sum(row) for row in matrix) == expected

# Cross product parametrize
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_cross_product(x, y):
    result = x * y
    assert result in [10, 20, 30, 40, 60]

# Parametrize with fixtures
@pytest.fixture
def database(request):
    return request.param

@pytest.mark.parametrize("database", ["sqlite", "mysql"], indirect=True)
def test_database_operation(database):
    assert database["connected"] is True

Parametrize Classes

import pytest

class TestMathOperations:
    """Test multiple math operations."""
    
    @pytest.mark.parametrize("a,b,expected", [
        (1, 2, 3),
        (0, 0, 0),
        (-1, 1, 0),
    ])
    def test_add(self, a, b, expected):
        assert a + b == expected
    
    @pytest.mark.parametrize("a,b,expected", [
        (10, 2, 5),
        (0, 1, 0),
        (-4, 2, -2),
    ])
    def test_divide(self, a, b, expected):
        if b == 0:
            pytest.skip("Cannot divide by zero")
        assert a / b == expected

ℹ️

Performance Tip: Parametrize runs each case as a separate test, making failures easier to identify.


Coverage Analysis

Running Coverage

# Run pytest with coverage
pytest --cov=src --cov-report=html

# Coverage configuration (.coveragerc or pyproject.toml)
[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/migrations/*"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise NotImplementedError",
    "if __name__ == .__main__.",
]
show_missing = true
fail_under = 80

Coverage in Tests

import pytest
from coverage import Coverage

def test_with_coverage():
    """Test that measures coverage."""
    # Start coverage
    cov = Coverage()
    cov.start()
    
    # Code to test
    result = complex_function()
    
    # Stop coverage
    cov.stop()
    cov.save()
    
    # Check coverage
    coverage_percent = cov.report()
    assert coverage_percent >= 80, f"Coverage {coverage_percent}% is below 80%"

Coverage Configuration

# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --cov=src --cov-report=html --cov-report=term"

[tool.coverage.run]
source = ["src"]
branch = true
parallel = true

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise NotImplementedError",
    "if __name__ == .__main__.",
    "@abstractmethod",
]
show_missing = true
precision = 2
fail_under = 85

[tool.coverage.html]
directory = "htmlcov"

Advanced Testing Patterns

Test Doubles

from abc import ABC, abstractmethod
from typing import List
import pytest

# Abstract base
class UserRepository(ABC):
    @abstractmethod
    def get_user(self, user_id: int) -> dict:
        pass
    
    @abstractmethod
    def save_user(self, user: dict) -> bool:
        pass

# Real implementation
class DatabaseUserRepository(UserRepository):
    def __init__(self, db_connection):
        self.db = db_connection
    
    def get_user(self, user_id: int) -> dict:
        return self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
    
    def save_user(self, user: dict) -> bool:
        return self.db.execute("INSERT INTO users ...", user)

# Test double (Fake)
class FakeUserRepository(UserRepository):
    def __init__(self):
        self.users = {}
        self.id_counter = 1
    
    def get_user(self, user_id: int) -> dict:
        return self.users.get(user_id)
    
    def save_user(self, user: dict) -> bool:
        user["id"] = self.id_counter
        self.users[self.id_counter] = user
        self.id_counter += 1
        return True

# Test double (Spy)
class SpyUserRepository(UserRepository):
    def __init__(self):
        self.get_calls = []
        self.save_calls = []
    
    def get_user(self, user_id: int) -> dict:
        self.get_calls.append(user_id)
        return {"id": user_id, "name": "Test"}
    
    def save_user(self, user: dict) -> bool:
        self.save_calls.append(user)
        return True

# Tests using test doubles
class TestUserService:
    def test_get_user_with_fake(self):
        repo = FakeUserRepository()
        repo.save_user({"name": "Alice"})
        
        user = repo.get_user(1)
        assert user["name"] == "Alice"
    
    def test_save_user_with_spy(self):
        repo = SpyUserRepository()
        repo.save_user({"name": "Bob"})
        
        assert len(repo.save_calls) == 1
        assert repo.save_calls[0]["name"] == "Bob"

Mocking Async Code

import pytest
from unittest.mock import AsyncMock, patch
import asyncio

# Async function to test
async def fetch_user_data(user_id: int) -> dict:
    """Fetch user data asynchronously."""
    await asyncio.sleep(0.1)  # Simulate async operation
    return {"id": user_id, "name": "Alice"}

class AsyncUserService:
    def __init__(self, db):
        self.db = db
    
    async def get_user(self, user_id: int) -> dict:
        return await self.db.fetch_one(f"SELECT * FROM users WHERE id = {user_id}")

# Tests
class TestAsyncCode:
    @pytest.mark.asyncio
    async def test_fetch_user_data(self):
        result = await fetch_user_data(1)
        assert result == {"id": 1, "name": "Alice"}
    
    @pytest.mark.asyncio
    async def test_async_service_with_mock(self):
        mock_db = AsyncMock()
        mock_db.fetch_one.return_value = {"id": 1, "name": "Alice"}
        
        service = AsyncUserService(mock_db)
        user = await service.get_user(1)
        
        assert user == {"id": 1, "name": "Alice"}
        mock_db.fetch_one.assert_called_once()

Property-Based Testing

from hypothesis import given, strategies as st
import pytest

def reverse_string(s: str) -> str:
    """Reverse a string."""
    return s[::-1]

def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

# Property-based tests
class TestProperties:
    @given(st.text())
    def test_reverse_idempotent(self, s: str):
        """Reversing twice gives original string."""
        assert reverse_string(reverse_string(s)) == s
    
    @given(st.text())
    def test_reverse_length(self, s: str):
        """Reversed string has same length."""
        assert len(reverse_string(s)) == len(s)
    
    @given(st.integers(), st.integers())
    def test_add_commutative(self, a: int, b: int):
        """Addition is commutative."""
        assert add(a, b) == add(b, a)
    
    @given(st.integers())
    def test_add_identity(self, a: int):
        """Zero is additive identity."""
        assert add(a, 0) == a

πŸ’‘

Interview Tip: Property-based testing finds edge cases automatically. Use Hypothesis library for powerful testing.


Test Organization

Directory Structure

Architecture Diagram
project/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ calculator.py
β”‚   └── user_service.py
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ conftest.py          # Shared fixtures
β”‚   β”œβ”€β”€ test_calculator.py
β”‚   β”œβ”€β”€ test_user_service.py
β”‚   └── integration/
β”‚       β”œβ”€β”€ conftest.py
β”‚       └── test_database.py
β”œβ”€β”€ pyproject.toml
└── pytest.ini

conftest.py

# tests/conftest.py
import pytest
from typing import Generator

@pytest.fixture(scope="session")
def db_connection() -> Generator:
    """Session-wide database connection."""
    # Setup
    connection = create_db_connection()
    yield connection
    # Teardown
    connection.close()

@pytest.fixture(autouse=True)
def reset_state():
    """Reset state between tests."""
    yield
    # Cleanup
    clear_cache()

@pytest.fixture
def sample_user() -> dict:
    """Provide a sample user."""
    return {
        "id": 1,
        "name": "Alice",
        "email": "alice@example.com",
        "role": "admin"
    }

Test Markers

import pytest

# Mark tests
@pytest.mark.slow
def test_expensive_operation():
    pass

@pytest.mark.integration
def test_database_operation():
    pass

@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
    pass

@pytest.mark.skipif(
    sys.platform == "win32",
    reason="Unix only test"
)
def test_unix_only():
    pass

# Run specific markers
# pytest -m "not slow"
# pytest -m "integration"

Interview Tips

Common Follow-up Questions

  1. "When should you mock vs use real objects?"

    • Mock external dependencies (APIs, databases)
    • Use real objects for business logic
    • Mock slow or unreliable systems
  2. "How do you test private methods?"

    • Test through public interface
    • Or use name mangling: _ClassName__method()
    • Prefer testing behavior over implementation
  3. "What's the difference between mock and patch?"

    • mock: Create mock objects
    • patch: Replace objects in a namespace
    • Often used together

Code Review Tips

# BAD: Testing implementation details
def test_internal_calculation():
    service = UserService()
    # Accessing private attribute
    assert service._internal_cache == {}

# GOOD: Testing behavior
def test_user_caching_behavior():
    service = UserService()
    user = service.get_user(1)
    # Verify behavior, not implementation
    assert user is not None

# BAD: Tests that depend on order
def test_create_user():
    create_user({"name": "Alice"})
    
def test_get_user():
    # Fails if test_create_user hasn't run
    user = get_user(1)
    assert user["name"] == "Alice"

# GOOD: Independent tests
@pytest.fixture
def user():
    return create_user({"name": "Alice"})

def test_get_user(user):
    retrieved = get_user(user["id"])
    assert retrieved["name"] == "Alice"

⚠️

Common Mistake: Writing tests that depend on execution order. Each test should be independent.


Summary

Tool/ConceptPurposeWhen to Use
pytestTest runnerAlways
FixturesTest setup/teardownShared resources
Mock/PatchIsolate dependenciesExternal systems
ParametrizeMultiple test casesSimilar tests
CoverageMeasure test completenessCI/CD pipelines
HypothesisProperty-based testingEdge cases

Best Practices

  1. Write tests first (TDD/BDD)
  2. Keep tests independent
  3. Use descriptive names
  4. Test edge cases
  5. Mock external dependencies
  6. Aim for 80%+ coverage
  7. Run tests in CI/CD

ℹ️

Key Takeaway: Good tests catch bugs early, document behavior, and enable confident refactoring.


Practice Problems

  1. Test Calculator: Write tests for a calculator class with error handling
  2. Mock API: Test an API client with mocked HTTP responses
  3. Parametrize Login: Test login with various valid/invalid credentials
  4. Integration Test: Write integration tests with database fixtures
  5. Coverage Analysis: Achieve 90%+ coverage on a module

Further Reading

Remember: Testing is not just about finding bugsβ€”it's about building confidence in your code.

Advertisement