Python · Testing with pytest · Intermediate

conftest.py and Shared Fixtures

5 tasks

Share fixtures across test files using conftest.py without any imports.

Sharing Fixtures with conftest.py

#
## The fixture duplication problem As your test suite grows across multiple files, you'll want the same fixtures in several places — a `sample_user`, a database connection, an auth token. Duplicating them is a maintenance burden; importing them from another test file is awkward and breaks pytest's discovery model. `conftest.py` solves this cleanly. It is a special file that pytest discovers automatically. Any fixture defined there is available to all tests in the same directory and all subdirectories — **with no import required**. ## How pytest discovers conftest.py When pytest collects a test at `tests/unit/test_users.py`, it loads every `conftest.py` it finds from the rootdir down to the test file's directory: ``` project/ ├── conftest.py ← loaded 1st (project-wide fixtures) └── tests/ ├── conftest.py ← loaded 2nd (test-suite fixtures) └── unit/ ├── conftest.py ← loaded 3rd (unit-test fixtures) └── test_users.py ← the test file ``` Fixtures at each level are available to all tests below that level. The test file never imports from conftest — pytest injects fixtures by parameter name automatically. ## Multiple conftest files and override precedence If the same fixture name appears in multiple conftest files, the **nearest** one wins — the one closest to the test file. This lets you define a sensible default at the root and override it for a specific subtree: ``` project/ ├── conftest.py # base_url = 'https://prod.example.com' └── tests/ ├── conftest.py # base_url = 'https://staging.example.com' ← overrides for tests/ └── local/ └── conftest.py # base_url = 'http://localhost:8000' ← overrides for local/ ``` Tests in `tests/local/` use `localhost`; everything else in `tests/` uses staging. ## Fixture scope in conftest Scope works the same as in-file fixtures, but session-scoped fixtures in conftest are especially powerful — they run once for the entire pytest session: ```python # conftest.py import pytest import json @pytest.fixture(scope='session') def product_catalog(): with open('tests/fixtures/products.json') as f: return json.load(f) # loaded once; shared by every test that needs it ``` This is the right home for expensive setup: authenticated HTTP sessions, database connections, loading large datasets. Run them once, share the result. ## Scope lifetimes | Scope | Created | Destroyed | Best for | |-------|---------|-----------|---------| | `function` (default) | before each test | after each test | isolated state per test | | `class` | before first test in class | after last test in class | class-level shared setup | | `module` | before first test in file | after last test in file | file-level resources | | `session` | before first test of run | after last test of run | expensive global setup | ## conftest is not a module you import Never write `from conftest import my_fixture`. pytest handles discovery and injection automatically. Importing from conftest bypasses this mechanism and creates subtle bugs. ## yield fixtures for teardown `yield` in a fixture separates setup from teardown. The code after `yield` runs when the fixture's scope ends — even if the test fails: ```python @pytest.fixture(scope='session') def db_connection(): conn = open_connection() yield conn # test code runs here; conn is injected conn.close() # runs after the last test that uses this fixture ```

conftest.py in a Real Project

#
## Project layout ``` project/ ├── conftest.py └── tests/ ├── conftest.py ├── test_products.py └── api/ ├── conftest.py └── test_orders.py ``` ## conftest.py (project root) — project-wide fixtures ```python # conftest.py import pytest import requests BASE_URL = 'https://apilearn.tukas.dev' @pytest.fixture(scope='session') def base_url(): return BASE_URL @pytest.fixture(scope='session') def catalog(base_url): '''Load the product list once for the entire test session.''' resp = requests.get(f'{base_url}/api/products/') resp.raise_for_status() return resp.json()['results'] ``` ## tests/conftest.py — shared auth fixtures ```python # tests/conftest.py import pytest import requests @pytest.fixture(scope='session') def auth_token(base_url): '''Log in once and reuse the token for all tests in the session.''' resp = requests.post(f'{base_url}/api/auth/token/', json={ 'username': 'testuser', 'password': 'TestUser2024!', }) resp.raise_for_status() return resp.json()['token'] @pytest.fixture def auth_headers(auth_token): return {'Authorization': f'Token {auth_token}'} ``` ## tests/test_products.py — no imports from conftest needed ```python # tests/test_products.py import requests def test_products_list(base_url): # from root conftest.py resp = requests.get(f'{base_url}/api/products/') assert resp.status_code == 200 def test_catalog_not_empty(catalog): # session-scoped; loaded once assert len(catalog) > 0 def test_profile_requires_auth(base_url, auth_headers): # from tests/conftest.py resp = requests.get(f'{base_url}/api/users/profile/', headers=auth_headers) assert resp.status_code == 200 ``` ## tests/api/conftest.py — directory-level override ```python # tests/api/conftest.py import pytest @pytest.fixture(scope='session') def base_url(): '''Override for integration tests: hit local server instead.''' return 'http://localhost:8000' ``` Tests inside `tests/api/` automatically use `http://localhost:8000`. Tests elsewhere still see `https://apilearn.tukas.dev`. The override is silent — nearest conftest wins. ## Fixture with setup and teardown via yield ```python # tests/conftest.py import pytest import requests @pytest.fixture def created_item(base_url, auth_headers): '''Create a test item before the test; delete it after.''' resp = requests.post( f'{base_url}/api/cart/items/', json={'product_id': 1, 'quantity': 1}, headers=auth_headers, ) resp.raise_for_status() item_id = resp.json()['id'] yield item_id # test receives item_id requests.delete( # teardown: always runs, even if test fails f'{base_url}/api/cart/items/{item_id}/', headers=auth_headers, ) ``` ## Indirect fixture: parametrize via conftest A fixture that reads its parameter from `request.param` can be driven by `parametrize`: ```python # conftest.py @pytest.fixture def role_headers(request, base_url): '''Log in as different user roles.''' role = request.param # 'admin' or 'viewer' resp = requests.post(f'{base_url}/api/auth/token/', json={ 'username': f'{role}user', 'password': 'TestUser2024!', }) return {'Authorization': f'Token {resp.json()["token"]}'} # test file @pytest.mark.parametrize('role_headers', ['admin', 'viewer'], indirect=True) def test_access(base_url, role_headers): resp = requests.get(f'{base_url}/api/products/', headers=role_headers) assert resp.status_code == 200 ```

conftest.py Quick Reference

#
**Discovery:** pytest loads `conftest.py` from rootdir down to the test file's directory. No import needed — fixtures are injected by parameter name. **Override precedence:** nearest conftest wins. `tests/unit/conftest.py` overrides `tests/conftest.py` for tests inside `tests/unit/`. **Fixture scope lifetimes:** | Scope | `setup` runs | `teardown` runs | |-------|-------------|----------------| | `function` | before each test | after each test | | `class` | before first method | after last method | | `module` | before first test in file | after last test in file | | `session` | once per `pytest` run | at the very end | **yield fixture (setup + teardown):** ```python @pytest.fixture(scope='session') def resource(): obj = setup() yield obj # tests receive obj teardown(obj) # runs when scope ends ``` **Fixture depending on another fixture:** ```python @pytest.fixture def auth_headers(auth_token): # auth_token is another fixture return {'Authorization': f'Token {auth_token}'} ``` **Indirect parametrize:** ```python @pytest.fixture def user(request): return create_user(role=request.param) @pytest.mark.parametrize('user', ['admin', 'viewer'], indirect=True) def test_access(user): ... ``` **Never** `from conftest import fixture` — pytest handles it automatically.
01

Shared Fixture Across Two Test Files

#

Create `conftest.py` at the project root with a `sample_users` fixture that returns a list of three user dicts (each with `"name"` and `"email"` keys). Create `test_users_a.py` with a test that asserts `len(sample_users) == 3`, and `test_users_b.py` with a test that asserts each user dict contains the `"email"` key. Neither test file should import anything from `conftest.py`. Run `pytest -v` and confirm both tests pass.

# conftest.py
import pytest

@pytest.fixture
def sample_users():
    # return a list of 3 dicts, each with 'name' and 'email'
    pass


# test_users_a.py
def test_users_count(sample_users):
    # assert len(sample_users) == 3
    pass


# test_users_b.py
def test_users_have_email(sample_users):
    # assert 'email' in each user
    pass
Solution
# conftest.py
import pytest

@pytest.fixture
def sample_users():
    return [
        {'name': 'Alice', 'email': '[email protected]'},
        {'name': 'Bob',   'email': '[email protected]'},
        {'name': 'Carol', 'email': '[email protected]'},
    ]


# test_users_a.py
def test_users_count(sample_users):
    assert len(sample_users) == 3


# test_users_b.py
def test_users_have_email(sample_users):
    for user in sample_users:
        assert 'email' in user
02

Session-Scoped Fixture That Loads a File Once

#

Create `settings.json` with `{"debug": true, "page_size": 10}`. Create `conftest.py` with a `session`-scoped `config` fixture that reads this file and prints `"Loading config"` before returning the parsed dict. Write two test functions in two separate files — both use `config`. Run `pytest -v -s` and verify `"Loading config"` appears exactly once in the output, not twice.

# settings.json (create this file):
# {"debug": true, "page_size": 10}


# conftest.py
import pytest
import json

@pytest.fixture(scope='session')
def config():
    print('Loading config')
    # open 'settings.json', parse JSON, return the dict
    pass


# test_a.py
def test_debug_mode(config):
    # assert config['debug'] is True
    pass


# test_b.py
def test_page_size(config):
    # assert config['page_size'] == 10
    pass
Solution
# settings.json:
# {"debug": true, "page_size": 10}


# conftest.py
import pytest
import json

@pytest.fixture(scope='session')
def config():
    print('Loading config')
    with open('settings.json') as f:
        return json.load(f)


# test_a.py
def test_debug_mode(config):
    assert config['debug'] is True


# test_b.py
def test_page_size(config):
    assert config['page_size'] == 10
03

Directory-Level Fixture Override

#

Create a root `conftest.py` with a `base_url` fixture returning `"https://apilearn.tukas.dev"`. Create a `local/` subdirectory with its own `conftest.py` that overrides `base_url` to `"http://localhost:8000"`. Write `test_root.py` at the root and `local/test_local.py` inside the subdirectory — each should print `base_url` with `-s`. Run `pytest -v -s` and confirm the correct URL appears in each test.

# conftest.py (project root)
import pytest

@pytest.fixture
def base_url():
    # return the production URL
    pass


# local/conftest.py
import pytest

@pytest.fixture
def base_url():
    # override: return localhost URL
    pass


# test_root.py
def test_root_url(base_url):
    print(f'  root test sees: {base_url}')
    # assert base_url == 'https://apilearn.tukas.dev'
    pass


# local/test_local.py
def test_local_url(base_url):
    print(f'  local test sees: {base_url}')
    # assert base_url == 'http://localhost:8000'
    pass
Solution
# conftest.py (project root)
import pytest

@pytest.fixture
def base_url():
    return 'https://apilearn.tukas.dev'


# local/conftest.py
import pytest

@pytest.fixture
def base_url():
    return 'http://localhost:8000'


# test_root.py
def test_root_url(base_url):
    print(f'  root test sees: {base_url}')
    assert base_url == 'https://apilearn.tukas.dev'


# local/test_local.py
def test_local_url(base_url):
    print(f'  local test sees: {base_url}')
    assert base_url == 'http://localhost:8000'
04

Indirect Fixture with request.param

#

Create a `conftest.py` with a `scaled` fixture that reads `request.param` (a number) and returns `request.param * 10`. Write a parametrized test using `indirect=True` with params `[2, 5, 7]`. Each test run should receive `20`, `50`, and `70` respectively — assert this. Run `pytest -v` and confirm three separate test cases appear in the output.

# conftest.py
import pytest

@pytest.fixture
def scaled(request):
    # return request.param multiplied by 10
    pass


# test_scale.py
import pytest

@pytest.mark.parametrize('scaled', [2, 5, 7], indirect=True)
def test_scaled_value(scaled):
    # assert scaled in (20, 50, 70) depending on which param was passed
    pass
Solution
# conftest.py
import pytest

@pytest.fixture
def scaled(request):
    return request.param * 10


# test_scale.py
import pytest

@pytest.mark.parametrize('scaled', [2, 5, 7], indirect=True)
def test_scaled_value(scaled):
    assert scaled in (20, 50, 70)
05

Fixture with yield: Setup and Teardown

#

Create a `conftest.py` with a `temp_file` fixture that: (1) creates a `Path("test_output.txt")` and writes `"hello from fixture"` to it, printing `"SETUP"`, then (2) yields the path, then (3) deletes the file and prints `"TEARDOWN"`. Write a test that reads the file and asserts its contents. Run `pytest -v -s` to see SETUP and TEARDOWN appear around the test.

# conftest.py
import pytest
from pathlib import Path

@pytest.fixture
def temp_file():
    path = Path('test_output.txt')
    # write 'hello from fixture' to path, print 'SETUP'
    # yield path
    # delete the file, print 'TEARDOWN'
    pass


# test_tempfile.py
def test_reads_fixture_file(temp_file):
    # read temp_file and assert contents == 'hello from fixture'
    pass
Solution
# conftest.py
import pytest
from pathlib import Path

@pytest.fixture
def temp_file():
    path = Path('test_output.txt')
    print('SETUP')
    path.write_text('hello from fixture')
    yield path
    print('TEARDOWN')
    path.unlink(missing_ok=True)


# test_tempfile.py
def test_reads_fixture_file(temp_file):
    content = temp_file.read_text()
    assert content == 'hello from fixture'