pytest, the ubiquitous Python testing framework, isn’t just about running tests; it’s a powerful tool for understanding and shaping your code’s behavior.
Let’s see it in action. Imagine you have a simple Python file named my_math.py with a function you want to test:
# my_math.py
def add(x, y):
return x + y
def subtract(x, y):
return x - y
To test this, create a file named test_my_math.py in the same directory. The naming convention is crucial: files starting with test_ or ending with _test.py are automatically discovered by pytest.
# test_my_math.py
from my_math import add, subtract
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -1) == -2
def test_subtract_positive_numbers():
assert subtract(5, 2) == 3
Now, open your terminal, navigate to the directory containing these two files, and simply run:
pytest
Pytest will discover test_my_math.py, find the functions starting with test_, execute them, and report the results.
============================= test session starts ==============================
platform linux -- Python 3.8.10, pytest-7.1.2, pluggy-1.0.0
rootdir: /path/to/your/project
collected 3 items
test_my_math.py ... [100%]
============================== 3 passed in 0.01s ===============================
This basic setup illustrates the core principle: write simple functions that use assert statements to check expected outcomes. Pytest’s magic lies in its discovery mechanism and its ability to report failures clearly.
The real power of pytest comes from its fixture system, which allows you to set up and tear down test environments. Consider a scenario where you need to interact with a database. Instead of initializing the database connection in every test, you can use a fixture.
# conftest.py (for shared fixtures)
import pytest
import sqlite3
@pytest.fixture(scope="session")
def db_connection():
conn = sqlite3.connect(":memory:") # In-memory SQLite database
cursor = conn.cursor()
cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
conn.commit()
yield conn # Provide the connection to the tests
conn.close() # Close the connection after all tests in the session are done
# test_database.py
def test_add_user(db_connection):
cursor = db_connection.cursor()
cursor.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
db_connection.commit()
cursor.execute("SELECT name FROM users WHERE name = ?", ("Alice",))
result = cursor.fetchone()
assert result is not None
assert result[0] == "Alice"
In conftest.py, db_connection is a fixture. scope="session" means this fixture is created once for the entire test run. The yield keyword is crucial: everything before yield is setup, and everything after yield is teardown. Here, we create an in-memory SQLite database, create a users table, and then yield the connection. After all tests using this fixture complete, conn.close() is called.
In test_database.py, test_add_user simply requests the db_connection fixture by including its name as an argument. Pytest injects the active connection, allowing the test to interact with the database.
The mental model for pytest fixtures is: Dependency Injection for Tests. You declare what you need as function arguments, and pytest provides it. This makes tests independent, reusable, and easier to manage.
One thing most people don’t know is how powerful the request fixture is within other fixtures. It provides access to the requesting test function’s metadata, allowing for dynamic fixture behavior. For instance, you could use request.param to drive parametrized tests directly from a fixture.
The next concept you’ll likely explore is parametrization, which lets you run the same test logic with different inputs without duplicating code.