Testing with pytest

Python Intermediate — 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.