Testing Python Applications

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 pytest

Basic 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 == 12

Run your tests:

pytest

Testing 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 ** exponent

test_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.25

Test 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) == 5

Fixture 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 == 2

Parameterized 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() == expected

Testing 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"):
            pass

Mocking 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.ini

conftest.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 tests

Running 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=html

Test-Driven Development (TDD)

TDD Workflow

  1. Red: Write a failing test
  2. Green: Write minimal code to make test pass
  3. 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.0001

Step 2: Make it pass

# geometry.py
import math

def calculate_circle_area(radius):
    return math.pi * radius ** 2

Step 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.0001

Best Practices

Writing Good Tests

  1. Test One Thing: Each test should verify one specific behavior
  2. Use Descriptive Names: Test names should describe what they test
  3. Arrange-Act-Assert: Structure tests clearly
  4. Independent Tests: Tests shouldn’t depend on each other
  5. 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_total

What 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@v1

Resources

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!

Last updated on