Configuration
Every setting has three homes: the command line, an ini file, and — since 0.4.3 — a named profile that holds a whole shape of a run under one word. This page lists all 29 flags and all 27 ini keys, says which of the six layers wins, names the values that fail the run rather than falling back, and ends with three configurations you can copy.
Where configuration lives
The plugin adds 29 command-line options to pytest, under the option group report generator, and registers 27 ini keys. The ini keys are read wherever pytest reads ini options:
[pytest]inpytest.ini[pytest]intox.ini[tool:pytest]insetup.cfg[tool.pytest.ini_options]inpyproject.toml
There is no third file: everything below lives in a file pytest already opens. Since 0.4.3 the plugin also reads sections of its own in those same files — [tool.pytest-html-reporter] and a table per named profile — which is a layer above these keys and below the command line. The four blocks below are the same configuration written four ways.
$ pytest tests/ \
--html-report=./report \
--environment=staging \
--build-info branch=main \
--build-info team=payments \
--archive-count=20 \
--archive-days=30 \
--report-logs=failed \
--report-open=none \
--report-link 'Coverage=htmlcov/index.html'
[pytest]
html_report = ./report
environment = staging
archive_count = 20
archive_days = 30
report_logs = failed
report_open = none
build_info =
branch=main
team=payments
report_link =
Coverage=htmlcov/index.html
report_link_pattern =
jira = https://acme.atlassian.net/browse/{}
[tool.pytest.ini_options]
html_report = "./report"
environment = "staging"
archive_count = "20"
archive_days = "30"
report_logs = "failed"
report_open = "none"
build_info = ["branch=main", "team=payments"]
report_link = ["Coverage=htmlcov/index.html"]
report_link_pattern = ["jira = https://acme.atlassian.net/browse/{}"]
[tool:pytest]
html_report = ./report
environment = staging
archive_count = 20
archive_days = 30
report_logs = failed
report_open = none
build_info =
branch=main
team=payments
report_link =
Coverage=htmlcov/index.html
report_link_pattern =
jira = https://acme.atlassian.net/browse/{}
Every ini key is a plain string key with an empty default, except build_info, report_link and report_link_pattern, which are declared as pytest linelists: one entry per line in an ini file, a list of strings in TOML. Numbers are quoted in TOML because the keys are strings, not integers.
addopts, and the one flag with no ini key
--title is the only option in the group without an ini key. To set it in a file, put it in pytest's own addopts.
[pytest]
addopts = -v -rf --title='Payments regression'
html_report = ./report
addopts is prepended to the command line, so anything written there is a command-line value. addopts = --report-logs=all beats report_logs = failed in the very same file. Pick one route per setting.Precedence — what wins
- The flag winsFor every setting, the command-line option is used and the ini key is consulted only when the option was not passed, or was passed as an empty or whitespace-only string.
-
Between them sit two more layersA
PYTEST_HTML_REPORTER_*variable, then the selected profile and the table it shares, then the plain ini key — six layers in all, listed in full under what wins. A run that names no profile and sets no variable behaves exactly as this page always described. -
Two settings add instead
--build-infoand--report-linkare the exception. The command-line entries come first and the ini lines are appended, so both sources appear in the report. Nothing is replaced. -
Three settings are switches, not values
--report-packages,--report-shard-mergeand--report-shard-resetturn a behaviour on, and so does a truthy ini key. There is nothing on the command line that turns off an ini file which has already said yes — remove the key instead. -
An unset ini key is not a valueAn absent key and an empty key are the same thing, and both fall through to the option's own default — which is why the tables below give the effective default rather than the literal
"".
When settings are resolved
Everything is settled in pytest_configure, before the first test runs. A bad value therefore fails the run at the start rather than after an hour of tests. Two answers are resolved once and written back onto the config, because a pytest-xdist worker is a separate process handed a copy of the options and has to agree with the controller:
- the report path, after date placeholders have been expanded — so a run that crosses a minute boundary still writes one report, not two;
- the shard name, the merge flag and the run token, so every worker of an
-n 8leg files into the same shard directory.
Invalid values: what fails and what falls back
The two routes are not validated the same way. An out-of-range value on the command line is rejected by pytest's own argument parser, because those options declare their choices. An out-of-range value in an ini file is handled per setting, and the split is deliberate: a setting whose default is the answer you were trying to avoid raises, and a setting whose default is harmless falls back.
| Bad ini value in | Result |
|---|---|
report_logs, report_attachments, report_screenshots, report_steps, report_coverage | Silently falls back to the effective default (all, all, failed, all, auto). |
report_log_limit, report_attachment_limit, report_step_limit, report_coverage_limit | A non-numeric value silently falls back to 10000, 20000, 500 and 500. |
report_open | pytest.UsageError. Somebody who wrote report_open = off has said they do not want a browser; opening one anyway is the single outcome they were avoiding. |
report_junit_xpass | pytest.UsageError, for the same reason: the point of setting it is disagreeing with pass. |
archive_count, archive_days, archive_since | pytest.UsageError, naming the value it could not read. A retention limit that silently did nothing would grow the report for months before anyone noticed. |
pytest run started with report_open = off in pytest.ini and exiting immediately with ERROR: --report-open takes auto, always or none, not 'off' — no test output above it, which is the point: the run stopped before collection.
Limits, zero, and negative numbers
All four numeric limits — report_log_limit, report_attachment_limit, report_step_limit, report_coverage_limit — mean no limit at 0, not "keep nothing". A negative number is clamped to zero, so it also means unlimited. To keep nothing, use the mode beside the limit: report_logs = none, report_attachments = none, report_steps = none.
Date placeholders and shell variables
Two settings run through strftime: html_report and report_junit. Only the directives strftime documents are substituted (%Y %m %d %H %M and the rest of that set); %% becomes a literal percent, and any other %X is left alone, so a folder named 100% pass survives being expanded. Expansion happens once, at configure time.
$ pytest tests/ --html-report=./reports/%Y%m%d/report_%H%M.html
[pytest]
html_report = ./reports/%Y%m%d/report_%H%M.html
~ and $VARS are expanded in the report folder and in report_coverage_file. Nothing else in an ini file is expanded — a build_info line reading commit=$GITHUB_SHA reaches the report as those eleven characters, not as the commit. Pass a variable through the flag instead, where the shell expands it before pytest sees it:
$ pytest tests/ --build-info commit="$GITHUB_SHA" --build-info run="$GITHUB_RUN_ID"
Because the two sources add rather than replace, the fixed rows can stay in the ini file and only the moving ones need to be on the command line.
staging, at least three build_info rows (branch, team, commit) and the Captured output row reading something like all tests: stdout, stderr and logging, logging from WARNING — this panel is how a reader checks what the run actually resolved.
Named configuration profiles
local, ci, nightly — and chosen in one word with --report-profile=ci. It is a layer above the plain ini keys and below the command line, and a run that names no profile resolves byte for byte the way it did before.A suite is run in more than one shape. On a laptop you want the browser to open, every log kept and a handful of builds in the archive; on CI you want no browser, logs only where something failed, a JUnit xml beside the report and a month of history. That was two long command lines living in two places — a Makefile target and a workflow file — which drift the moment one of them is edited, and the drift is invisible until somebody reads a report missing the thing they went looking for.
A profile is those two shapes written down once, under a name:
[tool.pytest-html-reporter.profiles.local]
open = "auto"
logs = "all"
screenshots = "failed"
archive_count = 10
[tool.pytest-html-reporter.profiles.ci]
open = "none"
logs = "failed"
screenshots = "failed"
junit = "report/junit.xml"
archive_days = 30
[pytest-html-reporter.profiles.local]
open = auto
logs = all
screenshots = failed
archive_count = 10
[pytest-html-reporter.profiles.ci]
open = none
logs = failed
screenshots = failed
junit = report/junit.xml
archive_days = 30
$ pytest tests/ --report-profile=ci
Both files are read: the one pytest chose as this run's configuration file first, then pyproject.toml in the rootdir. A repository that keeps a pytest.ini therefore does not have to grow a pyproject.toml to say ci in a single word. When both define the same name, the ini file's definition is the one used, whole rather than merged — half a profile from each file would be a shape nobody composed, and which half came from where would depend on which file pytest happened to pick.
The underscore spelling of the distribution name works in either file, since pytest_html_reporter is what half of everybody types first. In a setup.cfg, where pytest's own section is [tool:pytest], the sections may be written [tool:pytest-html-reporter.profiles.ci] to match.
Choosing one
Four routes, highest first. The first two name a profile for one run; the last two pin the one a bare pytest uses, so the shape the repository agreed on is what happens by default.
$ pytest --report-profile=ci
$ pytest --report-profile=none # this run uses no profile at all
$ PYTEST_HTML_REPORTER_PROFILE=ci pytest
[pytest]
report_profile = ci
[tool.pytest-html-reporter]
profile = "ci"
--report-profile=none is how a single run opts back out of a pinned default — which is why no profile can be called none. The name is matched case-insensitively, so --report-profile=CI finds ci, and the report shows the name as it was written down rather than as it was typed.
Settings every profile shares
What is true of every shape of a run — the title, whether the Coverage tab is built — goes in the tool table itself, and a profile then says only what it changes. That table also applies to a run naming no profile at all, so it is a layer rather than something that only sometimes exists.
[tool.pytest-html-reporter]
title = "PAYMENTS"
coverage = "auto"
links = { Coverage = "htmlcov/index.html" }
[tool.pytest-html-reporter.profiles.ci]
open = "none"
logs = "failed"
[pytest-html-reporter]
title = PAYMENTS
coverage = auto
links =
Coverage=htmlcov/index.html
[pytest-html-reporter.profiles.ci]
open = none
logs = failed
Overriding from the environment
Every setting has a PYTEST_HTML_REPORTER_ variable, under both its short name and its ini spelling — PYTEST_HTML_REPORTER_LOGS and PYTEST_HTML_REPORTER_REPORT_LOGS reach the same option — and it sits above the profile.
$ PYTEST_HTML_REPORTER_LOGS=all pytest --report-profile=ci
$ PYTEST_HTML_REPORTER_JUNIT=out/junit.xml pytest
$ PYTEST_HTML_REPORTER_BUILD_INFO="commit=$GITHUB_SHA" pytest
$ PYTEST_HTML_REPORTER_PROFILE=ci pytest
That order is what an override is for: the profile is what the repository committed, and the variable is one job, one machine or one debugging session saying otherwise without editing a file everybody else reads. A variable set to nothing is not an answer — PYTEST_HTML_REPORTER_JUNIT= in a matrix leg that left its value blank means "I am not saying", not "write no xml". A list takes one entry per line.
What wins, all six layers
Highest first. Everything a profile settles is written onto the very option the flag writes onto, which is why nothing else in the plugin had to learn that profiles exist — and why an xdist worker, handed a copy of those same options, is shaped by the same profile the controller was.
- a flag on the command line — including one reached through
addopts; - a
PYTEST_HTML_REPORTER_*environment variable; - the selected profile;
- the
[tool.pytest-html-reporter]table every profile shares; - the plain ini key in
[pytest]; - the option's own default.
A profile is a set of overrides rather than a replacement configuration: everything it is silent about goes on being answered exactly as it was, which is what makes adopting one cheap. build_info, links and link_patterns are the exception and add up rather than replace, the way --build-info has always added to the ini key, so a profile wanting one more row does not restate the rows that were already there.
A label named by more than one layer is one row, and it is the highest layer's answer — at every layer of the list above, not only against the ini key:
[tool.pytest-html-reporter]
build_info = { branch = "main", team = "payments" }
[tool.pytest-html-reporter.profiles.ci]
build_info = { branch = "release" }
$ PYTEST_HTML_REPORTER_BUILD_INFO="branch=hotfix" pytest --report-profile=ci
That shows branch = hotfix and team = payments: the label three layers argued over takes the highest answer, and the row nobody argued over is kept. The row also stays where the layer that introduced it put it, so overriding a value does not reorder the panel. Before 0.4.3 the obvious way to write a profile — copy a block of ini keys under a name and leave the originals where they are — rendered every one of those rows twice, and a report_link_pattern in the ini file quietly took back a marker the command line had just claimed.
The settings a profile can carry
All 27 — every option in the group except --report-profile and --report-show-config, which choose the profile rather than being chosen by it. The ini spelling of each works as well as the short one, so a block of ini keys can be moved under a name without being rewritten.
| In a profile | On the command line | Value |
|---|---|---|
path | --html-report | html_report and report also name it |
title | --title | text — the one setting with no ini key of its own |
open | --report-open | auto | always | none |
logs | --report-logs | all | failed | none |
log_limit | --report-log-limit | integer, 0 is no limit |
attachments | --report-attachments | all | failed | none |
attachment_limit | --report-attachment-limit | integer, 0 is no limit |
screenshots | --report-screenshots | failed | all | none |
steps | --report-steps | all | failed | none |
step_limit | --report-step-limit | integer, 0 keeps every one |
coverage | --report-coverage | auto | none |
coverage_file | --report-coverage-file | path |
coverage_limit | --report-coverage-limit | integer, 0 lists every file |
environment | --environment | text |
build_info | --build-info | a list, or a table of key = "value" — added to |
links | --report-link | a list, or a table of Label = "url" — added to |
link_patterns | --report-link-pattern | a list, or a table of marker = "url" — added to |
archive_count | --archive-count | integer |
archive_days | --archive-days | number of days |
archive_since | --archive-since | date or datetime |
junit | --report-junit | path |
junit_xpass | --report-junit-xpass | pass | fail | skip |
packages | --report-packages | true / false |
shard | --report-shard | id, e.g. 1/4 |
shard_merge | --report-shard-merge | true / false |
shard_run | --report-shard-run | token |
shard_reset | --report-shard-reset | true / false |
A wrong value fails the run, not the report
A profile bypasses argparse entirely, so every check the command line would have made is made when the profile is applied — at configure time, before a test is collected, with one line naming the file:
ERROR: profile 'ci' in pyproject.toml: logs takes all, failed, none, not 'fail'
ERROR: profile 'ci' in pyproject.toml: unknown setting 'screenshot'. Did you mean 'screenshots'?
ERROR: --report-profile=cli: no such profile. Defined: ci, local. Did you mean 'ci'?
Without that check, logs = "fail" would land on the option, fail the helper's own test for a value it knows and fall through to all: a run that kept every log because six letters were typed instead of seven, and a report nobody can tell was misconfigured. An unknown key fails the same way and offers the nearest name; an unknown profile lists the ones that are defined.
Seeing what a run resolved
Six layers is more precedence than anybody holds in their head, and getting it wrong is silent: the run is green and the report is simply not the one that was configured. Two commands print the answer in provenance rather than in values — which layer decided each setting, and which layers it overrode.
$ pytest-html-reporter config --profile=ci
Profile: ci
Files read: pytest.ini, pyproject.toml
Profiles defined: ci, local
Setting Value Source
-------------------------------------
path report/ci profile 'ci' in pyproject.toml
build_info branch=hotfix the environment
over profile 'ci' in pyproject.toml
over [tool.pytest-html-reporter] in pyproject.toml
build_info team=payments [tool.pytest-html-reporter] in pyproject.toml
logs all the environment
over profile 'ci' in pyproject.toml
screenshots failed [tool.pytest-html-reporter] in pyproject.toml
open none profile 'ci' in pyproject.toml
junit report/junit.xml profile 'ci' in pyproject.toml
$ pytest --report-profile=ci --report-show-config
--profile is optional; without it the command resolves the profile a bare pytest would use, including one pinned as the default. --all also lists the settings nobody named, at their defaults, and --json prints the same thing as a document for a CI step to assert on. Every value shown is what gets written onto config.option, which is the copy an xdist worker is handed — so what the table says is what the workers ran with. See the CLI reference for both.
Environment panel carries a Profile row naming it, so a report found on a CI server months later answers "where did the logs go" with "this was built with ci, which keeps them only on failures" — which is the same fact. See the Environment dialog.Every ini key
All 27, with the value you get when the key is absent. Each one mirrors the flag of the same name with underscores for dashes; the long-form explanation of what each does lives in the CLI reference.
| Key | Type | Effective default | What it does |
|---|---|---|---|
report_profile | string | empty — no profile | The named profile a bare pytest uses, e.g. ci. New in 0.4.3, and mirrors --report-profile; everything the profile sets overrides the plain keys in this same file. |
html_report | path | . → ./pytest_html_report.html | Where the build is written. A value containing .html names the file; anything else names a folder and the file is pytest_html_report.html. Date placeholders expanded. |
archive_count | integer as text | empty — every build kept | Builds kept in Archives. Empty and 0 are different answers: empty keeps everything, 0 deletes archive/ and drops the Archives section entirely, and N keeps N-1 files plus the build being written. |
archive_days | number | no age limit | Keep only builds run in the last N days; fractions allowed, 0.5 is twelve hours. |
archive_since | date or datetime | no cut | Delete every archived build older than this. Read as YYYY-MM-DD or YYYY-MM-DD HH:MM, in local time. |
environment | string | empty | Names the environment under test. Adds an Environment row and a badge cut at 10 characters. |
build_info | linelist | empty | Extra KEY=VALUE rows for the Environment panel, one per line. Added to whatever --build-info gave. Split on the first =. |
report_logs | all | failed | none | all | Whose captured stdout, stderr and logging is kept. |
report_log_limit | integer | 10000 | Characters of output kept per test. The end survives, cut back to a whole line, with a note saying how much was dropped. 0 keeps everything. |
report_attachments | all | failed | none | all | Whose attachments — the payloads handed to attach_text, attach_json, attach_file and attach_api — are kept. |
report_attachment_limit | integer | 20000 | Characters kept per payload. The start survives, which is where a response puts its status and its error field. 0 keeps everything. |
report_screenshots | failed | all | none | failed | When a live Selenium driver or Playwright page is photographed without the suite asking. An image handed to attach() is kept whatever this says. See Screenshots. |
report_steps | all | failed | none | all | Whose step trees are kept — both step() and a pytest-bdd scenario's Given/When/Then. See Steps and BDD. |
report_step_limit | integer | 500 | Steps recorded per test, so a step inside a ten-thousand-row loop cannot write ten thousand lines into the page. 0 keeps every one. |
report_coverage | auto | none | auto | Whether the Coverage tab is built from whatever coverage this run produced. |
report_coverage_file | path | empty — go looking | Read coverage from this file — a coverage.json, a Cobertura coverage.xml or a .coverage data file — instead of discovering one. |
report_coverage_limit | integer | 500 | Files listed on the Coverage tab, least-covered first. 0 lists every one. |
report_link | linelist | empty | Side-nav entries, one LABEL=URL per line. Added to whatever --report-link gave. Relative paths are kept; a scheme that is not http://, https:// or mailto: is dropped. |
report_link_pattern | linelist | empty | Turns a marker into a link on the test it is written on, one MARKER=URL per line, where {} is the marker's argument — jira = https://acme.atlassian.net/browse/{}. Added to whatever --report-link-pattern gave. The argument is percent-encoded, a template with no {} is a fixed destination, and the same scheme sieve as report_link applies. Naming a marker here also writes it into the JUnit xml and registers it with pytest. See Traceability markers. |
report_open | auto | always | none | auto | Whether the finished report is handed to a browser. auto needs an interactive terminal, no CI variable and a graphical session. A fourth value fails the run. |
report_packages | boolean text | false | Whether the Environment panel carries a Packages row listing every installed distribution and its version, the way pip freeze reads. Truthy values are 1, true, yes, on. See the packages inventory below. |
report_shard | string | empty — not a shard | Names this run as one leg of a sharded matrix, e.g. 1/4. The leg writes a bundle under <report>/shards/ and no report of its own. |
report_shard_merge | boolean text | false | Whether this leg also merges every bundle beside it and renders one report. Truthy values are 1, true, yes, on; anything else is false. Needs report_shard too. |
report_shard_run | string | empty → detected from CI | Names the CI run this leg belongs to, so a merging leg merges only this run's bundles. |
report_shard_reset | boolean text | false | Whether this leg deletes <report>/shards before writing into it. Same truthy values. It deletes the other legs' work, so it is never implied. |
report_junit | path | empty — no XML | Also write a JUnit XML of this run to this path. Date placeholders expanded. Ignored on a shard that is not also the merge leg. |
report_junit_xpass | pass | fail | skip | pass | How an xpassed test is written to the XML. pass is what pytest's own --junitxml does. A fourth value fails the run. |
pytest-html-reporter merge command, which has no ini file at all, both fall through to the option defaults instead of raising.report_link — "Coverage" pointing at htmlcov/index.html and "CI job" pointing at an external URL — so the reader can see where a LABEL=URL line lands.
The packages inventory
--report-packages, or report_packages in the ini file, adds one more row to the
Environment panel: Packages, carrying every installed distribution and its version the way
pip freeze reads them, with the count in the label — Packages (214). Names are
sorted case-insensitively and a distribution whose metadata cannot be read is skipped rather than costing the
other two hundred.
[pytest]
report_packages = true
It is off by default on purpose. It is a few hundred entries nobody reads until the day the report is the
only surviving record of what was installed — and it publishes a full dependency inventory into a file
that gets attached to tickets and passed around. The Plugins row above it answers a much smaller
question, and the library whose new minor version broke the suite last night is almost never a pytest plugin.
See Security & privacy for what else the panel already carries.
Listing what was installed is a property of the job rather than of one run — either every build of this suite should carry the inventory or none of them should — so the ini file is usually where it belongs. It is a switch rather than a value: the flag turns it on and so does a truthy key, and there is no flag that turns off an ini file which has already said yes.
On a sharded run each leg collects the list for itself, on the machine that actually imported those versions. The merged report shows one list when every leg agrees and one row per leg when they do not, which is the case the row exists for.
Every command-line flag
All 29, with the ini key each one overrides. The CLI reference has the long-form entry for each, including the standalone pytest-html-reporter merge, junit and inspect commands, which are a separate surface.
| Flag | Value | Default | Ini key |
|---|---|---|---|
--report-profile | name, or none | the pinned default, else no profile | report_profile |
--report-show-config | flag | off | none — it prints, it does not set |
--html-report | folder or .html path | ./pytest_html_report.html | html_report |
--title | string | PYTEST REPORT | none — use addopts |
--environment | string | unset | environment |
--build-info | KEY=VALUE, repeatable | none | build_info — added to |
--report-link | LABEL=URL, repeatable | none | report_link — added to |
--report-link-pattern | MARKER=URL, repeatable | none | report_link_pattern — added to |
--report-open | auto | always | none | auto | report_open |
--report-packages | flag | off | report_packages |
--archive-count | integer | every build kept | archive_count |
--archive-days | number of days | no age limit | archive_days |
--archive-since | date or datetime | no cut | archive_since |
--report-logs | all | failed | none | all | report_logs |
--report-log-limit | integer, 0 = no limit | 10000 | report_log_limit |
--report-attachments | all | failed | none | all | report_attachments |
--report-attachment-limit | integer, 0 = no limit | 20000 | report_attachment_limit |
--report-screenshots | failed | all | none | failed | report_screenshots |
--report-steps | all | failed | none | all | report_steps |
--report-step-limit | integer, 0 = no limit | 500 | report_step_limit |
--report-coverage | auto | none | auto | report_coverage |
--report-coverage-file | path | discover one | report_coverage_file |
--report-coverage-limit | integer, 0 = all files | 500 | report_coverage_limit |
--report-shard | id, e.g. 1/4 | not a shard | report_shard |
--report-shard-merge | flag | off | report_shard_merge |
--report-shard-run | token | detected from CI | report_shard_run |
--report-shard-reset | flag | off | report_shard_reset |
--report-junit | path | no XML | report_junit |
--report-junit-xpass | pass | fail | skip | pass | report_junit_xpass |
--html-report defaults to ., so a plain pytest with the package installed writes ./pytest_html_report.html without being asked. Point html_report at a folder to keep it out of the repository root.pytest's own options that change the report
These belong to pytest and pytest-cov, not to this plugin, but the reporter can only show what those hand it. Four of the five most common "why is this empty" answers are here.
Capture — the usual cause of an empty Logs column
The plugin reads pytest's own capture option and says what it found in the Environment panel. Under -s there is nothing any reporter can do: pytest never takes stdout and stderr in, they go straight to the terminal.
--capture | What reaches the Logs column |
|---|---|
fd default | Everything, including output written by subprocesses and C extensions. |
sys | Everything Python itself writes; a subprocess's output is not captured. |
tee-sys | As sys, and it still prints live to the terminal while the run goes. |
no (same as -s) | Logging only. stdout and stderr are gone, and the report says so in a banner above the Test Metrics table. |
If you were running with -s to watch output go past, --capture=tee-sys is the replacement that gives you both. Stay on -s if you drop into pdb.
-s, light theme, ~1000px wide, cropped to the notice and the first three table rows. The banner must be legible — "stdout and stderr are not captured while pytest runs with -s / --capture=no, so only logging output reaches this column" — with the Logs column showing dashes beneath it.
Log level
Logging is captured from WARNING up until something lowers it, so log.info(...) and log.debug(...) are not in the report until you say so. The plugin reads both the flag and pytest's log_level ini key, and prints whichever it found in the Environment panel's Captured output row.
$ pytest tests/ --html-report=./report --log-level=INFO
[pytest]
log_level = INFO
html_report = ./report
pytest-cov, and what the Coverage tab reads
| Option | Effect on the report |
|---|---|
--cov=my_package | Without it nothing measures coverage and the tab shows its setup guide. Point it at the code under test, not at the tests — a --cov=src against a project with no src directory measures nothing, and the tab says so, naming the --cov that matched nothing. |
--cov-branch | Branch coverage is folded into the percentage exactly as pytest-cov folds it in, Branches and Partial tiles join the summary, and the table's Branches column fills in with counts instead of dashes. |
--cov-fail-under=80 | Becomes the line the coverage ring is coloured at, replacing the default 90/75 bands, and the tab says so. |
--cov-report=html | Writes htmlcov/, and the tab then offers a relative link to the annotated source — never an embed, so the report stays one file. The link appears only when the folder was written after this run started, so a stale htmlcov from last week cannot masquerade as fresh. |
--cov-report=json | Writes coverage.json, which is the first thing discovery looks for. Name a file explicitly with --report-coverage-file when it lives somewhere else. |
pytest-xdist
-n and --dist change nothing about the configuration above. Every worker sends its records back to the controller, which writes one report, one build in Archives and one row per test, in collection order. The report path and the shard settings are resolved once and handed to the workers so all of them agree. See CI integrations for the sharded case, where separate machines each write a bundle and one job merges them.
Three configurations worth copying
The first two are the same suite in two shapes, which is exactly what a named profile is for: written as local and ci in one file, they stop drifting apart and become one word on the command line. They are given here as plain ini files because that is what a profile is a set of — moving a block of these keys under a name needs nothing rewritten.
A CI build
Failures are what anybody opens a CI report for, so the passing tests give up their logs, attachments and steps. The XML is there for the build system's own test view; the HTML is the artifact people read.
[pytest]
addopts = -q -rf --capture=tee-sys --title='Payments regression'
html_report = ./report
report_open = none
report_logs = failed
report_attachments = failed
report_steps = failed
report_screenshots = failed
report_junit = ./report/junit.xml
archive_days = 30
report_link =
Coverage=htmlcov/index.html
report_link_pattern =
jira = https://acme.atlassian.net/browse/{}
$ pytest tests/ -n auto \
--cov=my_package --cov-report=html --cov-fail-under=80 \
--environment=staging \
--build-info branch="$GITHUB_REF_NAME" \
--build-info commit="$GITHUB_SHA" \
--build-info run="$GITHUB_RUN_ID"
Notes on the choices, in the order they appear:
--capture=tee-syskeeps the output for the report and still streams it into the job log, which is where you look while the build is running.report_open = noneis belt and braces —autoalready refuses on a build agent — but a runner that sets none of the usual CI variables and allocates a tty would otherwise try.archive_days = 30only does anything when the report folder survives between runs, from a cache or a checked-in artifact. On a fresh workspace every run is the first one.- The
build_infovalues come through the flag because the shell expands them; the same text in the ini file would arrive literally.
CI integrations has the full workflow files, and the GitHub Action wraps this into a step.
A local suite
The opposite trade: keep everything, and let the report open itself when the run ends.
[pytest]
addopts = -v -rf --capture=tee-sys --title='Checkout suite'
log_level = INFO
html_report = ./report
report_open = auto
report_logs = all
report_screenshots = all
report_steps = all
archive_count = 20
html_report pointed at one stable folder. Archives, trends and each test's history live in the folder the report is written to, so a dated path like ./reports/%Y%m%d/report_%H%M.html starts a fresh history every day — right for a nightly job that publishes each run on its own, wrong for a suite whose trend line you want to watch.Keeping the HTML file small
The report is one self-contained file, so everything it keeps is weight. Four things drive the size: retained builds, captured output, attachments and step trees. A retained build costs roughly 5KB of the page, so an hourly run with no retention limit reaches a multi-megabyte report inside a couple of months.
[pytest]
html_report = ./report
archive_count = 10
report_logs = failed
report_log_limit = 2000
report_attachments = failed
report_attachment_limit = 5000
report_steps = failed
report_step_limit = 100
report_screenshots = failed
report_coverage_limit = 100
| If it is still too big | Set | What you lose |
|---|---|---|
| History is the bulk of it | archive_count = 0 | The Archives section goes entirely, archive/ is deleted, and Analytics and the trend chart have nothing to read. |
| A few chatty tests dominate | report_log_limit = 500 | Only the last 500 characters of each test's output, cut to a whole line, with a note saying how much went. |
| Screenshots are the weight | report_screenshots = none | No automatic photographs. Images you handed to attach() yourself are still kept — this only governs the ones nobody asked for. |
| Nothing reads the output | report_logs = none | The Logs column entirely, and no size cost at all. The Environment panel says disabled (--report-logs=none) so a reader is not left guessing. |
| The coverage table is long | report_coverage = none | The Coverage tab, and the coverage entry in output.json, so no coverage delta on the next build either. |
Attachments and step trees are held outside the metrics table, so trimming them does not change the search box or the CSV, Excel and print exports — those read the table only.