Python Testing Frameworks¶
This guide covers Python testing frameworks, best practices, and strategies for writing effective tests.
🎯 Testing Fundamentals¶
Testing Pyramid¶
- Unit Tests: Fast, isolated tests for individual components
- Integration Tests: Test component interactions
- End-to-End Tests: Test complete user workflows
- Performance Tests: Load and stress testing
Testing Principles¶
- AAA Pattern: Arrange, Act, Assert
- First Principles: Fast, Independent, Repeatable, Self-validating, Timely
- Test Coverage: Aim for high coverage of critical paths
- Test Naming: Descriptive test names that explain behavior
🧪 pytest Framework¶
pytest Basics¶
# test_calculator.py
import pytest
from calculator import Calculator
class TestCalculator:
def setup_method(self):
"""Setup before each test"""
self.calculator = Calculator()
def teardown_method(self):
"""Cleanup after each test"""
pass
def test_add_positive_numbers(self):
"""Test adding two positive numbers"""
# Arrange
a = 5
b = 3
# Act
result = self.calculator.add(a, b)
# Assert
assert result == 8, "5 + 3 should equal 8"
def test_add_negative_numbers(self):
"""Test adding two negative numbers"""
# Arrange
a = -5
b = -3
# Act
result = self.calculator.add(a, b)
# Assert
assert result == -8, "-5 + -3 should equal -8"
def test_divide_by_zero_raises_exception(self):
"""Test that division by zero raises exception"""
# Arrange
a = 10
b = 0
# Act & Assert
with pytest.raises(ZeroDivisionError):
self.calculator.divide(a, b)
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 5, 5),
(-1, 1, 0),
(100, 200, 300)
])
def test_add_multiple_cases(self, a, b, expected):
"""Test addition with multiple parameter sets"""
result = self.calculator.add(a, b)
assert result == expected
pytest Fixtures¶
# conftest.py
import pytest
import tempfile
import os
@pytest.fixture
def sample_data():
"""Fixture providing sample data"""
return [1, 2, 3, 4, 5]
@pytest.fixture
def temp_file():
"""Fixture providing a temporary file"""
temp_fd, temp_path = tempfile.mkstemp()
try:
with os.fdopen(temp_fd, 'w') as f:
f.write("test data")
yield temp_path
finally:
os.unlink(temp_path)
@pytest.fixture(scope="session")
def database_connection():
"""Session-scoped database connection"""
connection = create_database_connection()
yield connection
connection.close()
# test_data_processing.py
def test_process_sample_data(sample_data):
"""Test processing sample data"""
result = process_data(sample_data)
assert len(result) == 5
def test_file_operations(temp_file):
"""Test file operations"""
with open(temp_file, 'r') as f:
content = f.read()
assert content == "test data"
pytest Markers and Skipping¶
import pytest
import sys
@pytest.mark.slow
def test_slow_operation():
"""Test that takes a long time"""
import time
time.sleep(5)
assert True
@pytest.mark.skipif(sys.version_info < (3, 8), reason="Requires Python 3.8+")
def test_python_38_feature():
"""Test feature only available in Python 3.8+"""
import math
result = math.prod([1, 2, 3, 4])
assert result == 24
@pytest.mark.xfail(reason="Known issue, will be fixed in v2.0")
def test_known_failing_feature():
"""Test that currently fails but is expected"""
assert False # This test is expected to fail
@pytest.mark.parametrize("input_data,expected", [
("[email protected]", True),
("invalid-email", False),
("", False),
("@domain.com", False)
])
def test_email_validation(input_data, expected):
"""Test email validation with multiple inputs"""
result = validate_email(input_data)
assert result == expected
pytest Plugins¶
# pytest.ini
[tool:pytest]
addopts = --verbose --tb=short
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
unit: marks tests as unit tests
# Using pytest-cov for coverage
# pip install pytest-cov
# Run with: pytest --cov=myapp tests/
# Using pytest-mock for mocking
# pip install pytest-mock
def test_with_mock(mocker):
mock_function = mocker.patch('module.function')
mock_function.return_value = 42
result = module.function()
assert result == 42
mock_function.assert_called_once()
🎭 unittest Framework¶
unittest Basics¶
# test_calculator_unittest.py
import unittest
from calculator import Calculator
class TestCalculator(unittest.TestCase):
def setUp(self):
"""Setup before each test"""
self.calculator = Calculator()
def tearDown(self):
"""Cleanup after each test"""
pass
def test_add_positive_numbers(self):
"""Test adding two positive numbers"""
result = self.calculator.add(5, 3)
self.assertEqual(result, 8)
def test_add_negative_numbers(self):
"""Test adding two negative numbers"""
result = self.calculator.add(-5, -3)
self.assertEqual(result, -8)
def test_divide_by_zero(self):
"""Test that division by zero raises exception"""
with self.assertRaises(ZeroDivisionError):
self.calculator.divide(10, 0)
def test_float_comparison(self):
"""Test floating point comparison"""
result = self.calculator.divide(1, 3)
self.assertAlmostEqual(result, 0.333, places=2)
if __name__ == '__main__':
unittest.main()
unittest Test Suite¶
# test_suite.py
import unittest
from test_calculator import TestCalculator
from test_string_utils import TestStringUtils
def create_test_suite():
"""Create a test suite with multiple test cases"""
suite = unittest.TestSuite()
# Add test cases
suite.addTest(unittest.makeSuite(TestCalculator))
suite.addTest(unittest.makeSuite(TestStringUtils))
return suite
if __name__ == '__main__':
runner = unittest.TextTestRunner(verbosity=2)
suite = create_test_suite()
result = runner.run(suite)
🌐 Django Testing¶
Django Test Framework¶
# tests/test_models.py
from django.test import TestCase
from django.contrib.auth.models import User
from blog.models import Post, Comment
class PostModelTest(TestCase):
def setUp(self):
"""Setup test data"""
self.user = User.objects.create_user(
username='testuser',
email='[email protected]',
password='testpass123'
)
self.post = Post.objects.create(
title='Test Post',
content='Test content',
author=self.user
)
def test_post_creation(self):
"""Test post creation"""
self.assertEqual(self.post.title, 'Test Post')
self.assertEqual(self.post.author, self.user)
self.assertEqual(str(self.post), 'Test Post')
def test_post_slug_creation(self):
"""Test automatic slug creation"""
self.assertIsNotNone(self.post.slug)
self.assertTrue(len(self.post.slug) > 0)
# tests/test_views.py
from django.test import Client
from django.urls import reverse
from django.contrib.auth.models import User
class PostViewTest(TestCase):
def setUp(self):
"""Setup test data"""
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
self.post = Post.objects.create(
title='Test Post',
content='Test content',
author=self.user
)
def test_post_list_view(self):
"""Test post list view"""
response = self.client.get(reverse('post-list'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Test Post')
def test_post_detail_view(self):
"""Test post detail view"""
response = self.client.get(
reverse('post-detail', kwargs={'pk': self.post.pk})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Test Post')
def test_create_post_view(self):
"""Test post creation view"""
self.client.login(username='testuser', password='testpass123')
response = self.client.post(reverse('post-create'), {
'title': 'New Post',
'content': 'New content'
})
self.assertEqual(response.status_code, 302)
self.assertTrue(Post.objects.filter(title='New Post').exists())
Django API Testing¶
# tests/test_api.py
from django.test import TestCase
from django.contrib.auth.models import User
from rest_framework.test import APIClient
from rest_framework import status
from blog.models import Post
from blog.serializers import PostSerializer
class PostAPITest(TestCase):
def setUp(self):
"""Setup test data"""
self.client = APIClient()
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
self.post = Post.objects.create(
title='Test Post',
content='Test content',
author=self.user
)
def test_get_post_list(self):
"""Test getting post list"""
response = self.client.get('/api/posts/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
def test_create_post(self):
"""Test creating a post"""
self.client.force_authenticate(user=self.user)
data = {
'title': 'New Post',
'content': 'New content'
}
response = self.client.post('/api/posts/', data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Post.objects.count(), 2)
def test_update_post(self):
"""Test updating a post"""
self.client.force_authenticate(user=self.user)
data = {
'title': 'Updated Post',
'content': 'Updated content'
}
response = self.client.put(
f'/api/posts/{self.post.pk}/',
data
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.post.refresh_from_db()
self.assertEqual(self.post.title, 'Updated Post')
📊 Performance Testing¶
Load Testing with Locust¶
# locustfile.py
from locust import HttpUser, task, between
import json
class WebsiteUser(HttpUser):
wait_time = between(1, 3)
def on_start(self):
"""Called when a user starts"""
response = self.client.get("/api/auth/login/")
self.token = response.json()['token']
@task(3)
def view_posts(self):
"""View posts (3x more likely)"""
self.client.get("/api/posts/")
@task(1)
def create_post(self):
"""Create a post"""
headers = {'Authorization': f'Bearer {self.token}'}
data = {
'title': 'Load Test Post',
'content': 'This is a load test post'
}
self.client.post("/api/posts/", json=data, headers=headers)
@task(2)
def view_post_detail(self):
"""View post details"""
post_id = 1 # In real scenario, randomly select
self.client.get(f"/api/posts/{post_id}/")
# Run with: locust -f locustfile.py
Benchmark Testing with pytest-benchmark¶
# test_performance.py
import pytest
from calculator import Calculator
@pytest.mark.benchmark
def test_add_performance(benchmark):
"""Benchmark addition operation"""
calculator = Calculator()
result = benchmark(calculator.add, 100, 200)
assert result == 300
@pytest.mark.benchmark
def test_string_concatenation(benchmark):
"""Benchmark string concatenation methods"""
strings = ['hello', 'world', 'test', 'benchmark']
def concat_with_plus():
result = ''
for s in strings:
result += s
return result
def concat_with_join():
return ''.join(strings)
# Benchmark both methods
plus_result = benchmark(concat_with_plus)
join_result = benchmark(concat_with_join)
assert plus_result == join_result
🛠️ Mocking and Patching¶
unittest.mock¶
# test_with_mocks.py
from unittest.mock import Mock, patch, MagicMock
import requests
from api_client import APIClient
class TestAPIClient:
def test_get_data_with_mock(self):
"""Test API client with mocked requests"""
# Create mock response
mock_response = Mock()
mock_response.json.return_value = {'data': 'test'}
mock_response.status_code = 200
# Patch requests.get
with patch('requests.get', return_value=mock_response) as mock_get:
client = APIClient()
result = client.get_data('https://api.example.com/data')
# Assertions
assert result == {'data': 'test'}
mock_get.assert_called_once_with('https://api.example.com/data')
def test_database_operations_with_mock(self):
"""Test database operations with mocked database"""
mock_db = MagicMock()
mock_db.get_user.return_value = {'id': 1, 'name': 'Test User'}
user_service = UserService(mock_db)
user = user_service.get_user(1)
assert user['name'] == 'Test User'
mock_db.get_user.assert_called_once_with(1)
@patch('time.time')
def test_timestamp_generation(self, mock_time):
"""Test timestamp generation with patched time"""
mock_time.return_value = 1234567890
timestamp = generate_timestamp()
assert timestamp == 1234567890
mock_time.assert_called_once()
pytest-mock¶
# test_with_pytest_mock.py
import pytest
from unittest.mock import Mock
def test_with_pytest_mock(mocker):
"""Test using pytest-mock fixture"""
# Mock external API
mock_api = mocker.patch('api_client.requests.get')
mock_api.return_value.json.return_value = {'status': 'success'}
client = APIClient()
result = client.check_status()
assert result['status'] == 'success'
mock_api.assert_called_once()
def test_spy_on_method(mocker):
"""Test spying on method calls"""
calculator = Calculator()
# Spy on the add method
spy = mocker.spy(calculator, 'add')
result = calculator.add(2, 3)
assert result == 5
spy.assert_called_once_with(2, 3)
📈 Test Coverage¶
Coverage Analysis¶
# Run coverage with pytest-cov
# pip install pytest-cov
# Commands:
# pytest --cov=myapp tests/
# pytest --cov=myapp --cov-report=html tests/
# pytest --cov=myapp --cov-report=term-missing tests/
# pytest.ini configuration
[tool:pytest]
addopts = --cov=myapp --cov-report=term-missing --cov-report=html
Coverage Configuration¶
# .coveragerc
[run]
source = myapp
omit =
*/tests/*
*/venv/*
*/migrations/*
*/__pycache__/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
🎯 Testing Best Practices¶
Test Structure¶
class TestBestPractices:
"""Example of well-structured tests"""
def test_descriptive_name_explains_behavior(self):
"""
Test name should describe what is being tested and expected behavior.
Follow AAA pattern: Arrange, Act, Assert.
"""
# Arrange - set up test data and mocks
calculator = Calculator()
input_a = 5
input_b = 3
# Act - call the method under test
result = calculator.add(input_a, input_b)
# Assert - verify the result
assert result == 8, f"Expected 8 but got {result}"
def test_multiple_assertions_with_descriptive_messages(self):
"""Multiple assertions with clear messages"""
result = some_complex_operation()
assert result is not None, "Result should not be None"
assert result.status == "success", f"Expected success but got {result.status}"
assert len(result.data) > 0, "Result data should not be empty"
@pytest.mark.parametrize("input_data,expected", [
(1, 2),
(2, 4),
(3, 6),
])
def test_parameterized_test_with_clear_cases(self, input_data, expected):
"""Parameterized tests with clear test cases"""
result = double_value(input_data)
assert result == expected
Test Data Management¶
# factories.py - Using factory pattern for test data
import factory
from django.contrib.auth.models import User
from blog.models import Post
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f"user{n}")
email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com")
password = factory.PostGenerationMethodCall('set_password')
class PostFactory(factory.django.DjangoModelFactory):
class Meta:
model = Post
title = factory.Faker('sentence')
content = factory.Faker('paragraph')
author = factory.SubFactory(UserFactory)
# Usage in tests
def test_with_factory_data():
user = UserFactory()
post = PostFactory(author=user)
assert post.author == user
assert len(post.title) > 0
📚 Related Resources¶
- Python Best Practices - Write testable code
- Python Common Mistakes - Avoid testing pitfalls
- Python Performance Tips - Performance testing
- Python Resources - Testing tools and documentation
🔗 Related Testing Guides¶
- Testing Strategies - General testing approaches
- Code Review Checklist - Review test quality
- Self-Assessment Techniques - Assess testing skills
🔗 Language-Specific Testing¶
- Java Testing Frameworks - Java testing
- C Testing Methods - C testing
- Oracle Testing Methods - Oracle testing