Testing Python Applications
Learn how to write effective tests for your Python applications using pytest and unittest.
Why Testing Matters
Testing ensures your code works as expected, prevents regressions, and makes your application more reliable. Good tests act as documentation and help you refactor with confidence.
Testing Frameworks in Python
unittest
Built into Python’s standard library, inspired by Java’s JUnit.
Pros:
- No installation required
- Part of standard library
- Good for beginners
Cons:
- Verbose syntax
- More boilerplate code
pytest
Third-party framework with powerful features and clean syntax.
Pros:
- Simple, readable syntax
- Powerful fixtures
- Great plugin ecosystem
- Excellent error messages
Cons:
- Requires installation (
pip install pytest)
Getting Started with pytest
Installation
pip install pytestBasic Test Structure
Create a file named test_calculator.py:
def test_addition():
assert 2 + 2 == 4
def test_subtraction():
assert 10 - 5 == 5
def test_multiplication():
assert 3 * 4 == 12Run your tests:
pytestTesting a Real Application
Let’s test a simple calculator module:
calculator.py:
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def power(base, exponent):
return base ** exponenttest_calculator.py:
import pytest
from calculator import add, subtract, multiply, divide, power
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-2, -3) == -5
def test_add_mixed_numbers():
assert add(5, -3) == 2
def test_subtract_positive_numbers():
assert subtract(10, 4) == 6
def test_multiply_by_zero():
assert multiply(5, 0) == 0
def test_divide_positive_numbers():
assert divide(10, 2) == 5
def test_divide_by_zero_raises_error():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
def test_power_positive_exponent():
assert power(2, 3) == 8
def test_power_zero_exponent():
assert power(5, 0) == 1
def test_power_negative_exponent():
assert power(2, -2) == 0.25Test Fixtures
Fixtures help you set up test data and environments.
Basic Fixture
import pytest
@pytest.fixture
def sample_data():
return [1, 2, 3, 4, 5]
def test_sum(sample_data):
assert sum(sample_data) == 15
def test_length(sample_data):
assert len(sample_data) == 5Fixture with Setup and Cleanup
@pytest.fixture
def temp_file():
import tempfile
import os
# Setup
temp_fd, temp_path = tempfile.mkstemp()
with os.fdopen(temp_fd, 'w') as f:
f.write("test content")
yield temp_path # Provide the file path to tests
# Cleanup
os.unlink(temp_path)
def test_file_content(temp_file):
with open(temp_file, 'r') as f:
content = f.read()
assert content == "test content"Database Fixture
import sqlite3
import pytest
@pytest.fixture
def test_db():
# Setup
conn = sqlite3.connect(':memory:')
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
''')
# Insert test data
cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("John Doe", "[email protected]")
)
conn.commit()
yield conn # Provide connection to tests
# Cleanup
conn.close()
def test_user_insertion(test_db):
cursor = test_db.cursor()
cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("Jane Smith", "[email protected]")
)
test_db.commit()
cursor.execute("SELECT COUNT(*) FROM users")
count = cursor.fetchone()[0]
assert count == 2Parameterized Tests
Run the same test with multiple inputs:
import pytest
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(10, -5, 5)
])
def test_add_various_numbers(a, b, expected):
assert add(a, b) == expected
@pytest.mark.parametrize("input_str,expected", [
("hello", "hello"),
("Hello", "hello"),
("HELLO", "hello"),
("HeLLo", "hello")
])
def test_string_lowercase(input_str, expected):
assert input_str.lower() == expectedTesting Exceptions
def test_divide_by_zero():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert "Cannot divide by zero" in str(exc_info.value)
def test_file_not_found():
with pytest.raises(FileNotFoundError):
with open("nonexistent_file.txt", "r"):
passMocking and Patching
Use unittest.mock to isolate code from external dependencies:
from unittest.mock import patch, Mock
import requests
def get_user_data(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
@patch('requests.get')
def test_get_user_data(mock_get):
# Setup mock response
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "John Doe"}
mock_get.return_value = mock_response
# Test
result = get_user_data(1)
# Assertions
assert result["name"] == "John Doe"
mock_get.assert_called_once_with("https://api.example.com/users/1")
@patch('builtins.open')
def test_file_reading(mock_open):
# Setup mock file
mock_file = Mock()
mock_file.read.return_value = "file content"
mock_open.return_value.__enter__.return_value = mock_file
# Test
with open("test.txt", "r") as f:
content = f.read()
# Assertions
assert content == "file content"
mock_open.assert_called_once_with("test.txt", "r")Testing Web Applications
Flask Example
from flask import Flask
import pytest
app = Flask(__name__)
@app.route('/hello')
def hello():
return "Hello, World!"
@app.route('/user/<int:user_id>')
def get_user(user_id):
users = {1: "Alice", 2: "Bob"}
return users.get(user_id, "User not found"), 404 if user_id not in users else 200
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
def test_hello_route(client):
response = client.get('/hello')
assert response.status_code == 200
assert response.data.decode() == "Hello, World!"
def test_get_user_success(client):
response = client.get('/user/1')
assert response.status_code == 200
assert response.data.decode() == "Alice"
def test_get_user_not_found(client):
response = client.get('/user/999')
assert response.status_code == 404
assert response.data.decode() == "User not found"Test Organization
Directory Structure
project/
├── src/
│ ├── calculator.py
│ └── utils.py
├── tests/
│ ├── test_calculator.py
│ ├── test_utils.py
│ └── conftest.py # Shared fixtures
└── pytest.iniconftest.py for Shared Fixtures
# tests/conftest.py
import pytest
import tempfile
import os
@pytest.fixture(scope="session")
def temp_dir():
"""Create a temporary directory for all tests"""
temp_dir = tempfile.mkdtemp()
yield temp_dir
# Cleanup after all tests
import shutil
shutil.rmtree(temp_dir)
@pytest.fixture
def sample_config():
return {
"database_url": "sqlite:///:memory:",
"debug": True,
"secret_key": "test-secret"
}Test Configuration
pytest.ini
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
markers =
slow: marks tests as slow
integration: marks tests as integration tests
unit: marks tests as unit testsRunning Specific Tests
# Run all tests
pytest
# Run specific file
pytest tests/test_calculator.py
# Run specific test function
pytest tests/test_calculator.py::test_addition
# Run tests with specific marker
pytest -m unit
# Run tests excluding marker
pytest -m "not slow"
# Run with coverage
pytest --cov=src --cov-report=htmlTest-Driven Development (TDD)
TDD Workflow
- Red: Write a failing test
- Green: Write minimal code to make test pass
- Refactor: Improve code while keeping tests green
Example
Step 1: Write failing test
def test_calculate_area_of_circle():
from geometry import calculate_circle_area
assert abs(calculate_circle_area(1) - 3.14159) < 0.0001Step 2: Make it pass
# geometry.py
import math
def calculate_circle_area(radius):
return math.pi * radius ** 2Step 3: Refactor and add more tests
def test_calculate_area_various_radii():
from geometry import calculate_circle_area
assert abs(calculate_circle_area(0) - 0) < 0.0001
assert abs(calculate_circle_area(2) - 12.56637) < 0.0001
assert abs(calculate_circle_area(10) - 314.15926) < 0.0001Best Practices
Writing Good Tests
- Test One Thing: Each test should verify one specific behavior
- Use Descriptive Names: Test names should describe what they test
- Arrange-Act-Assert: Structure tests clearly
- Independent Tests: Tests shouldn’t depend on each other
- Test Edge Cases: Test boundaries and error conditions
Example of Good Test Structure
def test_calculate_total_with_discount_applied():
# Arrange
cart = ShoppingCart()
cart.add_item("Book", 20.0)
cart.add_item("Pen", 5.0)
discount_percentage = 10
# Act
total = cart.calculate_total(discount_percentage)
# Assert
expected_total = 22.5 # (20 + 5) * 0.9
assert total == expected_totalWhat to Test
- Public APIs: Test all public functions and methods
- Business Logic: Test core application logic
- Error Handling: Test exception scenarios
- Edge Cases: Test boundary conditions
- Integration: Test component interactions
What Not to Test
- Private Methods: Test through public interface
- Third-party Code: Trust that libraries work
- Configuration: Don’t test framework features
- Trivial Code: Don’t test simple getters/setters
Continuous Integration
GitHub Actions Example
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, 3.10, 3.11]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov
- name: Run tests
run: |
pytest --cov=src --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v1Resources
- pytest Documentation
- Python Testing with pytest
- Test-Driven Development with Python
- Effective Testing with Python
Next Steps
Start incorporating tests into your projects gradually. Begin with critical business logic and expand your test coverage over time. Remember: some tests are better than no tests!