ScreenshotNeo

BlogHow-to

How to Generate XML Test Reports in Pytest

Generate a JUnit-style XML report with pytest, configure its format and contents, and save it as a CI artifact—even when tests fail.

By the ScreenshotNeo team4 October 20267 min read

Run pytest --junit-xml=reports/junit.xml to write a JUnit-style XML report. The --junitxml spelling works too. Create the destination directory first if it might not exist, then configure your CI job to collect that exact file path. Pytest’s report family, suite name, duration measurement, and captured output can be configured in pytest.ini or another supported pytest configuration file.

1. Generate a report locally

Install pytest in your project environment if needed, create the output directory, and run the tests with the report option:

python -m pip install pytest
mkdir -p reports
python -m pytest --junit-xml=reports/junit.xml

The command is equivalent to pytest --junitxml=reports/junit.xml. Both option spellings write the XML report to the path you supply. The output is JUnit-style XML for CI systems and other tools that consume test results. See the pytest guide to JUnit XML output.

To check that the file was created, list it or parse it as XML:

test -s reports/junit.xml && python -c 'import xml.etree.ElementTree as ET; ET.parse("reports/junit.xml"); print("Valid XML")'

Pytest writes the report after the test run. A nonzero pytest exit status means tests failed or pytest encountered an error; it does not by itself mean the XML file is absent. Inspect the file and the pytest output separately.

2. Configure report options

Put options that should apply consistently in your pytest configuration. For example, create pytest.ini at the repository root:

[pytest]
junit_family = xunit2
junit_suite_name = my-project
junit_duration_report = total
junit_logging = no

Then run pytest with the report path as usual:

python -m pytest --junit-xml=reports/junit.xml

Alternatively, pass supported settings on the command line. For example, to report only test-call duration rather than total setup, call, and teardown duration:

python -m pytest --junit-xml=reports/junit.xml --junit-duration-report=call

Check pytest’s reference for JUnit configuration against your installed pytest version. The current documented families are legacy, xunit1, and xunit2; xunit2 is the default.

Report family

junit_family selects the XML compatibility family. Use the default xunit2 unless your receiving CI system or test-results plugin requires another family. Pytest documentation names Jenkins with the JUnit plugin and Azure Pipelines among known xunit2 consumers, but compatibility depends on the versions and plugins in your environment. Confirm those details when the report is part of a release or test-management workflow. See pytest’s xunit2 compatibility guidance.

Suite name

junit_suite_name sets the root XML suite name; the documented default is pytest. Set a stable project or component name when your results dashboard groups reports by suite.

Duration measurement

junit_duration_report accepts total or call. The default, total, includes setup, test call, and teardown. The call setting reports only the test call. These figures answer different questions: setup and teardown can be significant, so don’t compare one mode’s durations with the other as if they were measured the same way.

Captured output and logging

junit_logging controls which captured logging, standard output, and standard error are included. Its default is no. When logging is enabled, junit_log_passing_tests controls whether captured output for passing tests is included. Retaining output can help diagnose failures, but may make reports larger and noisier. Choose settings that match what the report consumer displays and what your team needs to troubleshoot.

Custom metadata

Pytest provides fixtures such as record_property, record_xml_attribute, and the session-scoped record_testsuite_property for adding metadata. Pytest warns that record_property and record_xml_attribute can make output fail validation against the latest JUnit XML schema. Check your consumer’s requirements before using custom properties or attributes. The docs describe record_testsuite_property as compatible with the latest xunit standard. See pytest’s documentation on recording JUnit XML metadata.

3. Save the report in GitHub Actions

Make the output directory in the workflow, write the report to a predictable path, and upload that same path as an artifact. Use an always() condition on the upload step so it remains eligible to run after pytest reports a test failure:

name: Python tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - run: python -m pip install pytest

      - name: Create report directory
        run: mkdir -p reports

      - name: Run tests
        run: python -m pytest --junit-xml=reports/junit.xml

      - name: Upload pytest results
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: pytest-results
          path: reports/junit.xml

This follows the pattern in GitHub’s official Python Actions guide. The artifact step can run after a failed test step, but it can only upload a file that exists. If pytest fails before writing the report, check the job log and artifact warning.

In a matrix workflow, give each job a distinct report path and artifact name when jobs could otherwise write or upload the same name. For example, use the Python version in both names. This keeps results from separate matrix jobs identifiable.

4. Troubleshoot common problems

Symptom Likely cause Fix
No report appears The parent directory does not exist, the path differs from the one CI uploads, or pytest did not reach report generation. Create the directory before the run, use one consistent path in the test and upload steps, and inspect the pytest log and exit status.
The artifact step cannot find the file The artifact path does not match the pytest output path, or the report was never created. Compare the --junit-xml value with the artifact path. Keep the upload condition set to always() if results should be retained after test failures.
The report is rejected by a consumer The consumer may expect another JUnit family, have version-specific parsing behavior, or reject custom XML fields. Check the receiver’s supported format and versions. Try the required junit_family and remove custom properties or attributes if schema validation fails.
Durations differ from expected test times The default total duration includes setup and teardown as well as the test call. Use junit_duration_report = call if you need test-call-only timings, and label or interpret the values accordingly.
XML is unexpectedly large Captured logs or output, particularly for passing tests, are being included. Review junit_logging and junit_log_passing_tests. Include only the captured output your report consumer and workflow need.
Matrix jobs overwrite or confuse results Jobs use the same output path or artifact name. Include a matrix value such as the Python version in each report path and artifact name.

5. Performance, reliability, and cost

Generating the XML uses pytest’s built-in reporting option, so a separate report-generation service is not required. The report still takes disk space, especially when captured output is included; artifact retention and storage are managed by your CI platform. Select output settings based on the diagnostic value you need, and check your CI provider’s current artifact storage and retention terms.

For reliable collection, keep the report path stable, create its parent directory, and upload it in a step that runs after failures. In matrix builds, isolate files and artifact names by job. A successful upload preserves the file for review; it does not prove that every test passed or that the XML matches every consumer’s schema.

Or skip the browser setup

Pytest XML reporting is a local test workflow. If you also need website screenshots for test evidence or visual checks, ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. One GET request returns an image or PDF. The request below saves a screenshot; see the ScreenshotNeo API documentation for options and response details.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)

Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo accepts cookie and consent banners like a visitor, then removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each of those steps can be turned off. Bot checks, blank pages, and failed loads are never billed, and response headers report the page verdict and billing status. Its MCP server gives AI agents tools for screenshots, page information, and PDF capture. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots.

Sign up for 1,000 free screenshots a month, with no card required.

FAQ

Does pytest generate JUnit XML without a plugin?

Yes. Use pytest’s built-in --junit-xml or --junitxml option.

Which report family should I choose?

Use the default xunit2 unless your specific CI or test-results consumer requires another family. Verify compatibility with the versions you run.

Does a failed test run still produce a report?

A test failure does not necessarily prevent report generation. In CI, make the artifact upload step run with always() and confirm the file exists.

Can I add build metadata to the XML?

Pytest has fixtures for adding metadata, but some custom properties and attributes can fail schema validation. Check the receiving tool before adding them.