TechLead
Lesson 18 of 25
5 min read
Python

Python Testing with pytest

Master testing in Python with pytest, fixtures, mocking, and test-driven development practices

Why pytest?

pytest is the most popular testing framework in the Python ecosystem. It offers a simple syntax, powerful fixtures, informative error messages, and a rich plugin ecosystem. It can run tests written for unittest and nose as well, making migration easy.

Writing Your First Tests

# Install pytest
# pip install pytest

# test_calculator.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 - just use assert!
def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

def test_add_floats():
    result = add(0.1, 0.2)
    assert result == pytest.approx(0.3)  # Handle float precision

def test_divide():
    assert divide(10, 2) == 5.0
    assert divide(7, 2) == 3.5

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

# Run tests:
# pytest                    # Run all tests
# pytest test_calculator.py # Run specific file
# pytest -v                 # Verbose output
# pytest -x                 # Stop on first failure
# pytest -k "test_add"      # Run tests matching pattern

Fixtures

Fixtures provide a way to set up test data and resources. They are more powerful than setUp/tearDown methods because they are modular, composable, and support dependency injection.

import pytest

# Basic fixture
@pytest.fixture
def sample_users():
    return [
        {"name": "Alice", "age": 30, "role": "admin"},
        {"name": "Bob", "age": 25, "role": "user"},
        {"name": "Charlie", "age": 35, "role": "user"},
    ]

def test_user_count(sample_users):
    assert len(sample_users) == 3

def test_admin_exists(sample_users):
    admins = [u for u in sample_users if u["role"] == "admin"]
    assert len(admins) == 1

# Fixture with setup and teardown
@pytest.fixture
def temp_file(tmp_path):
    file_path = tmp_path / "test.txt"
    file_path.write_text("test data")
    yield file_path  # test runs here
    # Cleanup runs after test (automatic with tmp_path)

def test_read_file(temp_file):
    assert temp_file.read_text() == "test data"

# Fixture scopes
@pytest.fixture(scope="module")  # Created once per module
def db_connection():
    conn = create_connection()
    yield conn
    conn.close()

@pytest.fixture(scope="session")  # Created once per test session
def app_config():
    return {"debug": True, "db": "sqlite:///:memory:"}

# Parametrized fixtures
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def database_type(request):
    return request.param

def test_supported_database(database_type):
    assert database_type in ["sqlite", "postgresql", "mysql"]

Parametrized Tests

import pytest

# Run the same test with different inputs
@pytest.mark.parametrize("input,expected", [
    ("hello", "HELLO"),
    ("world", "WORLD"),
    ("Python", "PYTHON"),
    ("", ""),
])
def test_uppercase(input, expected):
    assert input.upper() == expected

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

# Multiple parametrize decorators create combinations
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    assert x * y > 0  # Tests: (1,10), (1,20), (2,10), (2,20)

Mocking

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

# Simple mock
def test_mock_basic():
    mock_db = Mock()
    mock_db.get_user.return_value = {"id": 1, "name": "Alice"}
    
    user = mock_db.get_user(1)
    assert user["name"] == "Alice"
    mock_db.get_user.assert_called_once_with(1)

# Patching external dependencies
# service.py
import requests

def get_weather(city):
    response = requests.get(f"https://api.weather.com/{city}")
    return response.json()

# test_service.py
@patch("service.requests.get")
def test_get_weather(mock_get):
    mock_response = Mock()
    mock_response.json.return_value = {"temp": 72, "condition": "sunny"}
    mock_get.return_value = mock_response
    
    result = get_weather("NYC")
    assert result["temp"] == 72
    mock_get.assert_called_once_with("https://api.weather.com/NYC")

# Context manager style
def test_get_weather_context():
    with patch("service.requests.get") as mock_get:
        mock_get.return_value.json.return_value = {"temp": 72}
        result = get_weather("NYC")
        assert result["temp"] == 72

# pytest-mock fixture (cleaner)
# pip install pytest-mock
def test_get_weather_mocker(mocker):
    mock_get = mocker.patch("service.requests.get")
    mock_get.return_value.json.return_value = {"temp": 72}
    result = get_weather("NYC")
    assert result["temp"] == 72

Key Takeaways

  • Use pytest: Simpler syntax and better output than unittest
  • Fixtures for setup: Modular, composable, and support dependency injection
  • Parametrize tests: Test many inputs with one test function
  • Mock external deps: Isolate your tests from networks, databases, and files

Continue Learning