A test suite is only trustworthy if each test’s result depends on the code under test and nothing else. The moment one test can change something another test reads (a module-level dict, an environment variable, a file, a database row), results start depending on order. Then you get the most frustrating failure in testing: it passes when run alone and fails in CI. Fixtures exist to stop that. Each test gets the state it needs, built fresh, and whatever it changes is put back afterwards. Interviewers asking about fixtures are usually checking whether you understand isolation, not whether you’ve memorised decorator syntax.
The runnable examples here use the standard library’s unittest, run with Python 3.14. The pytest snippets show the equivalent idioms; pytest wasn’t installed in the environment used for this article, so those weren’t executed here and are written against pytest’s documented API.
Passing alone, failing together
Here’s a tiny cart module that keeps carts in a module-level dict, and two tests:
# cart.py
_CARTS = {}
def add_item(user, sku):
_CARTS.setdefault(user, []).append(sku)
return _CARTS[user]
def count(user):
return len(_CARTS.get(user, []))# test_cart.py
import unittest
import cart
class CartTests(unittest.TestCase):
def test_add_item(self):
self.assertEqual(cart.add_item("ana", "book"), ["book"])
def test_empty_cart(self):
self.assertEqual(cart.count("ana"), 0)Run the second test by itself, then the whole file:
$ python3 -m unittest test_cart.CartTests.test_empty_cart
Ran 1 test in 0.000s
OK
$ python3 -m unittest test_cart
.F
======================================================================
FAIL: test_empty_cart (test_cart.CartTests.test_empty_cart)
...
AssertionError: 1 != 0unittest runs methods in alphabetical order, so test_add_item goes first and leaves a book in Ana’s cart. test_empty_cart then reads that state. Neither test is wrong by itself; the module-level dict is shared state that nobody resets. pytest runs tests in file order instead, but the problem is identical, and plugins like pytest-randomly shuffle the order on purpose to flush these bugs out.
The useful way to describe this in an interview: the tests aren’t isolated, because they share a mutable global that lives as long as the process. That points straight at the fix: either stop sharing it, or reset it around every test.
Building and undoing state around each test
In unittest, setUp runs before each test and addCleanup registers undo steps that run after it, even if the test fails. mock.patch.dict with clear=True empties the dict for the duration and restores its original contents afterwards:
import unittest
from unittest import mock
import cart
class CartTests(unittest.TestCase):
def setUp(self):
patcher = mock.patch.dict(cart._CARTS, clear=True)
patcher.start()
self.addCleanup(patcher.stop)
def test_add_item(self):
self.assertEqual(cart.add_item("ana", "book"), ["book"])
def test_empty_cart(self):
self.assertEqual(cart.count("ana"), 0)Ran 2 tests in 0.000s
OKWhy addCleanup instead of tearDown? If setUp itself fails halfway (after patching one thing, before patching another), tearDown doesn’t run at all, but the cleanups registered so far do. Registering each undo right after the change it reverses keeps them paired.
The pytest version of the same idea is a fixture that yields:
import pytest
import cart
@pytest.fixture(autouse=True)
def empty_carts(monkeypatch):
monkeypatch.setattr(cart, "_CARTS", {})monkeypatch.setattr replaces the attribute for one test and restores the original afterwards. autouse=True applies the fixture to every test in its scope without naming it, which suits a reset like this one. Code before a fixture’s yield is setup and code after it is teardown, which pytest runs even when the test fails.
The longer-term fix is design, not test plumbing: if cart took a store object as a parameter instead of using a global, each test could pass a fresh store and nothing would need patching. Saying that in an interview shows you see tests as feedback on the code’s structure.
Environment variables and files
Processes share environment variables and the filesystem across every test. Both need the same undo discipline.
import os, tempfile, unittest
from pathlib import Path
from unittest import mock
def export_dir():
return Path(os.environ.get("EXPORT_DIR", "/var/exports"))
class ExportTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = Path(tmp.name)
env = mock.patch.dict(os.environ, {"EXPORT_DIR": str(self.tmp)})
env.start()
self.addCleanup(env.stop)
def test_uses_temp_dir(self):
self.assertEqual(export_dir(), self.tmp)After the run, a quick check confirmed the variable was gone:
after tests, EXPORT_DIR set? FalseSetting os.environ["EXPORT_DIR"] directly inside a test would have leaked into every later test in the process. In pytest, monkeypatch.setenv("EXPORT_DIR", str(tmp_path)) combined with the built-in tmp_path fixture (a fresh pathlib.Path per test) does the same in one line each.
Note that this only works because export_dir() reads the environment when called. If the module had computed EXPORT_DIR at import time, patching the environment in a fixture would be too late; the imports and side effects guide covers that trap.
Scope: how long a fixture’s value lives
pytest fixtures have a scope: function (the default, rebuilt for every test), class, module, package or session. Wider scopes run setup less often, which matters for expensive resources like a database server or a browser. The cost is sharing: every test in that scope gets the same object.
A common, sensible pattern splits the two concerns:
@pytest.fixture(scope="session")
def engine():
eng = create_engine(TEST_DATABASE_URL)
yield eng
eng.dispose()
@pytest.fixture
def db(engine):
with engine.connect() as conn:
tx = conn.begin()
yield conn
tx.rollback()The connection pool is created once per run. Each test gets a connection inside a transaction that’s rolled back afterwards, so writes never persist between tests. The rule pytest enforces here is that a fixture can only depend on fixtures with the same or a wider scope; a session-scoped fixture that requests a function-scoped one fails with a ScopeMismatch error.
The misconception to avoid is “use session scope to make tests faster” without asking what the fixture holds. A session-scoped fixture returning a mutable dict of test users is the module-level _CARTS bug again, with a decorator on it.
Mocks are shared state too
mock.patch replaces a name for the duration of a test. Two things go wrong with it. Patching the wrong location: you patch where the name is looked up, so if orders.py does from payments import charge, the target is "orders.charge", not "payments.charge". And patches that never stop: calling patcher.start() without a matching stop (or addCleanup(patcher.stop)) leaves the fake in place for the rest of the process. Using patch as a decorator or with block, or pytest’s monkeypatch, avoids the second problem entirely.
Interview exercise: the flaky CI suite
A suite passes locally but fails in CI about one run in five, always in different tests. CI runs tests in parallel with pytest -n 4 (pytest-xdist). The tests use a session-scoped fixture that creates a user test@example.com in a shared PostgreSQL database and a few tests write to /tmp/export.csv. What’s going on and how do you fix it?
Answer
The tests share state that’s now being touched concurrently. With xdist, each worker is a separate process with its own session, so the session fixture runs four times and four workers try to create the same user, which can collide on a unique constraint. Tests that modify that user can also see changes from another worker. The fixed path /tmp/export.csv is shared by all workers, so one test can read a file another test is halfway through writing. It passes locally because a single process runs tests one at a time. The fixes all restore isolation: give each test its own data (a function-scoped fixture that creates a user with a unique email, inside a transaction that rolls back), use tmp_path instead of fixed paths, and if the database must be shared, give each worker its own schema or database named after the worker_id fixture xdist provides. Finally, running the suite with random ordering locally (pytest-randomly) would surface the order dependence without needing CI.