Suites grow switches. Run the slow tests too; point the integration tests at a different environment; regenerate golden files instead of comparing against them; choose which database backends to exercise. Each of those is a decision the person running the suite should make at the command line, and pytest_addoption is how a suite — or a plugin — adds its own flags alongside pytest's.
The mechanics are a few lines. What separates a well-behaved option from a confusing one is where it is registered, whether it has a project-level default, how tests read it, and whether pytest --help tells a newcomer that it exists.
Options are also where a test suite's operating knowledge accumulates. The flag that points integration tests at staging, the switch that regenerates snapshots, the selector for an expensive backend — each encodes a decision somebody made about how the suite should be run, and registering it properly is what turns that decision from tribal knowledge into something --help can explain. This guide covers registration, precedence between flags and configuration, the difference between options that change behaviour and options that change collection, and the conventions that keep options findable as their number grows.
Prerequisites
pytest >= 8.0.- A root
conftest.pyor a plugin module — see building custom pytest plugins. - The ini/
pyproject.tomlconfiguration model from pyproject.toml vs pytest.ini.
Solution
# conftest.py at the repository root
import pytest
def pytest_addoption(parser):
group = parser.getgroup("billing", "billing test-suite options")
group.addoption(
"--billing-env",
choices=["local", "staging"],
default=None, # None: fall back to the ini value
help="which billing environment integration tests target",
)
group.addoption(
"--update-golden",
action="store_true",
help="rewrite golden files from actual output instead of comparing",
)
parser.addini("billing_env", default="local",
help="default billing environment for integration tests")
@pytest.fixture(scope="session")
def billing_env(request):
# Command line wins; otherwise the project default from pyproject.toml.
return request.config.getoption("--billing-env") or request.config.getini("billing_env")
@pytest.fixture
def update_golden(request):
return request.config.getoption("--update-golden")
# pyproject.toml — the project's default, overridable per run
[tool.pytest.ini_options]
billing_env = "local"
pytest -q # local, from the ini value
pytest -q --billing-env staging # this run only
pytest --help | sed -n '/billing test-suite options/,/^$/p'
Why this works
pytest parses the command line in two stages. It first loads plugins and the root conftest.py, collecting every pytest_addoption implementation so that all options are known; only then does it parse the arguments. An option registered in a conftest.py below the root is discovered after parsing has already happened, which is why it produces "unrecognized arguments" when used — the most common mistake with this hook.
The two-stage parse also explains why options cannot be conditional on other options at registration time: every pytest_addoption runs before any argument has been read, so the registration must be unconditional and any interaction between options belongs in the code that reads them.
parser.addini declares a configuration key with a default and help text; config.getini reads it from whichever file the rootdir configuration lives in. Pairing the two gives each setting a project-level default under version control and a per-run override, and resolving both in one session fixture keeps the precedence logic out of the tests.
Edge cases and failure modes
- Option in a nested
conftest.py. Registered too late. Move it to the root or into a plugin. - Reading options in the test body. Scatters configuration logic and makes tests depend on invocation. Read in a fixture.
- Free-form strings where choices would do. A typo in
--billing-env staginsilently targets nothing. Usechoices=so argparse rejects it. - Options that change collection. Reading an option only in a fixture cannot stop tests being collected. Use
pytest_collection_modifyitemsfor options that select or deselect tests. - Names colliding with other plugins. Prefix options with the project or plugin name, since two plugins registering the same flag fails at start-up.
Options that change what is collected
Some options should decide which tests run rather than how they behave — a --run-slow flag, say, that includes tests marked slow only when given. Reading the option in a fixture is too late for that, because by the time a fixture runs the test has already been collected and started. The collection hook is the right place.
# conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption("--run-slow", action="store_true", help="include tests marked slow")
def pytest_collection_modifyitems(config, items):
if config.getoption("--run-slow"):
return
skip_slow = pytest.mark.skip(reason="needs --run-slow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
The skipped tests still appear in the report with a reason that tells the reader exactly how to include them, which is friendlier than deselecting them silently. Where silence is preferable — thousands of generated cases, say — config.hook.pytest_deselected(items=…) removes them from the report entirely, and -m "not slow" is often simpler still if the project is happy to use markers directly.
Designing options people will actually find
An option nobody knows about is an option nobody uses, and the usual way people learn a suite's switches is by reading pytest --help or by reading a colleague's CI configuration. Both work only if the option was registered with care.
Grouping is the first step. parser.getgroup("billing", "billing test-suite options") places every related flag under one heading in the help output, separate from pytest's own dozens of options and from every installed plugin's. Without a group, custom options appear in a generic "custom options" section alongside everything else a conftest.py ever registered, which in a large repository is an unordered list nobody reads.
Help text is the second. The string should say what the option changes and what the default is, in terms a newcomer understands: "which billing environment integration tests target (default: local, from pyproject)" is useful; "billing env" is not. Choices, where they exist, belong in choices= so argparse both documents and enforces them.
Naming is the third. A project-specific prefix — --billing-env rather than --env — prevents collisions with plugins that might register the same generic name later, which fails at start-up with a confusing error for everyone on the team the moment the plugin is installed. It also makes the option greppable across CI configuration, which is where people go to find out how the suite is actually run.
Testing the option itself
An option is behaviour, and it can break like any other — a refactor that renames the fixture reading it, a precedence bug where the ini value wins over the flag, a choices list that drifts from the environments that exist. The pytester fixture tests options end to end by running pytest on a small generated test file with specific arguments and asserting on the outcome.
The checks worth having are the precedence ones: with no flag and no ini value the default applies, with only an ini value it wins, and with both the flag wins. Three small tests, each running a generated test that prints or asserts on the resolved value, pin down the behaviour permanently and document it at the same time. The approach is covered in testing a pytest plugin with the pytester fixture, and it applies unchanged to options defined in a conftest.py rather than a packaged plugin.
These tests matter most for options that change collection. A --run-slow flag that silently stops including slow tests after a refactor produces no failure at all — the suite just runs fewer tests — and the only thing that notices is a test asserting on how many items were collected with and without the flag. That test takes seconds to write and is the only reliable guard against an option that quietly stops doing anything.
Frequently Asked Questions
Where must pytest_addoption be defined?
In a plugin or in the root conftest.py — the one pytest loads before parsing the command line. Options defined in a conftest.py deeper in the tree are registered too late and produce an "unrecognized arguments" error when used.
Should a setting be a command-line option or an ini value?
Both, usually. parser.addini declares a value that can live in pyproject.toml for the project's default, and parser.addoption gives a flag to override it per run. Read the option first and fall back to the ini value.
How do I read an option inside a test?
Through a fixture that calls request.config.getoption, rather than in the test itself. The fixture gives the value a name, a single place to convert and validate it, and lets tests stay unaware of where configuration comes from.
Related
- Building Custom pytest Plugins — where options live once they outgrow a conftest.
- Generating Cases with pytest_generate_tests — options that decide which cases are generated.
- Testing a pytest Plugin with the pytester Fixture — verifying an option behaves as documented.
- pytest Markers for Conditional Test Execution — the marker half of collection-changing options.
← Back to Building Custom pytest Plugins