Unit Testing in Python with pytest: Complete Guide
Unit tests verify that each function or class in your code works correctly in isolation. pytest is Python's most popular testing framework: simpler than unittest, with better output and a rich plugin ecosystem.
1. Installation and first test
pip install pytest pytest-cov
# calculator.py
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
# test_calculator.py
from calculator import add, divide, is_prime
import pytest
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_divide_normal():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
def test_is_prime():
assert is_prime(2)
assert is_prime(7)
assert not is_prime(1)
assert not is_prime(9)
pytest # Run all tests
pytest -v # Verbose output
pytest test_calculator.py::test_add_positive # Single test
2. Parametrize: multiple cases with one test
import pytest
from calculator import add, is_prime
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
(1.5, 2.5, 4.0),
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected
@pytest.mark.parametrize("n,expected", [
(2, True),
(3, True),
(4, False),
(17, True),
(25, False),
(97, True),
])
def test_is_prime_parametrized(n, expected):
assert is_prime(n) == expected
3. Fixtures: shared setup and teardown
import pytest
import tempfile
from pathlib import Path
@pytest.fixture
def temp_file():
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write("line 1\nline 2\nline 3\n")
path = Path(f.name)
yield path # Test receives the path here
path.unlink() # Cleanup after test
def test_read_file(temp_file):
content = temp_file.read_text()
assert "line 1" in content
assert content.count("\n") == 3
@pytest.fixture(scope="module")
def db_connection():
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
conn.commit()
yield conn
conn.close()
def test_find_user(db_connection):
cursor = db_connection.execute("SELECT name FROM users WHERE id=1")
assert cursor.fetchone()[0] == "Alice"
4. Mocks with unittest.mock
import pytest
from unittest.mock import patch, MagicMock
def get_btc_price(api_client):
resp = api_client.get("https://api.example.com/btc")
return resp.json()["price"]
def test_get_btc_price():
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = {"price": 45000.0}
mock_client.get.return_value = mock_response
price = get_btc_price(mock_client)
assert price == 45000.0
mock_client.get.assert_called_once_with("https://api.example.com/btc")
import requests
def fetch_data(url):
return requests.get(url).json()
def test_fetch_data():
with patch("requests.get") as mock_get:
mock_get.return_value.json.return_value = {"result": "ok"}
data = fetch_data("https://api.example.com/data")
assert data["result"] == "ok"
def test_network_error():
with patch("requests.get", side_effect=ConnectionError("No network")):
with pytest.raises(ConnectionError):
fetch_data("https://api.example.com")
5. tmp_path fixture for file tests
def process_text(input_path, output_path):
content = open(input_path).read()
open(output_path, "w").write(content.upper())
def test_process_text(tmp_path):
# tmp_path: built-in pytest fixture → unique temp dir per test
inp = tmp_path / "input.txt"
out = tmp_path / "output.txt"
inp.write_text("hello world")
process_text(str(inp), str(out))
assert out.exists()
assert out.read_text() == "HELLO WORLD"
6. Markers and test groups
import pytest
@pytest.mark.slow
def test_slow_operation():
import time
time.sleep(2)
assert True
@pytest.mark.skip(reason="Not yet implemented")
def test_not_ready():
pass
@pytest.mark.xfail(reason="Known bug #123")
def test_with_known_bug():
assert 1 == 2 # Expected failure
pytest -m slow # Only slow tests
pytest -m "not slow" # Exclude slow tests
pytest -v --tb=short # Compact traceback
7. Code coverage
pytest --cov=my_module --cov-report=term-missing
pytest --cov=my_module --cov-report=html # Open htmlcov/index.html
# pytest.ini
[pytest]
addopts = --cov=my_app --cov-report=term-missing --cov-fail-under=80
testpaths = tests
8. conftest.py: shared fixtures
# tests/conftest.py — auto-loaded by pytest
import pytest
@pytest.fixture(scope="session")
def test_data():
return {
"user": {"id": 1, "name": "Alice", "email": "alice@example.com"},
"product": {"id": 10, "price": 29.99},
}
@pytest.fixture
def sample_dir(tmp_path):
(tmp_path / "images").mkdir()
(tmp_path / "docs").mkdir()
(tmp_path / "images" / "photo.jpg").write_bytes(b"FAKE_JPEG")
return tmp_path
9. Testing classes
# processor.py
class FileProcessor:
def __init__(self, valid_ext=".txt"):
self.valid_ext = valid_ext
self.processed = []
def validate(self, name):
return name.endswith(self.valid_ext)
def process(self, name):
if not self.validate(name):
raise ValueError(f"Invalid extension: {name}")
self.processed.append(name)
return f"OK: {name}"
# test_processor.py
import pytest
from processor import FileProcessor
class TestFileProcessor:
@pytest.fixture(autouse=True)
def setup(self):
self.proc = FileProcessor(".txt")
def test_valid_extension(self):
assert self.proc.validate("doc.txt")
def test_invalid_extension(self):
assert not self.proc.validate("image.jpg")
def test_process_valid(self):
result = self.proc.process("note.txt")
assert result == "OK: note.txt"
assert "note.txt" in self.proc.processed
def test_process_invalid(self):
with pytest.raises(ValueError, match="Invalid extension"):
self.proc.process("image.png")
10. Best practices
- One concept per test: multiple asserts are fine if they test the same thing.
- Descriptive names:
test_divide_raises_when_divisor_is_zerobeatstest2. - Independent tests: each test must run alone regardless of order.
- No complex logic in tests: if the test is hard to understand, refactor the code.
- Fast tests: use mocks to isolate external dependencies.
- Aim for ≥ 80% coverage: 100% is not always practical.
- Run in CI/CD on every push to catch regressions automatically.
Related conversions
Frequent conversions across the catalogue: