Skip to content

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

🔗 Language-Specific Testing