Testing (pytest)

Difficulty: Intermediate

Question

How do you write tests in Python? Explain pytest and testing best practices.

Answer

Python has unittest (built-in) and pytest (third-party, preferred by most).

pytest advantages over unittest: - Simple assert statements (no self.assertEqual) - Powerful fixtures for setup/teardown - Parametrize for running tests with multiple inputs - Rich plugin ecosystem - Better output and error messages

Testing best practices: - Test one thing per test function - Use descriptive test names - Arrange-Act-Assert pattern - Mock external dependencies - Aim for meaningful coverage, not 100%

Code examples

Basic pytest Tests

# 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

# Simple assertions
def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

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

# Testing exceptions
import pytest

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

# Approximate comparisons (for floats)
def test_float_comparison():
    assert divide(1, 3) == pytest.approx(0.333, rel=1e-2)

# Run with: pytest test_calculator.py -v
# Output:
# test_calculator.py::test_add PASSED
# test_calculator.py::test_divide PASSED
# test_calculator.py::test_divide_by_zero PASSED
# test_calculator.py::test_float_comparison PASSED

pytest uses plain assert statements with detailed failure messages. pytest.raises() tests that exceptions are raised. pytest.approx() handles floating point comparison.

Fixtures and Parametrize

import pytest

# Fixtures: setup and teardown
@pytest.fixture
def sample_users():
    """Provides test data for user tests."""
    return [
        {'name': 'Alice', 'age': 30},
        {'name': 'Bob', 'age': 25},
        {'name': 'Charlie', 'age': 35},
    ]

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

def test_youngest_user(sample_users):
    youngest = min(sample_users, key=lambda u: u['age'])
    assert youngest['name'] == 'Bob'

# Parametrize: run test with multiple inputs
@pytest.mark.parametrize('input,expected', [
    ('hello', 'HELLO'),
    ('world', 'WORLD'),
    ('Python', 'PYTHON'),
    ('', ''),
])
def test_uppercase(input, expected):
    assert input.upper() == expected

# Parametrize with IDs for better output
@pytest.mark.parametrize('a,b,result', [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
    (100, 200, 300),
], ids=['positive', 'negative', 'zeros', 'large'])
def test_add_params(a, b, result):
    assert add(a, b) == result

# Fixture with teardown
@pytest.fixture
def temp_file(tmp_path):
    filepath = tmp_path / 'test.txt'
    filepath.write_text('test data')
    yield filepath  # Test runs here
    # Cleanup happens automatically (tmp_path handles it)

Fixtures provide reusable test data and setup/teardown. @parametrize runs the same test with different inputs, reducing code duplication.

Mocking External Dependencies

from unittest.mock import patch, MagicMock
import pytest

# Code under test
class UserService:
    def __init__(self, api_client):
        self.api = api_client
    
    def get_user(self, user_id):
        response = self.api.get(f'/users/{user_id}')
        if response.status_code == 200:
            return response.json()
        return None
    
    def create_user(self, name, email):
        response = self.api.post('/users', json={'name': name, 'email': email})
        return response.status_code == 201

# Test with MagicMock
def test_get_user_success():
    mock_api = MagicMock()
    mock_api.get.return_value.status_code = 200
    mock_api.get.return_value.json.return_value = {'id': 1, 'name': 'Alice'}
    
    service = UserService(mock_api)
    user = service.get_user(1)
    
    assert user == {'id': 1, 'name': 'Alice'}
    mock_api.get.assert_called_once_with('/users/1')

def test_get_user_not_found():
    mock_api = MagicMock()
    mock_api.get.return_value.status_code = 404
    
    service = UserService(mock_api)
    user = service.get_user(999)
    
    assert user is None

# Patch decorator for module-level mocking
@patch('builtins.open', create=True)
def test_read_config(mock_open):
    mock_open.return_value.__enter__ = lambda s: s
    mock_open.return_value.__exit__ = MagicMock(return_value=False)
    mock_open.return_value.read.return_value = '{"key": "value"}'
    
    # Test code that reads a file...
    mock_open.assert_called_once()

MagicMock creates flexible mock objects. patch replaces real objects with mocks during tests. assert_called_once_with verifies the mock was called correctly.

Key points

Concepts covered

pytest, unittest, Fixtures, Mocking, Parametrize, Coverage