Pytest Fixtures Tutorial: Setup, Teardown, Scope and conftest.py
A pytest fixture is a function marked with @pytest.fixture that builds something your tests need; a test receives it simply by naming it as a parameter. Here is the whole idea:
import pytest
@pytest.fixture
def cart():
return {"apple": 2, "banana": 3}
def test_total_items(cart):
assert sum(cart.values()) == 5
def test_has_apple(cart):
assert "apple" in cart
Save it as test_cart.py and run python -m pytest -q:
.. [100%]
2 passed in 0.01s
pytest saw that test_total_items asks for an argument called cart, found a fixture with that name, called it, and passed the result in. Each test got its own fresh dictionary.
A quick note before we go further: pytest is a third-party package (pip install pytest) and its test runner needs a normal Python install on your machine. The examples in this post were all run that way.
Why fixtures instead of setup methods?
If you learned testing with unittest, you know setUp and tearDown methods. They work, but every test in the class gets the same setup whether it needs it or not, and sharing setup between classes means inheritance.
Fixtures fix three things:
- Explicit dependencies. A test lists what it needs in its signature. Reading
def test_login(db, user)tells you exactly what is set up. - Only what you ask for. A test that does not name
dbnever opens a database. - Composable. Fixtures can depend on other fixtures, so small pieces combine into bigger ones.
Fixture scope: function, class, module, session
By default a fixture runs once per test (scope="function"). For expensive setup, widen the scope:
import pytest
@pytest.fixture(scope="module")
def config():
print("\nloading config")
return {"db": "test.db", "retries": 3}
def test_db_name(config):
assert config["db"] == "test.db"
def test_retries(config):
assert config["retries"] == 3
Run it with python -m pytest -q -s (-s shows prints) and "loading config" appears once, even though two tests use it:
loading config
..
2 passed in 0.01s
The scopes, from narrowest to widest:
function(default): fresh for every test.class: once per test class.module: once per test file.package: once per package directory.session: once for the whole test run.
Rule of thumb: use the narrowest scope that is fast enough. A shared, mutable object in a wide scope lets one test leak state into another, which is the source of tests that pass alone and fail together.
Teardown with yield
Most real setup needs cleanup: close the connection, delete the file. Write the fixture with yield instead of return. Everything before yield is setup, everything after is teardown:
import pytest
@pytest.fixture
def resource():
print("\nsetup")
yield "handle"
print("teardown")
def test_uses_resource(resource):
print("test body")
assert resource == "handle"
With -s, the order is exactly what you would hope:
setup
test body
.teardown
1 passed in 0.01s
(The dot is pytest marking the test as passed, printed before teardown runs.) Teardown still runs if the test fails, so this is the right home for any close() call. It is also the answer to the common search "pytest yield": there is no separate teardown decorator to learn.
Share fixtures with conftest.py
Once two test files need the same fixture, move it into a file named conftest.py. pytest loads it automatically; test files never import it.
# conftest.py
import pytest
@pytest.fixture
def user():
return {"name": "Asha", "role": "student"}
@pytest.fixture
def logged_in_user(user):
return {**user, "token": "abc123"}
# test_accounts.py (same folder, no imports needed)
def test_role(user):
assert user["role"] == "student"
def test_token(logged_in_user):
assert logged_in_user["name"] == "Asha"
assert logged_in_user["token"] == "abc123"
Both tests pass. Notice logged_in_user takes user as a parameter: fixtures can use other fixtures exactly the way tests do. pytest works out the order and builds user first.
A conftest.py applies to its own directory and everything below it, so you can keep project-wide fixtures at the top and specialised ones deeper in the tree.
Built-in fixtures you will use every week
pytest ships with fixtures you can request by name without defining them. Three worth knowing now:
import os
def save_report(folder, text):
path = folder / "report.txt"
path.write_text(text)
return path
def get_env():
return os.environ.get("APP_ENV", "production")
def greet(name):
print(f"Hello, {name}!")
def test_save_report(tmp_path):
path = save_report(tmp_path, "all good")
assert path.read_text() == "all good"
def test_env(monkeypatch):
monkeypatch.setenv("APP_ENV", "testing")
assert get_env() == "testing"
def test_greet(capsys):
greet("Ravi")
captured = capsys.readouterr()
assert captured.out == "Hello, Ravi!\n"
tmp_pathgives each test a fresh temporary directory as apathlib.Path. No more tests writing into your project folder.monkeypatchchanges environment variables, attributes or dictionary items for one test and restores them afterwards.capsyscaptures what was printed, so you can assert on output.
All three tests pass.
parametrize vs parametrized fixtures
Two tools with similar names do different jobs.
@pytest.mark.parametrize runs one test with several sets of inputs. Use it for data tables. A fixture with params= runs every test that uses the fixture once per value. Use it when the setup itself varies, such as the same tests against two backends.
import pytest
@pytest.mark.parametrize("marks, grade", [(92, "A"), (75, "B"), (40, "F")])
def test_grade(marks, grade):
assert to_grade(marks) == grade
def to_grade(marks):
if marks >= 90:
return "A"
if marks >= 70:
return "B"
return "F"
@pytest.fixture(params=["sqlite", "memory"])
def backend(request):
return request.param
def test_backend_name(backend):
assert backend in ("sqlite", "memory")
This file produces five tests. With -v you can see their IDs:
test_grade[92-A] PASSED
test_grade[75-B] PASSED
test_grade[40-F] PASSED
test_backend_name[sqlite] PASSED
test_backend_name[memory] PASSED
autouse: use sparingly
autouse=True applies a fixture to every test in scope without anyone asking for it:
import random
import pytest
@pytest.fixture(autouse=True)
def fixed_seed():
random.seed(42)
def test_first_roll():
assert random.randint(1, 6) == 6
def test_same_again():
assert random.randint(1, 6) == 6
Both pass, because the seed is reset before each test. This is reasonable for global housekeeping like seeding random numbers. It becomes a problem when autouse fixtures do real work: tests get slower and nobody reading a test can see what set it up. Prefer explicit parameters.
A realistic example: a temporary SQLite database
This is the pattern you will use in actual projects. The db fixture builds on tmp_path, creates a table, hands the connection to the test and closes it afterwards. Every test starts with an empty database.
import sqlite3
import pytest
def add_student(conn, name, marks):
conn.execute("INSERT INTO students (name, marks) VALUES (?, ?)", (name, marks))
conn.commit()
def toppers(conn, cutoff):
rows = conn.execute(
"SELECT name FROM students WHERE marks >= ? ORDER BY marks DESC", (cutoff,)
)
return [name for (name,) in rows]
@pytest.fixture
def db(tmp_path):
conn = sqlite3.connect(tmp_path / "test.db")
conn.execute("CREATE TABLE students (name TEXT, marks INTEGER)")
yield conn
conn.close()
def test_empty_table(db):
assert toppers(db, 0) == []
def test_toppers(db):
add_student(db, "Asha", 91)
add_student(db, "Ravi", 78)
add_student(db, "Meera", 88)
assert toppers(db, 85) == ["Asha", "Meera"]
.. [100%]
2 passed in 0.09s
test_empty_table passes regardless of which test runs first, because each gets its own file in its own temporary directory. That isolation is the whole point. You could pass ":memory:" to sqlite3.connect instead for an in-memory database; the file version is shown because it mirrors how most apps connect.
Common mistakes
- Calling a fixture directly.
cart()inside a test fails withFixture "cart" called directly. Fixtures are not meant to be called directly. Request it as a parameter instead. - Misspelling the parameter. A test asking for
cartswhen the fixture iscarterrors with "fixture 'carts' not found". The name is the link. - Using
returnwhen you need cleanup. Anything after areturnnever runs. Switch toyield. - Mutating a wide-scope fixture. If a
sessionfixture returns a list and one test appends to it, every later test sees the change. - Putting
conftest.pyin the wrong folder. It only covers its own directory and subdirectories. - Hiding too much in autouse. If you cannot tell from a test's signature what it depends on, debugging gets slow.
Practise it
Start with the pytest basics lesson if assert-style tests are new, then the pytest fixtures lesson, where you write fixtures in graded exercises in the browser. The SQLite basics lesson covers the sqlite3 side of the example above.
For quick experiments with the functions under test, the browser editor and the Python terminal run Python with no install. The first five lessons are free.