Testing with pytest

Python Intermediate
Course 2 ยท Chapter 8 ยท Testing with pytest

๐Ÿงช Testing with pytest

The final chapter of Python Intermediate: writing automated tests with pytest, the de facto standard testing tool in the Python ecosystem. This covers plain assert-based tests, fixtures for shared setup, parametrize for running one test against many inputs, and how pytest compares to Python's built-in unittest module.

โœ… Why Test? A Quick assert Primer

assert 2 + 2 == 4          # passes silently โ€” nothing happens
assert 2 + 2 == 5, "math is broken"   # raises AssertionError: math is broken

assert is a plain Python keyword โ€” if the condition is true, nothing happens; if it's false, it raises an AssertionError (optionally with a message). pytest builds its entire testing style around this one familiar keyword, rather than introducing a whole new assertion API.

๐Ÿ”ฌ Writing Tests with pytest

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

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

def test_add_negative_numbers():
    assert add(-1, -1) == -2
# run from the command line:
pytest test_math.py

# output:
# test_math.py::test_add_positive_numbers PASSED
# test_math.py::test_add_negative_numbers PASSED

pytest auto-discovers test functions by naming convention alone: any file named test_*.py (or *_test.py), containing functions named test_*, is picked up automatically โ€” no manual test registration, and no base class to inherit from, unlike unittest (see below).

๐Ÿงฉ Fixtures

import pytest

@pytest.fixture
def sample_list():
    return [1, 2, 3]

def test_sum(sample_list):        # pytest injects the fixture by matching the parameter name
    assert sum(sample_list) == 6

def test_length(sample_list):
    assert len(sample_list) == 3

A fixture is a function decorated with @pytest.fixture that provides shared setup for tests โ€” any test function that takes a parameter matching the fixture's name automatically receives its return value. This is pytest's own dependency-injection style, avoiding the repeated setup code every test would otherwise need.

@pytest.fixture
def temp_file():
    f = open("temp.txt", "w")
    yield f       # the test runs here, using f
    f.close()   # cleanup, guaranteed to run after the test finishes

A fixture that uses yield instead of return gets a setup/teardown split โ€” code before yield runs before the test, code after yield runs after, reusing the exact yield-pauses-and-resumes mechanic from Chapter 5's generator functions.

๐Ÿ” parametrize

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

@pytest.mark.parametrize runs the same test function once per tuple of inputs, reported as three separate, individually pass/fail test results โ€” instead of writing three nearly-identical test functions by hand, or looping inside a single test where one failure would hide the others.

โš  Never assert Equality on Floats Directly
assert 0.1 + 0.2 == 0.3   # fails! 0.1 + 0.2 is actually 0.30000000000000004

import pytest
assert 0.1 + 0.2 == pytest.approx(0.3)   # passes โ€” allows for tiny floating-point error

Floating-point numbers can't represent most decimal values exactly, so arithmetic that looks correct on paper often produces a result that's off by a tiny amount โ€” this isn't a Python bug, it's how binary floating-point works in every language. pytest.approx() compares with a small allowed tolerance instead of exact equality, exactly for this situation.

pytest vs unittest

unittest (standard library)pytest (third-party)
Test structureClass-based, inheriting from TestCasePlain functions โ€” no class or inheritance required
Assertionsself.assertEqual(a, b), self.assertTrue(x), a whole API of methods to rememberPlain assert โ€” one keyword covers everything
Setup/teardownsetUp()/tearDown() methodsFixtures with @pytest.fixture, more flexible and reusable
Multiple inputsManual loops or repeated methods@pytest.mark.parametrize

unittest ships with Python itself, so it needs no installation โ€” but pytest (a pip install away, per Course 1, Chapter 9) is the de facto standard in real-world Python projects for a reason: noticeably less boilerplate per test, and both fixtures and parametrize have no clean unittest equivalent.

assert

Plain Python keyword โ€” raises AssertionError if the condition is false.

Test discovery

test_*.py files, test_* functions โ€” found automatically, no registration.

Fixtures

@pytest.fixture โ€” shared setup, injected by matching parameter name.

parametrize

Runs one test function against many input/expected-output pairs.

๐Ÿ’ป Coding Challenges

Challenge 1: Test a String Function

Write a function is_palindrome(s) (reusing the logic from Course 1, Chapter 5's palindrome challenge), then write two pytest test functions for it: one asserting is_palindrome("racecar") is True, one asserting is_palindrome("python") is False.

Goal: Practice writing basic pytest test functions with plain assert.

โ†’ Solution

Challenge 2: A Fixture for Shared Test Data

Write a @pytest.fixture called sample_scores that returns the list [85, 90, 78, 92]. Then write two tests that both use it โ€” one asserting the average is 86.25, one asserting the max is 92.

Goal: Practice sharing setup data across multiple tests using a fixture instead of repeating the list in every test.

โ†’ Solution

Challenge 3: Parametrized FizzBuzz Test

Write a fizzbuzz(n) function (reusing the logic idea from Course 1, Chapter 3's FizzBuzz-style challenge, but returning a string instead of printing), then use @pytest.mark.parametrize to test it against at least 4 different inputs and their expected outputs in a single test function.

Goal: Practice parametrize to cover multiple cases without writing a separate test function for each.

โ†’ Solution

๐ŸŽ“ Course Complete!

That's all 8 chapters of Python Intermediate โ€” OOP, inheritance and dunder methods, exception handling, file I/O, comprehensions and generators, decorators, working with data, and testing with pytest. Next up: Python Advanced (py3), starting with Advanced OOP.