Verifying Code with Tests

Tests are executable specifications: small programs that invoke your code with known inputs and verify the results. They are the fastest feedback loop you get while changing a codebase. Python ships unittest in the standard library, but the de-facto choice in modern projects is pytest, which makes the same tests shorter and the failure messages clearer.

A pytest test is just a function whose name starts with test_ and that uses the plain assert statement. Place tests in files named test_*.py and run pytest in your project root; it discovers everything automatically. The output tells you which tests passed, which failed, and the exact comparison that broke.

Aim for three kinds of tests. Unit tests exercise a single function or class with mocked boundaries; they run in milliseconds. Integration tests wire a few components together against a real local resource (a temporary file, a test database). End-to-end tests drive the whole program from the CLI or HTTP layer. The pyramid principle: many unit tests, fewer integration tests, a handful of end-to-end.

Good tests are fast, deterministic, and focused on behavior rather than implementation. They call the public API, not private helpers. They use tmp_path and fixtures for side effects instead of touching the real filesystem. When they fail, the failure message should point at the mismatch, not just “AssertionError”.

Writing a first test

Create test_math.py, define def test_add_positive_numbers():, and inside write assert add(1, 2) == 3. Run pytest. Each failing assertion shows the expected and actual values side-by-side.

Group related tests in a class (class TestAdd: def test_positive(self): ...). pytest discovers those too, without any inheritance requirement.

Parametrize, fixtures, and mocks

@pytest.mark.parametrize("a,b,expected", [...]) turns one test into many. Use it to cover boundary conditions without copy-pasting the body.

Fixtures are functions decorated with @pytest.fixture that produce a value (or context) for tests to consume. tmp_path (built-in) gives each test its own temp directory; monkeypatch lets you replace attributes temporarily.

Essential testing tools.

ToolPurpose
pytest
tool
Modern test runner and assertion library.
unittest
module
Standard-library test framework.
unittest.mock
module
Mocking and patching helpers.
@pytest.mark.parametrize
decorator
Run the same test with many inputs.
@pytest.fixture
decorator
Inject setup/teardown into tests.
tmp_path
fixture
Unique temp directory per test.
hypothesis
library
Property-based testing with auto-generated inputs.
coverage.py
tool
Measure line coverage from tests.

Verifying Code with Tests code example

The script uses unittest from the standard library (works without installing pytest) to showcase the same patterns.

# Lesson: Verifying Code with Tests
import unittest


def add(a: int, b: int) -> int:
    return a + b


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


class TestAdd(unittest.TestCase):
    def test_positive(self):
        self.assertEqual(add(1, 2), 3)

    def test_negative(self):
        self.assertEqual(add(-5, 2), -3)

    def test_is_commutative(self):
        self.assertEqual(add(1, 7), add(7, 1))


class TestDivide(unittest.TestCase):
    def test_normal(self):
        self.assertAlmostEqual(divide(10, 4), 2.5)

    def test_zero_raises(self):
        with self.assertRaises(ZeroDivisionError):
            divide(1, 0)

    def test_table(self):
        cases = [(10, 2, 5), (9, 3, 3), (7, 1, 7)]
        for a, b, expected in cases:
            with self.subTest(a=a, b=b):
                self.assertEqual(divide(a, b), expected)


if __name__ == "__main__":
    # Run in-process so the lesson prints a clean summary
    loader = unittest.TestLoader()
    suite = loader.loadTestsFromModule(__import__("__main__"))
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(suite)

Things to spot:

1) Each test is small and names one behavior.
2) `assertRaises` encapsulates 'calling this should raise'.
3) `subTest` parametrizes a loop so failures report the specific input.
4) Tests belong with the code they exercise, so the rest of the project can trust it.

Write a pytest equivalent (requires pytest).

# test_add.py — run with: pytest -q
import pytest

def add(a, b):
    return a + b

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

def test_add_is_commutative():
    assert add(1, 7) == add(7, 1)

Small invariants that double as tests.

def abs_diff(a, b):
    return abs(a - b)
assert abs_diff(5, 3) == 2
assert abs_diff(3, 5) == 2
assert abs_diff(0, 0) == 0

Running the unittest demo prints:

test_is_commutative (__main__.TestAdd.test_is_commutative) ... ok
test_negative (__main__.TestAdd.test_negative) ... ok
test_positive (__main__.TestAdd.test_positive) ... ok
test_normal (__main__.TestDivide.test_normal) ... ok
test_table (__main__.TestDivide.test_table) ... ok
test_zero_raises (__main__.TestDivide.test_zero_raises) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.002s

OK