Injecting a Database, an HTTP Client, and a Logged-In Session Through Fixtures
Three test files each open their own database connection, copy the same eight lines of seeding, and — the part that hurts — skip cleanup whenever the test dies before reaching it. One morning the shared staging database has orphan rows and the suite fails for a reason nobody can reproduce. The fix is not more setup methods; it is moving resources into fixtures the runner creates on request and disposes for you.
# conftest.py
import pytest
@pytest.fixture(scope="session")
def app(tmp_path_factory):
db_url = f"sqlite:///{tmp_path_factory.mktemp('db')}/app.db"
application = create_app(db_url=db_url)
run_migrations(application)
yield application
application.shutdown() # runs even if the test failed
@pytest.fixture
def client(app):
with app.test_client() as c:
yield c
@pytest.fixture
def authed(client, seed_user):
token = client.post('/login', json=seed_user).json['token']
client.headers['Authorization'] = 'Bearer ' + token
return client
Tests declare what they need by parameter name:
def test_coupon_reduces_total(authed): # client and app are built transitively
...
That signature carries four guarantees that a hand-rolled setUp cannot. Setup is lazy: app is created only if some test in the run actually asks for it, so a pure API file never boots a database. Setup is compositional: authed depends on client depends on app, and pytest builds the graph once in the right order. Teardown is guaranteed: code after yield executes when the test fails, errors, or is interrupted — which is exactly when cleanup matters most — and finalizers run in reverse order of setup so the thing created last is closed first. Setup is visible: a reader of test_coupon_reduces_total(authed) knows the session exists before looking anywhere else, which is the Arrange section doing its job (see Design Patterns in Test Code). Before writing a custom fixture, check what the runner already ships: tmp_path hands every test a fresh directory the runner deletes, monkeypatch.setenv reverts environment changes at teardown, and capsys captures output — all three built on the same setup, hand over, guaranteed-revert shape. Authoring a third database helper instead of checking conftest is how suites end up with parallel helpers nobody trusts.
Scope decides how much of that runs per test:
| Scope | Created | Right for | Cost example |
|---|---|---|---|
function (default) |
every test | isolation-sensitive state: carts, tokens, mutated rows | 40ms seed × 400 tests |
module / class |
once per file or class | read-only reference data: a product catalogue, a country list | built once, reused |
session |
once per run | browsers, migrations, wiremock servers, licenses | 1.5s browser × 400 = 10min, or once |
The rule is the widest scope that preserves isolation. Two constraints keep you honest: a wider-scoped fixture must not depend on a narrower one, or pytest refuses the request with ScopeMismatch; and anything the test mutates belongs at function scope, because a session-scoped cart would make test order matter again — the exact flakiness fixtures were meant to remove. One budget note for CI: under pytest -n 8, session scope runs once per worker process rather than once per run, so a 1.5-second browser fixture costs 8 × session — small numbers multiplied by parallelism still deserve arithmetic.
autouse=True exists for cross-cutting setup only: freezing the clock, resetting a random seed, flushing captured logs. An autouse fixture that silently logs in everyone hides the precondition from the signature, which undoes the visibility benefit.
Interviewers ask "setUp or fixtures?" expecting the four guarantees above — lazy creation, dependency graph, teardown-on-failure, and visible requirements — plus the scope arithmetic. The runner-side version of the same idea, where capabilities plug in without editing test files, is what the last chapter of this topic covers; first, the same fixture model inside Playwright.
Paying the Login Cost Once: Worker-Scoped Fixtures and storageState
A UI suite with 300 tests that each sign in through the login form spends about six minutes of machine time on a flow that never changes. The fix is the same fixture model you know from pytest, expressed through Playwright's test.extend, plus one lever pytest does not have: fixtures can be scoped to a worker, so each parallel worker pays the login cost once instead of once per test.
// tests/fixtures/auth.ts
import { test as base, expect } from '@playwright/test';
type AuthFixtures = { buyer: Buyer; appPage: Page };
export const test = base.extend<AuthFixtures>({
buyer: [
async ({}, use) => { await use(createBuyer()); },
{ scope: 'worker' },
],
appPage: async ({ page, buyer }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill(buyer.email);
await page.getByLabel('Password').fill(buyer.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await use(page); // the test body runs with a signed-in page
},
});
export { expect };
Tests import test and expect from the fixtures module instead of @playwright/test, and the dependency is again explicit in the signature: test('shows order history', async ({ appPage }) => ...). The arithmetic is the argument: at 1.2 seconds per login, test-scoped sign-in costs 360 seconds across 300 tests; worker scope with 8 workers costs about ten seconds total — the fixture's scope is a performance decision, not a style choice.
When login must be truly shared, Playwright has a stronger mechanism than a worker fixture — a dedicated project that authenticates once and serializes the cookies for everyone:
// playwright.config.ts
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: { storageState: '.auth/buyer.json' },
},
]
The setup project writes .auth/buyer.json; every test in the dependent project starts with the session already established, and the file is a CI artifact you can inspect when auth itself misbehaves. Options ride the same rails: test.use({ storageState: '.auth/admin.json' }) inside a describe block switches identity for one suite only, and viewport, baseURL, and permissions are fixtures too — a config knob and a login session travel through identical mechanics. There is also a cheaper login: post credentials to the API (request.post('/login')) in tens of milliseconds, write the returned token into storageState yourself, and reserve the 1.2-second UI form flow for the handful of tests that actually assert the login form. Choose by lifetime: worker fixture when the session must differ per worker (parallel writes to one account are a flake source), setup project when one identity serves the whole run. Worker-scoped fixtures tear down when the worker finishes, not when a test ends — close API contexts, delete seeded accounts, and flush caches there, or parallel workers will race a cleanup written for a single-process world.
Two habits keep the extension healthy. Fixtures compose — add adminPage alongside appPage, both depending on shared helpers, rather than one mega-fixture that branches on a flag. And auto: true is reserved for genuinely universal behavior (a fixture attaching screenshots to every test); an auto-login fixture applied to API-only specs wastes seconds and confuses readers about which tests are authenticated.
Playwright's fixture objects also carry configuration: test.use({ baseURL, trace: 'on-first-retry' }) scopes options to a describe block without touching globals. The pattern generalizes: capabilities arrive through declared extension points, not through edits to every file — and once teams want retries, tags, and custom reporting bolted on the same way, the runner's plugin surface becomes the place to work, which is the final chapter here.
Adding Failure Screenshots, a --browser Flag, and a Reporter Without Touching One Test
Requirements keep arriving that sound global: capture a screenshot whenever a test fails, accept --browser=firefox from the command line, tag a smoke subset, post a summary to Slack. Each one tempts you to edit every test file. Runners expose hooks precisely so these land in one place — a plugin — and the core suite stays untouched.
pytest's hook for failure capture is pytest_runtest_makereport, which runs at the moment the test's call phase finishes:
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == 'call' and report.failed:
driver = item.funcargs.get('driver')
if driver is not None:
png = driver.get_screenshot_as_png()
item.add_report_section('call', 'screenshot', 'see attached image')
# allure.attach(png, name=item.nodeid, attachment_type=allure.png)
Before building a capture hook, check the built-ins: Playwright turns failure evidence into configuration — use: { trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' } — with no code anywhere. Write a custom hook when capture needs domain knowledge: attaching the request body and correlation id, dumping the server-side log excerpt, or serializing application state that no generic screenshot reveals. Knowing the built-in first keeps the plugin layer from reinventing the runner.
Options and markers follow the same rule — declared once, consumed anywhere:
def pytest_addoption(parser):
parser.addoption('--browser', default='chromium', help='browser for UI runs')
def pytest_configure(config):
config.addinivalue_line('markers', 'smoke: fast subset run on every PR')
# pytest -m smoke --browser=firefox
Registering the marker matters: unregistered ones raise PytestUnknownMarkWarning and the -m smoke filter silently stops meaning anything once someone typos it. Distribution is the last step — a module gets loaded with -p my_plugin or the pytest11 entry-point group, and allure-pytest is proof the model scales: your favorite report is itself just a plugin.
Playwright and Jest express the same idea through reporters. A reporter sees the whole run and cannot be imported by a single test:
export default class TeamReporter implements Reporter {
onBegin(config, suite) { this.total = suite.all().length; }
onTestEnd(test, result) {
if (result.status !== 'expected') this.failures.push(test.title);
}
onEnd(result) {
this.summary();
return result.status === 'failed' ? 'failed' : 'passed';
}
}
// playwright.config.ts
reporter: [['./reporters/team.ts', { webhook: process.env.SLACK_WEBHOOK }]]
Jest does the equivalent with reporters: [['jest-junit', { outputDirectory: 'reports' }]], and a custom module implements onRunComplete(context, results). Options flow through config, never through environment variables read inside tests.
Determinism rules govern every hook. Teardown and after-hooks run in reverse setup order and run even after failures — release browsers, close servers, delete temp dirs there, never in the test body. Hooks execute under parallel workers and retries, so read result.retry instead of assuming attempt one, keep hooks idempotent, and never introduce an assertion there: a reporter signals failure by its exit status, not by throwing mid-run. Finally, write the table — hook or extension point, purpose, owner — into the README, because an extension point nobody documents becomes an extension point nobody uses. In interviews the topic arrives as "how would you add retries, tagging, or a new report without changing every test?" Answer in the runner's vocabulary: declare the option in pytest_addoption, implement behavior in a hook or a reporter, register it in config, document it in the extension table. Naming the real entry points — instead of "I would write a utility class" — is what separates candidates who have extended a runner from candidates who have only used one. With fixtures and plugins in place, the remaining question is how the whole framework holds together and who guards it: Framework Architecture and Ownership comes next, after the matcher-driven assertions of Custom Matchers and Assertions that these reports will display.
Quiz
1. A UI suite runs 400 tests; launching a browser costs 1.5s and only 12 tests need a fresh signed-in profile. How should the browser be scoped?
2. A test raises an assertion error halfway through its body. Which statement about its fixture teardown is correct?
3. Every contributor must be able to run `pytest --browser=firefox` without editing any test file. Where does the option belong?
4. The team wants a Slack summary of nightly failures with zero changes to test files. What is the correct extension point?
Flashcards
Question
What four guarantees do pytest fixtures give that a hand-written setUp does not?
Click to reveal answer
Answer
Lazy creation (built only when a test requests them), a dependency graph (authed → client → app built once in order), guaranteed teardown after yield even when the test fails, with finalizers in reverse order, and visibility — requirements are named in the test signature, which keeps Arrange readable.
Question
How do you choose a fixture scope, and what two errors result from getting it wrong?
Click to reveal answer
Answer
Use the widest scope that preserves isolation: session for browsers/migrations, module for read-only reference data, function for anything a test mutates. A wide fixture depending on a narrow one raises ScopeMismatch; putting mutable state at session scope makes test order matter and reintroduces flakiness.
Question
Worker-scoped login versus a Playwright setup project with storageState — which do you pick and why?
Click to reveal answer
Answer
Worker scope when sessions must differ per parallel worker (a worker fixture costs ~one login per worker instead of per test). A setup project when one identity serves the run: it authenticates once, writes .auth/buyer.json, and dependent projects load it as storageState — fastest, and the file doubles as a CI artifact for debugging auth.
Question
Name the pytest extension points for: a new CLI flag, a marker, and a screenshot on failure.
Click to reveal answer
Answer
pytest_addoption(parser) declares the flag (read via request.config.getoption), pytest_configure adds the marker line so -m filtering works without PytestUnknownMarkWarning, and a hookwrapped pytest_runtest_makereport inspects report.when == 'call' and report.failed to attach the screenshot. Ship them in conftest or as a plugin via -p or the pytest11 entry point.
Question
What is the contract of a run reporter, and what determinism rules apply to hooks?
Click to reveal answer
Answer
A reporter receives run-wide lifecycle events (onBegin, onTestEnd, onEnd), is registered in config with options passed through config rather than read inside tests, and signals failure through its returned status. Hooks run under parallel workers and retries, so teardown releases resources in reverse order, idempotency is required, retry index is read rather than assumed, and assertions never live in hooks.
Revision Notes
Key Takeaways
- 1. Fixtures beat setUp on four counts: lazy creation, a dependency graph, teardown that runs after failures, and requirements visible in the test signature
- 2. Scope is a performance and correctness decision — session for expensive immutable resources, function for anything mutable, and never wide-depends-on-narrow (ScopeMismatch)
- 3. Playwright fixtures scope to a worker, turning hundreds of logins into one per worker; a setup project with storageState shares one identity across the whole run
- 4. Global behavior (flags, markers, screenshots, Slack summaries) belongs in a plugin or reporter registered in config — one module, zero test edits
- 5. Hooks must be idempotent and retry-aware, teardown must release in reverse order, and an undocumented extension point is one nobody will use
Interview Tips
- • "Why fixtures instead of setUp?" — lazy creation, dependency graph, teardown-on-failure with reverse ordering, and the signature documents Arrange; add the scope table as the follow-up
- • "How would you speed up a suite spending six minutes on logins?" — scope the session fixture to worker or move auth to a setup project writing storageState, with the arithmetic (1.2s × 300 versus one per worker)
- • "Where would you add --browser or a custom marker?" — pytest_addoption in a plugin/conftest plus addinivalue_line in pytest_configure, mention PytestUnknownMarkWarning, and note distribution via -p or the pytest11 entry point
- • "How do you add reporting without touching tests?" — a reporter registered in config observing run-wide lifecycle events; call out determinism: reverse-order teardown, idempotent hooks, reading the retry index, and signaling failure via exit status
Cheat Sheet
Fixtures, Plugins, and Extensibility
Fixture skeleton:
@pytest.fixture(scope="session")
def app(tmp_path_factory):
application = create_app(db_url=...) # setup
yield application # teardown runs even on failure
application.shutdown() # reverse order of setup
| Scope | Use for | Never for |
|---|---|---|
function |
carts, tokens, mutated rows | — |
module/class |
read-only reference data | anything a test writes |
session |
browsers, migrations, mock servers | state shared across parallel tests |
Rules: widest scope that preserves isolation · wide-depends-on-narrow → ScopeMismatch · autouse only for universal setup (frozen clock, seeds).
Playwright extension:
export const test = base.extend<AuthFixtures>({
buyer: [async ({}, use) => use(createBuyer()), { scope: 'worker' }],
appPage: async ({ page, buyer }, use) => { /* login */ await use(page); },
});
Login math: 1.2s × 300 tests ≈ 6 min → worker scope (8 workers) ≈ 10s. Shared identity: setup project writes .auth/buyer.json, dependent project sets use: { storageState }.
Plugin surface: pytest_addoption (flags) · pytest_configure (register markers, else PytestUnknownMarkWarning) · hookwrapped pytest_runtest_makereport (failure capture when report.when == 'call' and report.failed) · Playwright Reporter.onBegin/onTestEnd/onEnd or Jest reporters: [['jest-junit', ...]].
Hook rules: teardown in reverse order · idempotent under parallel workers · read result.retry · no assertions inside hooks · options arrive via config, not env vars in tests.
Document every extension point: table of hook → purpose → owner in the README.