Testing with pytest
๐งช Testing with pytest
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.
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 structure | Class-based, inheriting from TestCase | Plain functions โ no class or inheritance required |
| Assertions | self.assertEqual(a, b), self.assertTrue(x), a whole API of methods to remember | Plain assert โ one keyword covers everything |
| Setup/teardown | setUp()/tearDown() methods | Fixtures with @pytest.fixture, more flexible and reusable |
| Multiple inputs | Manual 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.
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.
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.
๐ 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.