Skip to main content
Python intermediate Lesson 19 of 28

Testing Python Code

Write reliable tests with pytest and unittest, use fixtures and parametrize, mock dependencies, and measure coverage.

pytest Basics

pytest discovers tests automatically — any file matching test_*.py or *_test.py, and any function starting with test_.

pip install pytest
pytest                   # run all tests
pytest tests/            # run tests in directory
pytest tests/test_auth.py  # run specific file
pytest -v                # verbose output
pytest -k "test_login"   # run tests matching name
pytest -x                # stop after first failure
pytest --tb=short        # shorter tracebacks

Writing Tests

# tests/test_math.py

def add(a, b):
    return a + b

def test_add_integers():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, 1) == 0

def test_add_floats():
    result = add(0.1, 0.2)
    assert abs(result - 0.3) < 1e-9  # float comparison
    # Or:
    import pytest
    assert result == pytest.approx(0.3)

Testing Exceptions

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

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

def test_divide_normal():
    assert divide(10, 2) == 5.0

Fixtures

Fixtures provide reusable setup and teardown. They’re injected by name into test functions.

import pytest
from myapp.database import Database, User

@pytest.fixture
def db():
    """Provide a fresh in-memory database for each test."""
    database = Database(":memory:")
    database.migrate()
    yield database
    database.close()  # runs after the test

@pytest.fixture
def sample_user(db):
    """Create a user for tests that need one."""
    user = db.create_user(name="Alice", email="alice@example.com")
    return user

def test_user_creation(db):
    user = db.create_user(name="Bob", email="bob@example.com")
    assert user.id is not None
    assert user.name == "Bob"

def test_user_lookup(db, sample_user):
    found = db.find_user(sample_user.id)
    assert found.email == "alice@example.com"

Fixture Scopes

@pytest.fixture(scope="session")   # once per test session
def client():
    return TestClient(app)

@pytest.fixture(scope="module")    # once per test module
def db_connection():
    conn = connect()
    yield conn
    conn.close()

@pytest.fixture(scope="function")  # default — once per test
def fresh_cache():
    return {}

conftest.py

Fixtures in conftest.py are automatically available to all tests in the same directory and below — no import needed.

# tests/conftest.py
import pytest
from myapp import create_app

@pytest.fixture(scope="session")
def app():
    app = create_app(testing=True)
    return app

@pytest.fixture
def client(app):
    return app.test_client()

Parametrize

Run the same test with multiple inputs:

import pytest

@pytest.mark.parametrize("input,expected", [
    ("hello", "HELLO"),
    ("world", "WORLD"),
    ("", ""),
    ("Python 3", "PYTHON 3"),
])
def test_uppercase(input, expected):
    assert input.upper() == expected

# Multiple parameters
@pytest.mark.parametrize("a,b,result", [
    (1, 2, 3),
    (-1, 1, 0),
    (0, 0, 0),
    (100, -50, 50),
])
def test_add(a, b, result):
    assert a + b == result

Mocking with unittest.mock

from unittest.mock import Mock, patch, MagicMock
import pytest

# Mock an object
def send_notification(user, message, emailer):
    emailer.send(user.email, message)

def test_send_notification():
    user = Mock()
    user.email = "alice@example.com"

    emailer = Mock()
    send_notification(user, "Hello!", emailer)

    emailer.send.assert_called_once_with("alice@example.com", "Hello!")

patch() as a Decorator

# myapp/orders.py
import requests

def get_order(order_id):
    response = requests.get(f"https://api.example.com/orders/{order_id}")
    return response.json()

# tests/test_orders.py
from unittest.mock import patch

@patch("myapp.orders.requests.get")
def test_get_order(mock_get):
    mock_get.return_value.json.return_value = {"id": 1, "item": "book"}

    result = get_order(1)

    assert result == {"id": 1, "item": "book"}
    mock_get.assert_called_once_with("https://api.example.com/orders/1")

patch() as a Context Manager

from unittest.mock import patch

def test_file_processing():
    with patch("builtins.open", mock_open(read_data="line1\nline2")):
        result = process_file("any_path.txt")
    assert result == ["line1", "line2"]

Coverage

pip install pytest-cov

pytest --cov=myapp --cov-report=term-missing
pytest --cov=myapp --cov-report=html   # generates htmlcov/index.html

.coveragerc to configure:

[run]
source = myapp
omit =
    */migrations/*
    */tests/*
    */conftest.py

[report]
fail_under = 80

unittest (for Completeness)

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        self.calc = Calculator()

    def tearDown(self):
        pass

    def test_add(self):
        self.assertEqual(self.calc.add(2, 3), 5)

    def test_divide_by_zero(self):
        with self.assertRaises(ValueError):
            self.calc.divide(10, 0)

    def test_approximate(self):
        self.assertAlmostEqual(self.calc.sqrt(2), 1.41421, places=4)

if __name__ == "__main__":
    unittest.main()

Testing Async Code

import pytest
import asyncio

@pytest.mark.asyncio
async def test_async_function():
    result = await fetch_data("https://example.com")
    assert result["status"] == "ok"

# Install: pip install pytest-asyncio
# Configure in pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto"

Test Organization

myapp/
├── src/
│   └── myapp/
│       ├── auth.py
│       └── orders.py
└── tests/
    ├── conftest.py
    ├── unit/
    │   ├── test_auth.py
    │   └── test_orders.py
    └── integration/
        └── test_api.py

Frequently Asked Questions

Should I use pytest or unittest?
pytest is the industry standard for new projects. It's more expressive, has better output, and works seamlessly with unittest-style tests. Use unittest only when you can't add dependencies.
What's the difference between a mock and a stub?
A stub returns canned data. A mock also tracks calls — you can assert it was called with specific arguments. Python's unittest.mock.Mock does both.
How much test coverage should I aim for?
100% coverage doesn't mean bug-free. Aim for 80-90% coverage on critical paths. Coverage is a floor, not a ceiling — focus on testing behavior, not lines.