Testing gives you confidence that code works and keeps working as you change it. Quality tools enforce consistency across teams.

unittest — Built-In Framework

  # math_utils.py
def add(a, b):
    return a + b

def divide(a, b):
    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(2, 3), 5)

    def test_add_negative(self):
        self.assertEqual(add(-1, 1), 0)

    def test_divide(self):
        self.assertAlmostEqual(divide(10, 3), 3.333, places=2)

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

    def setUp(self):
        self.data = [1, 2, 3, 4, 5]

    def test_data_length(self):
        self.assertEqual(len(self.data), 5)

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

Run: python -m unittest test_math_utils.py -v

pytest — Modern Testing

  pip install pytest
  
  # test_math.py
import pytest
from math_utils import add, divide

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

def test_divide():
    assert divide(10, 2) == 5

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

Run: pytest test_math.py -v

Fixtures

  @pytest.fixture
def sample_data():
    return {"users": ["Alice", "Bob"], "count": 2}

def test_user_count(sample_data):
    assert sample_data["count"] == len(sample_data["users"])

@pytest.fixture
def db_connection():
    conn = create_test_database()
    yield conn
    conn.close()
  

Parametrize

  @pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
])
def test_add_parametrized(a, b, expected):
    assert add(a, b) == expected
  

Mocking

Replace dependencies during tests:

  from unittest.mock import patch, MagicMock

@patch("myapp.api.requests.get")
def test_fetch_data(mock_get):
    mock_get.return_value = MagicMock(
        status_code=200,
        json=lambda: {"data": "test"},
    )
    result = fetch_data("https://api.example.com")
    assert result == {"data": "test"}
    mock_get.assert_called_once()
  

Test Coverage

  pip install pytest-cov
pytest --cov=myapp --cov-report=html tests/
  

Aim for high coverage on critical paths — 100% is not always necessary, but untested error handling often causes production bugs.

Code Quality Tools

Linting with flake8

  pip install flake8
flake8 src/ --max-line-length=88
  

Formatting with black

  pip install black
black src/
  

Import sorting with isort

  pip install isort
isort src/
  

Type checking with mypy

  pip install mypy
mypy src/
  

Pre-commit Hooks

Automate checks before every commit:

  pip install pre-commit
  
  # .pre-commit-config.yaml
repos:
  - repo: https://github.com/psf/black
    rev: 24.3.0
    hooks:
      - id: black
  - repo: https://github.com/pycqa/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
  - repo: https://github.com/pycqa/isort
    rev: 5.13.2
    hooks:
      - id: isort
  
  pre-commit install
  

Testing Best Practices

  1. Test behavior, not implementation — tests should survive refactors
  2. One assertion concept per test — clear failure messages
  3. Use descriptive test namestest_divide_by_zero_raises_error
  4. Keep tests fast — mock external services
  5. Run tests in CI — every pull request
  6. Test edge cases — empty inputs, None, boundary values

Project Test Structure

  myapp/
├── src/myapp/
│   ├── models.py
│   └── services.py
└── tests/
    ├── conftest.py       # shared fixtures
    ├── test_models.py
    └── test_services.py
  

Testing is not optional in professional Python development — it’s how you move fast without breaking things.

For production-scale patterns, see Advanced Testing.