Pytest & CI

Adding Command-Line Options with pytest_addoption

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

Solution

Python
# 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")
TOML
# pyproject.toml — the project's default, overridable per run
[tool.pytest.ini_options]
billing_env = "local"
Bash
pytest -q                               # local, from the ini value
pytest -q --billing-env staging         # this run only
pytest --help | sed -n '/billing test-suite options/,/^$/p'
Where an option's value comes from A precedence chain. A command-line flag overrides the ini value in pyproject.toml, which overrides the default declared in addini. A session fixture reads the resolved value once, and tests receive it by requesting the fixture rather than reading configuration themselves. Most specific wins; tests never read config directly --billing-env this run pyproject.toml the project default addini(default=…) billing_env fixture resolves once, validates tests request the fixture and never see config
Keeping the resolution in one fixture means the precedence rules are written once, and a test's behaviour never depends on how it happened to be launched.

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 stagin silently targets nothing. Use choices= so argparse rejects it.
  • Options that change collection. Reading an option only in a fixture cannot stop tests being collected. Use pytest_collection_modifyitems for 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.

Python
# 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.

Options that change behaviour versus options that change collection Two kinds of option. A behaviour option such as billing-env is read in a fixture and changes what a test does once it runs. A collection option such as run-slow is read in pytest_collection_modifyitems and changes which tests are run at all, adding skip marks with a reason to excluded items. Read the option where its effect belongs changes behaviour --billing-env, --update-golden read in a fixture test runs; does something different request.config.getoption changes collection --run-slow, --backend read in a collection hook test is skipped, deselected or multiplied pytest_collection_modifyitems
A fixture cannot un-collect a test; a collection hook cannot change what a running test does. Each option belongs to exactly one of these.

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.

Three properties of a discoverable option Three cards. A named group places related options under one heading in pytest help. Help text states what the option changes and what the default is. A project prefix prevents collisions with plugins and makes the option searchable in CI configuration. Registered so a newcomer can find it named group one heading in --help separate from pytest's own options parser.getgroup(…) useful help text what it changes what the default is allowed values help=, choices= project prefix no plugin collisions searchable in CI files obvious ownership --billing-env, not --env
All three cost nothing at registration time and save every future reader a search through conftest files.

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.

← Back to Building Custom pytest Plugins