63.3 pytest: Writing Tests Without Boilerplate
Alright, let’s talk about pytest. If unittest is the formal, three-piece-suit-wearing bureaucrat of the testing world, pytest is the brilliant, leather-jacketed hacker who gets the job done with half the code and twice the style. It doesn’t require you to subclass anything, meaning your test code can be… well, just code. It’s less about ceremony and more about results. You’re going to wonder how you ever lived without it.
The Bare Minimum: It’s Just a Function
The core premise of pytest is breathtakingly simple: if you write a function that starts with test_, and you put it in a file that starts with test_ or ends with _test.py, pytest will find it and run it. No inheritance, no special classes, just logic and assertions.
# test_my_awesome_module.py
def my_awesome_function(x):
return x * 2
def test_my_awesome_function_doubles_numbers():
result = my_awesome_function(4)
assert result == 8
Run it with pytest in your terminal. See? No TestCase, no self.assertWhatever(). You just use the assert statement. This is pytest’s secret weapon. It leverages the raw power of Python’s built-in assert and then, when the assertion fails, it performs absolute magic to show you exactly what went wrong. Compare a unittest failure message to a pytest one and you’ll never go back.
Fixtures: Your Test Sidekicks
Here’s where pytest graduates from “neat” to “indispensable.” You will inevitably have to set up state for your tests—a database connection, a mock object, a complex data structure. Doing this in every single test function is a recipe for messy, repetitive code.
pytest fixtures are the answer. You define a function with the @pytest.fixture decorator, and then you simply add that function’s name as a parameter to any test that needs it. pytest handles the rest, calling the fixture and injecting its return value into your test.
import pytest
class Database:
def __init__(self):
self.connected = False
def connect(self):
self.connected = True
def disconnect(self):
self.connected = False
@pytest.fixture
def database():
# Setup: create and connect to the DB
db = Database()
db.connect()
yield db # This is where the test runs
# Teardown: disconnect. This runs no matter what.
db.disconnect()
def test_database_connection(database): # pytest injects the fixture here
assert database.connected is True
The yield is the genius part. The code before it is setup. yield db passes the fixture value to the test. The code after it is teardown, which runs even if the test fails. This ensures you don’t leak resources. It’s clean, modular, and brilliant.
Parametrization: Don’t Repeat Yourself, Test Everything
What if you want to test the same function with a bunch of different inputs and expected outputs? Without parametrization, you’d write a separate test for each case or have a messy loop inside one test. pytest gives you a better way: @pytest.mark.parametrize.
import pytest
def is_even(number):
return number % 2 == 0
@pytest.mark.parametrize("test_input, expected", [
(2, True),
(1, False),
(0, True), # Edge case
(-2, True), # Negative numbers
(1.0, False), # Wait, a float? Let's see what happens...
])
def test_is_even(test_input, expected):
assert is_even(test_input) == expected
This one decorator generates multiple test cases. pytest will run test_is_even once for each tuple, feeding the values into the arguments. It’s an incredibly powerful and concise way to exhaustively test edge cases and ensure your function behaves across a wide range of inputs. The test output will even show you which specific parameter set failed, saving you hours of debugging.
Best Practices and Rough Edges
- Fixture Scope: By default, a fixture runs once per test function that uses it (
scope="function"). This is safe but can be slow. You can change the scope to"class","module", or"session"to have it run less frequently. Be careful withsession-scoped fixtures—mutating shared state between tests is a fantastic way to create bizarre, order-dependent test failures. - Conftest.py: This is your best friend. Place fixtures that need to be shared across multiple test files in a
conftest.pyfile.pytestwill automatically discover them and make them available everywhere. It keeps your test code DRY (Don’t Repeat Yourself). - The
tmp_pathFixture: Need a temporary file or directory?pytestprovides a built-intmp_pathfixture that gives you a uniquepathlib.Pathobject that’s automatically cleaned up after the test. Use it. Never hardcode temporary file paths in your tests. - The Rough Edge: Sometimes
pytest’s magic can be a bit too magical. Its sophisticated assertion rewriting can interact poorly with other decorators or tools. It’s rare, but if you see a bizarre error about introspection, that’s usually the culprit. 99.9% of the time, though, it just works flawlessly.
pytest respects your intelligence. It gives you simple, composable tools and gets out of your way. It assumes you want to write clean, effective code, not adhere to a rigid framework. Once you get a taste of it, writing tests any other way feels like trying to run a race with weights on your ankles.