Module 7: Data And Environments
6. The parametrized `settings` fixture
Overview
In lesson 5 you built the per-environment configuration: the Settings with the values that change, and load_settings that assembles it by reading --env and the environment variables. But that config, for now, is a function no one calls from the tests. A Settings that lives loose is of no use; the payoff arrives when the tests can receive it by name and adapt to it without knowing how it was built. This lesson makes that connection: it exposes load_settings as a settings fixture, so any test that requests it receives the Settings of the environment where the suite runs.
The word that titles the lesson is parametrized, and it deserves precision. The settings fixture is not a fixed normal fixture: its value depends on a command-line option, --env. Run pytest and settings is the local config; run pytest --env=ci and the same fixture, without changing a line, hands over the CI config. A single flag reconfigures the whole suite. By the end you are going to have the fixture written, you are going to understand why its scope should be session, and you are going to see the full payoff of module 6 executed: the same command with and without --env=ci changing the framework header live, and a test that reads settings and behaves differently according to the environment —all with a single change in the invocation—.
Connection to the module: this lesson is the hinge of the environments half. Lesson 5 built the config; this one plugs it into the framework as a fixture; lesson 7 finishes by ensuring its default is portable. It is also where module 6 collects its promise: there you were born with the --env option via pytest_addoption and were told "this is used in depth in module 7"; here it is exactly that —--env feeding the settings fixture—. The boundary with testing-in-cicd-guide stays firm: the fixture reads the environment, but who runs pytest --env=ci in the pipeline and how it is triggered is CI infra, and goes there. Here the framework only adapts to the lever; it does not build the hand that moves it.
The master switch of the stage
Imagine the stage of a theater with a master lighting switch that has two positions: "rehearsal" and "performance". In rehearsal, the lights are work lights —bright, flat, so everyone sees well—. In performance, the lights are the show's —dim, colored, dramatic—. The elegant thing about the master switch is that a single person moves it once, at the start, and the whole stage obeys: the back spotlights, the side ones, the proscenium ones, each one adopts its "rehearsal" or "performance" configuration without anyone having to adjust them one by one. The actors do not know —or care— what position the switch is in; they simply act under the light there is.
The settings fixture with --env is that master switch. You move it once, when invoking the suite: pytest (position "rehearsal"/local) or pytest --env=ci (position "performance"/CI). And the whole suite obeys: each test that requests settings receives the config of the chosen environment, without your touching a single test. The tests are the actors: they do not know what environment they run in; they request settings and use the values there are —the timeout that applies, the strictness that applies—. A change in one place (the invocation) reconfigures the whole stage.
That the switch is moved once, at the start is part of its virtue, and it has its technical reflection: the settings fixture is resolved once per session, not once per test. It would be absurd for each spotlight to decide its configuration separately midway through the performance, or to change between one scene and another; the run's config is a single one, fixed from startup. That "once per session" is the scope of the fixture, and it is an architecture decision this lesson justifies.
Writing the settings fixture
Here is the fixture, in the framework's root conftest.py, next to the --env option that feeds it:
# tests/conftest.py
import pytest
from tests.config import load_settings
def pytest_addoption(parser):
# The option that was born in module 6; here it chooses the environment.
group = parser.getgroup("reservo", "Reservo framework options")
group.addoption("--env", action="store", default="local",
choices=("local", "ci"),
help="Run environment (local, ci).")
@pytest.fixture(scope="session")
def settings(request):
# Reads --env once per session and builds that environment's config.
env = request.config.getoption("--env")
return load_settings(env)
Three pieces, each with its reason:
pytest_addoption creates the lever. It is the module-6 hook: it declares the --env option with a default ("local") and a closed set of valid values (choices=("local", "ci")). The choices is a safety net: if someone writes --env=prod, pytest rejects the invocation at once with a clear error, before load_settings has to deal with an unknown environment. The option is the master switch; choices are its valid positions.
The settings fixture reads the option and builds the config. request.config.getoption("--env") retrieves the value the user passed on the command line (or the default "local" if they passed nothing). That value goes to load_settings, which returns the environment's Settings. The fixture is thin on purpose: it has no logic of its own, it only connects the option with the function you already wrote in lesson 5. All the intelligence (the profiles, the variables, the precedence) lives in load_settings; the fixture only exposes it to the tests.
scope="session" resolves it once. This is the architecture detail. With session scope, pytest builds the Settings a single time at the start of the run and gives the same object to all the tests that request it. It does not rebuild it per test. That is correct for two reasons. First, a run's config is a single one —the environment does not change between one test and another—, so rebuilding it in each test would be repeated work with no sense. Second, and more important: it guarantees that all the tests see exactly the same config, with no risk that two tests receive different configs by accident. The master switch is moved once; the fixture reads it once.
The tests receive settings and adapt
With the fixture standing, a test requests it like any other —by name in its signature— and uses the values. Here is a test that verifies the config reflects the environment where it runs:
# tests/integration/test_settings.py
def test_settings_reflect_the_environment(settings):
if settings.env == "ci":
assert settings.strict_warnings is True
assert settings.timeout_seconds == 30
else:
assert settings.strict_warnings is False
assert settings.timeout_seconds == 5
# The database default is portable in any environment.
assert settings.database_url == ":memory:"
The test requests settings and branches by settings.env: if it runs in CI, it expects the strict config; if not, the relaxed one. Notice that the test does not call load_settings or read environment variables or know about profiles —it receives the Settings already built and only consumes it—. All the machinery of lesson 5 stays behind the fixture; the test only sees the result.
Now the payoff. Run this test locally (the default):
What to expect. With pytest tests/integration/test_settings.py -v (Python 3.14.0, pytest 9.1.1):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- /usr/local/bin/python3
reservo env: local | db: :memory: | timeout: 5s | strict: False
collecting ... collected 1 item
tests/integration/test_settings.py::test_settings_reflect_the_environment PASSED [100%]
============================== 1 passed in 0.01s ===============================
And now exactly the same test, without touching a line, with --env=ci:
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- /usr/local/bin/python3
reservo env: ci | db: :memory: | timeout: 30s | strict: True
collecting ... collected 1 item
tests/integration/test_settings.py::test_settings_reflect_the_environment PASSED [100%]
============================== 1 passed in 0.00s ===============================
Compare the two headers. Locally, reservo env: local | timeout: 5s | strict: False; in CI, reservo env: ci | timeout: 30s | strict: True. The same test passed in both, but by different paths: locally it took the else branch (it expected timeout 5, strict False) and in CI the if branch (timeout 30, strict True). A single change in the invocation —adding --env=ci— reconfigured the fixture, which reconfigured the test. The master switch was moved once, and the whole stage obeyed.
(The header line reservo env: ... comes from a pytest_report_header hook that reads --env and prints the config, the same reporting pattern of module 6. It is not indispensable for the fixture to work, but it makes visible in each run what config is being used —a courtesy worth it in a real framework, so no one doubts what environment it ran in—.)
Why the fixture, and not a direct import
You could ask: if load_settings is an importable function (like the module's factory, which we decided should be a module and not a fixture), why is the config a fixture instead of a from tests.config import load_settings in each test? The answer refines the module's distinction and is worth it.
The data factory is a module because it produces things the test builds in its arrange —and sometimes from outside a test (another factory, a script)—. The config is a fixture because it is a collaborator the test receives and whose value depends on the run's context (the --env option), a context only pytest has clean access to. A direct import of load_settings in a test would have to, in addition, read --env from somewhere —and --env is a pytest option, read via request.config.getoption, something that only exists inside the fixture machinery—. The fixture is the natural bridge between the command-line option (pytest's territory) and the test (which only wants the value). It is the same distinction of lesson 3: it is built with a module (the factory), it is received according to the run's context with a fixture (the config). Each tool for its nature.
And there is an extra benefit of it being a fixture: the scope. A session fixture resolves the config once and shares it; an import in each test would rebuild the config in each one. For something that is a fixed snapshot of the run, the session scope of the fixture is exactly what you want, and it is free by being a fixture.
Common mistakes
Putting the settings fixture in function scope (or leaving the default). What happens: someone writes @pytest.fixture def settings(request): ... without scope=, so it takes the default scope (function) and the config is rebuilt in each test. Why it happens: the function scope is the default and it is easy to forget. How to detect it: it usually gives no error —the config comes out the same each time—, but it is repeated work, and if load_settings had effects (opening something, logging), they would fire per test. How to fix it: the run's config is a single fixed snapshot; scope="session" resolves it once and shares it. It is the correct architecture decision, and also the most efficient. Only lower the scope if you really need different config per test —something rare, and almost always a sign you are mixing config with data—.
Putting business logic in the fixture instead of in load_settings. What happens: the settings fixture grows with ifs and computations —it reads variables, decides profiles, validates— instead of delegating to load_settings. Why it happens: the fixture is "where the test touches the config", so the logic accumulates there. How to detect it: if the fixture has more than two or three lines, or if its logic cannot be tested without pytest, too much migrated. How to fix it: all the intelligence (profiles, variables, precedence) lives in load_settings, which is a normal function testable without pytest (with the injected environ of lesson 5); the fixture only reads --env and calls load_settings. Thin fixture, fat function: the logic in the testable place, the fixture as a bridge.
Tests that ask "am I in CI?" instead of reading the value. What happens: a test does if settings.env == "ci": timeout = 30 else: timeout = 5 —it re-derives a value the config already has—. Why it happens: the settings.env field invites branching by environment. How to detect it: if a test computes a value from settings.env that the config already exposes as a field (settings.timeout_seconds), it is duplicating the profiles table inside the test. How to fix it: read the value, not the environment. A test that needs the timeout uses settings.timeout_seconds directly, without asking what environment it is in —the decision of what timeout goes with what environment was already made in load_settings—. Branching by settings.env is only justified when the test verifies the config itself (like the test_settings_reflect_the_environment above, whose job is to check that each environment carries its values); for everything else, read the value and that is it.
Exercises
Exercise 1 — Predict the behavior per environment. Given the lesson's settings fixture and this test, say what it does in each invocation. Test: def test_uses_timeout(settings): assert settings.timeout_seconds in (5, 30). (a) pytest (no flags). (b) pytest --env=ci. (c) pytest --env=prod. (d) Without changing the test, what invocation would make settings.strict_warnings be True inside the test?
See solution
- (a)
pytest→--envtakes its default"local", the fixture builds the local config, andsettings.timeout_secondsis5. The assert (5 in (5, 30)) passes. - (b)
pytest --env=ci→ the fixture builds the CI config, andsettings.timeout_secondsis30. The assert (30 in (5, 30)) passes. The same test, another value, because the fixture read another position of the switch. - (c)
pytest --env=prod→ pytest rejects the invocation before running the test, becauseprodis not inchoices=("local", "ci"). The error comes from the options parser, not from the fixture orload_settings:argument --env: invalid choice: 'prod' (choose from local, ci). Thechoicescaught the invalid environment at the door. - (d)
pytest --env=cimakessettings.strict_warningsbeTrueinside the test, because theciprofile sets the strictness toTrue. No need to touch the test: the fixture hands over the CI config and the test reads the field there is.
The lesson of the exercise: the test does not change between invocations; what changes is the Settings the fixture hands it according to --env. A single flag moves the switch, and the choices protects against invalid positions.
Exercise 2 — Choose the scope. For each fixture, say what scope is better and why. (a) The settings fixture, which hands over the environment's config. (b) A calendar fixture that gives an empty Calendar so each test starts clean. (c) A report_dir fixture that creates a temporary directory to write reports for the whole run.
See solution
- (a)
settings→ session. The config is a fixed snapshot of the run: the environment does not change between tests, so resolving it once and sharing it is correct and efficient. Function scope would rebuild it uselessly in each test. Session. - (b)
calendar→ function (the default). Here it is the opposite: each test needs a clean and own calendar, without the bookings another test added. Ifcalendarwere session, all the tests would share the same calendar and contaminate each other —a test that adds a booking would leave it for the next—. The mutable state that each test modifies wants function scope. This contrast with (a) is the key: immutable config → session; mutable state per test → function. - (c)
report_dir→ session. A report directory for the whole run is created once and all the tests write there; recreating it per test would make no sense and would fragment the reports. Since it is cumulative write-only and not state a test "dirties" for another, session is correct (and you would create it withtmp_path_factory, the session version oftmp_path, which you will see in lesson 7).
The rule the exercise practices: a fixture's scope depends on whether its value is a shared fixed snapshot (session: config, run directories) or own state each test modifies (function: calendars, stateful services). Mixing them causes contamination (a mutable session-scoped one) or waste (an immutable function-scoped one).
Exercise 3 — Refactor the scattered if os.environ. A coworker has this repeated in five tests: import os; timeout = 30 if os.environ.get("CI") else 5; ...uses timeout.... Refactor it using the settings fixture, and explain what three problems of the original disappear.
See solution
The refactor: each test requests settings and reads the already-resolved value.
def test_something(settings):
timeout = settings.timeout_seconds # already resolved by the environment
...uses timeout...
And the decision of "5 or 30 according to environment" lives once, in the profiles dictionary of load_settings (local: 5, ci: 30), not in each test.
The three problems of the original that disappear:
-
The duplication. The
30 if os.environ.get("CI") else 5was copied in five tests; the day the CI timeout changes to 45, you had to edit the five (and not skip any). Withsettings, the value lives in one place and the five tests read it; the change is made once, in the profile. -
The coupling to
os.environin the test. The original tied each test to reading theCIvariable directly —a dependency on the outside world put in the test's logic—. Withsettings, the test does not know about environment variables; it receives a number. The reading of the environment is encapsulated inload_settings(testable with injectedenviron), outside the tests. -
The environment detection tied to a concrete variable. The original decides "I am in CI" by looking at the
CIvariable —if the pipeline uses another variable, or if you want a thirdstagingenvironment, each test has to be re-educated—. Withsettings, the notion of environment lives in the--envoption and the profiles; addingstagingor changing how the environment is detected does not touch a single test.
The general pattern: the environment decision is made once, in the config, and the tests receive values, not make environment decisions. It is the same principle the factory applied to the data —centralize what repeats— applied to the configuration.
Summary and next step
In this lesson you plugged the config into the framework with the parametrized settings fixture. You wrote a thin session-scoped fixture that reads the --env option (the one from module 6) once per run and delegates to load_settings to hand the environment's Settings to any test that requests it. You saw the full payoff executed: the same test_settings_reflect_the_environment passing locally and in CI by different paths, with the framework header changing from local | timeout: 5s | strict: False to ci | timeout: 30s | strict: True just by adding --env=ci —the master switch that is moved once and reconfigures the whole stage—. And you refined the module's distinction: the data factory is a module (it is built), the config is a fixture (it is received according to the run's context), each tool for its nature.
Before moving on you should be able to: write a session-scoped settings fixture that reads --env and delegates to load_settings; justify why the config is a fixture and the factory is a module; and read the value the config exposes instead of re-deriving it by asking about the environment.
What comes next is finishing the environments half with its quietest and most important piece: the portability. You have local and ci profiles that change the behavior, but that does not guarantee the framework can run on any machine. A single absolute path hidden in a test —/Users/your-name/reservo/data.db— is enough for the suite, with its perfect profiles, to fail on a coworker's machine or in CI's clean container. In lesson 7 you are going to hunt those red flags —absolute paths, fixed ports, disk assumptions— and cure them with tmp_path and :memory:, so the framework runs the same everywhere. The portable default you saw in passing in settings (db: :memory:) is the tip of that iceberg.
Resources
- Fixture scope — pytest documentation — the reference of
scope="session"and the other scopes; the central architecture decision of this lesson (config once per session versus state per test). requestandrequest.config.getoption— pytest documentation — how a fixture reads a command-line option, the bridge between--envand thesettingsfixture. The reference of the mechanism that connects the module-6 lever with the tests.pytest_addoption— pytest documentation — the hook that declares--envwith itschoices, the safety net that rejects an invalid environment at the door.