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.