Python Testing and Quality Assurance
Reliable software requires automated tests and consistent code quality. Python provides built-in testing tools and a rich ecosystem of linters, formatters, and coverage reporters.
Why Test?
- Catch regressions before they reach production
- Document behavior — tests show how code is meant to work
- Enable refactoring — change internals confidently when tests pass
- Speed up reviews — CI runs tests on every pull request
A practical testing pyramid for Python services:
┌─────────┐
│ E2E │ few, slow
├─────────┤
│ Integr. │ some
├─────────┤
│ Unit │ many, fast
└─────────┘
unittest — Built-In Framework
unittest follows the xUnit pattern: test classes inherit from TestCase, methods start with test_.
# math_utils.py
def add(a: int, b: int) -> int:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# test_math_utils.py
import unittest
from math_utils import add, divide
class TestMathUtils(unittest.TestCase):
def test_add_positive(self):
self.assertEqual(add(1, 2), 3)
def test_add_negative(self):
self.assertEqual(add(-1, 1), 0)
def test_divide_by_zero(self):
with self.assertRaises(ValueError):
divide(10, 0)
if __name__ == "__main__":
unittest.main()
Run tests:
python -m unittest discover -v
# test_add_negative ... ok
# test_add_positive ... ok
# test_divide_by_zero ... ok
pytest — Preferred for Most Projects
pytest discovers tests automatically, supports fixtures, and produces clear failure output.
# test_math_pytest.py
import pytest
from math_utils import add, divide
def test_add():
assert add(1, 2) == 3
assert add(-1, 1) == 0
def test_divide():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
Run with pytest:
pip install pytest
pytest -v
Fixtures
Fixtures set up and tear down shared test state:
# test_user_service.py
import pytest
class UserService:
def __init__(self):
self.users = {}
def create(self, name: str) -> int:
user_id = len(self.users) + 1
self.users[user_id] = name
return user_id
def get(self, user_id: int) -> str | None:
return self.users.get(user_id)
@pytest.fixture
def service():
return UserService()
def test_create_and_get(service):
uid = service.create("Alice")
assert service.get(uid) == "Alice"
def test_get_missing(service):
assert service.get(999) is None
Parameterized Tests
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected
Mocking External Dependencies
Use unittest.mock or pytest-mock to isolate units from databases and APIs:
from unittest.mock import patch, MagicMock
import requests
def fetch_title(url: str) -> str:
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.json()["title"]
@patch("requests.get")
def test_fetch_title(mock_get):
mock_get.return_value = MagicMock(
status_code=200,
json=lambda: {"title": "Example"}
)
assert fetch_title("https://api.example.com") == "Example"
mock_get.assert_called_once()
Code Coverage
Measure which lines your tests execute:
pip install pytest-cov
pytest --cov=math_utils --cov-report=term-missing
Aim for high coverage on business logic; 100% coverage is not always necessary, but untested critical paths are a risk.
Linting
Linters catch bugs, style violations, and unused imports before runtime.
flake8 — combines pyflakes, pycodestyle, and mccabe complexity:
pip install flake8
flake8 src/ tests/
ruff — fast, all-in-one linter (recommended for new projects):
pip install ruff
ruff check src/
ruff format src/ # also formats
Example pyproject.toml configuration:
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
Formatting
Consistent formatting eliminates style debates in code review.
black — opinionated formatter:
pip install black
black src/ tests/
isort — sorts imports (or use ruff’s import sorting):
isort src/
Pre-Commit Hooks
Automate quality checks before every commit:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
- id: ruff
- id: ruff-format
- repo: local
hooks:
- id: pytest
name: pytest
entry: pytest
language: system
pass_filenames: false
Install: pip install pre-commit && pre-commit install
CI Integration
Run tests on every push:
# .github/workflows/test.yml (excerpt)
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- run: pytest --cov=src --cov-fail-under=80
- run: ruff check src/
Summary
| Task | Command |
|---|---|
| Run unittest | python -m unittest discover -v |
| Run pytest | pytest -v |
| Coverage | pytest --cov=src |
| Lint | ruff check src/ |
| Format | black src/ or ruff format src/ |
Testing and quality tools are not optional extras — they are core to maintainable Python development. Apply them from the first module you write.