Sunday, 7 September 2025

Python 3 Unit Testing

Python 3 · Testing

Learn how to write fast, reliable tests using the built-in unittest and the popular pytest framework—plus fixtures, mocking, parameterization, coverage, and CI.

1) Why Unit Testing?

Unit tests check small, isolated pieces of code (functions/classes) to catch bugs early. They also document behavior and enable safe refactoring. With a fast test suite, you’ll ship changes with confidence.

2) A Clean Project Structure

Recommended layout

myapp/
├─ src/
│  └─ myapp/
│     ├─ __init__.py
│     ├─ mathy.py
│     └─ io_helpers.py
├─ tests/
│  ├─ test_mathy.py
│  └─ test_io_helpers.py
├─ pyproject.toml
└─ README.md

Tip: Put application code in src/myapp and tests in tests/. Tools like pytest discover tests named test_*.py.

Example code (to be tested)

# src/myapp/mathy.py
def add(a: float, b: float) -> float:
    return a + b

def divide(a: float, b: float) -> float:
    if b == 0:
        raise ZeroDivisionError("b must not be zero")
    return a / b

3) The Built-in unittest (Batteries Included)

unittest ships with Python 3—no install needed.

# tests/test_mathy_unittest.py
import unittest
from myapp.mathy import add, divide

class TestMathy(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)
        self.assertAlmostEqual(add(0.1, 0.2), 0.3, places=7)

    def test_divide_ok(self):
        self.assertEqual(divide(10, 2), 5.0)

    def test_divide_by_zero(self):
        with self.assertRaises(ZeroDivisionError) as cm:
            divide(1, 0)
        self.assertIn("must not be zero", str(cm.exception))

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

Run: python -m unittest or python -m unittest tests/test_mathy_unittest.py

4) pytest (Fast, Friendly, Powerful)

Install with pip install pytest then run pytest. It offers concise tests, rich output, and powerful plugins.

# tests/test_mathy_pytest.py
from myapp.mathy import add, divide
import pytest

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

def test_divide_ok():
    assert divide(9, 3) == 3.0

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)

Run: pytest -q

5) Fixtures: Reusable Setup/Teardown

Fixtures provide test data or resources and clean up automatically.

# tests/conftest.py
import tempfile, shutil, pathlib, pytest

@pytest.fixture
def temp_dir():
    d = tempfile.mkdtemp()
    try:
        yield pathlib.Path(d)
    finally:
        shutil.rmtree(d)

# tests/test_io_helpers.py
def test_write_and_read(temp_dir):
    p = temp_dir / "hello.txt"
    p.write_text("hi")
    assert p.read_text() == "hi"

Tip: Place shared fixtures in tests/conftest.py for auto-discovery.

6) Mocking with unittest.mock

Mock external calls (network, file system, time) so tests are fast and deterministic.

# src/myapp/io_helpers.py
import requests

def fetch_title(url: str) -> str:
    r = requests.get(url, timeout=5)
    r.raise_for_status()
    return r.text.split("<title>")[1].split("</title>")[0]

# tests/test_io_helpers_mock.py
from unittest.mock import patch, MagicMock
from myapp.io_helpers import fetch_title

@patch("myapp.io_helpers.requests.get")
def test_fetch_title_mocks_requests(mock_get):
    fake = MagicMock()
    fake.text = "<html><title>Hello</title></html>"
    fake.raise_for_status = lambda: None
    mock_get.return_value = fake

    assert fetch_title("https://example.com") == "Hello"
    mock_get.assert_called_once()

Rule of thumb: Patch where the object is used, not where it’s defined.

7) Parameterization (More Cases, Less Boilerplate)

# tests/test_mathy_param.py
import pytest
from myapp.mathy import add

@pytest.mark.parametrize(
    "a,b,expected",
    [
        (0, 0, 0),
        (2, 3, 5),
        (-1, 5, 4),
        (0.1, 0.2, 0.3),
    ],
)
def test_add_param(a, b, expected):
    assert add(a, b) == pytest.approx(expected)

Why it rocks: Easier to read, great failure messages, and one test turns into many.

8) Coverage: Know What You Tested

Install pip install coverage, then:

# with pytest
coverage run -m pytest
coverage report -m
coverage html  # open htmlcov/index.html

Aim for meaningful coverage (e.g., 80%+), without gaming the metric.

9) A Tiny TDD Loop

  1. Write a small failing test (red).
  2. Implement the simplest code to pass (green).
  3. Refactor for clarity/perf while keeping tests green.
# 1) failing test first
def test_is_even():  # tests/test_even.py
    from myapp.mathy import is_even
    assert is_even(2) is True
    assert is_even(3) is False

# 2) minimal implementation
# src/myapp/mathy.py
def is_even(n: int) -> bool:
    return (n % 2) == 0

10) Run Tests on Every Push (CI)

Here’s a minimal GitHub Actions workflow to run tests on Python 3.10–3.12 and generate coverage.

# .github/workflows/tests.yml
name: tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: ${{ matrix.python-version }} }
      - run: python -m pip install --upgrade pip
      - run: pip install -e . pytest coverage
      - run: coverage run -m pytest -q
      - run: coverage report -m

Pro tip: Fail the build if coverage drops using coverage report --fail-under=80.

11) Assertions & Cheatsheet

unittest assertions

AssertionUse
assertEqual(a, b)Exact equality
assertAlmostEqual(a, b, places=7)Floats
assertTrue(x) / assertFalse(x)Truthiness
assertIn(x, seq)Membership
assertIsNone(x)None checks
assertRaises(E)Exceptions

pytest power-ups

FeatureExample
Simple assertsassert func(x) == y
Parameterize@pytest.mark.parametrize(...)
Fixtures@pytest.fixture + autouse
Marks@pytest.mark.slow, -m "not slow"
Skip/xfailpytest.skip(), @pytest.mark.xfail

12) FAQ

Do I need both unittest and pytest?

No. Many teams use just pytest for new projects. You can still run legacy unittest tests under pytest.

How many tests should I write?

Focus on critical paths and tricky logic. Aim for high coverage with meaningful scenarios—not just lines executed.

How do I test time, random, or network calls?

Use unittest.mock to patch functions like time.time, random.random, or HTTP clients. Inject dependencies where possible.

Copy-Paste Quickstart

# 1) install
pip install pytest coverage

# 2) create files
mkdir -p src/myapp tests
# add src/myapp/mathy.py and tests/test_mathy_pytest.py (from above)

# 3) run tests + coverage
pytest -q
coverage run -m pytest
coverage report -m

No comments:

Post a Comment