Article summary

TL;DR

  • Put the workflow in .github/workflows/tests.yml and run the same pytest command locally and in CI.
  • Use actions/setup-python with an explicit Python matrix and dependency-file cache key.
  • Make branch coverage a deliberate gate with pytest-cov, not a number copied from another project.
  • Upload JUnit and coverage XML from every matrix job with unique artifact names.
  • Protect merges with one stable check that depends on the complete matrix.

01 / CI FEEDBACK LOOP

Move a passing local test into the pull request.

A test suite that only runs on one developer’s laptop is easy to forget. Someone skips it before pushing, a dependency behaves differently on another Python version, or a pull request is merged while the suite is red.

A GitHub Actions Python testing workflow can run pytest for every pull request and every push to the main branch. The result becomes part of the review instead of a manual reminder. This article starts with a small passing suite, then adds dependency caching, a Python version matrix, branch coverage, downloadable reports, and one stable check that can block a merge.

This is continuous integration, not deployment. The workflow proves that the tested code meets the checks you define. It does not publish a package or deploy an application.

CI is valuable when it repeats the same trustworthy check for every change, not when it hides a different test command inside a YAML file.

If pytest is new to you, first write and run your first Python test. The example below is self-contained, so you can also start here and copy the pattern into an existing project afterward.

02 / TEST PROJECT

Start with a test suite that already passes locally.

CI should automate a known working command. If the tests are already failing before you create the workflow, the first remote failure will not tell you whether the problem is the code, the dependencies, or GitHub Actions.

What do you need before running pytest in GitHub Actions?

You need a GitHub repository, a pytest suite that passes locally, and a committed dependency file or lockfile. Confirm the local command first so a remote failure points to CI configuration rather than an already broken suite.

The example uses one module and one test file:

python-ci-example/
├── .github/
│   └── workflows/
│       └── tests.yml
├── tests/
│   └── test_shop.py
├── pyproject.toml
├── requirements-dev.txt
└── shop.py

Put the business rule in shop.py:

def shipping_cost(order_total: int) -> int:
    if order_total < 0:
        raise ValueError("order total must not be negative")
    return 0 if order_total >= 50 else 5

Add the tests in tests/test_shop.py:

import pytest

from shop import shipping_cost


@pytest.mark.parametrize(
    ("order_total", "expected_cost"),
    [
        pytest.param(0, 5, id="zero"),
        pytest.param(49, 5, id="below-threshold"),
        pytest.param(50, 0, id="at-threshold"),
        pytest.param(75, 0, id="above-threshold"),
    ],
)
def test_shipping_cost(order_total: int, expected_cost: int) -> None:
    assert shipping_cost(order_total) == expected_cost


def test_shipping_cost_rejects_negative_total() -> None:
    with pytest.raises(ValueError, match="must not be negative"):
        shipping_cost(-1)

The development dependencies are deliberately small. Put them in requirements-dev.txt:

pytest>=9,<10
pytest-cov>=7,<8

For a real project, install from the dependency file or lockfile already used by the repository. Do not maintain a separate, loosely related list only for CI. The important property is that local development and automation resolve the same dependency set.

Add focused pytest configuration to pyproject.toml:

[tool.pytest]
addopts = ["-ra", "--strict-config", "--strict-markers"]
testpaths = ["tests"]
xfail_strict = true

The native [tool.pytest] table requires pytest 9 or later. Projects that support older pytest versions can use [tool.pytest.ini_options] instead.

Create an environment, install the dependencies, and run the suite with the Python interpreter that will execute pytest:

python -m pip install -r requirements-dev.txt
python -m pytest -q

The useful part of the output should be:

5 passed

At this point, you have the local feedback loop that the rest of the article will automate. If you want the broader learning path, my complete Pytest course connects this foundation to fixtures, parametrization, mocking, API tests, coverage, and CI/CD.

Continue from this local test suite through coverage gates, matrix testing, and GitHub Actions CI/CD in the complete Pytest course.

View course

python -m pytest makes the selected interpreter explicit. If your project uses uv, keep that contract consistent by running uv sync --frozen and uv run pytest both locally and in the workflow.

03 / FIRST WORKFLOW

How do you run pytest in GitHub Actions?

Create .github/workflows/tests.yml and run python -m pytest in a normal workflow step after checkout, Python setup, and dependency installation:

name: Python tests

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  pytest:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v7
        with:
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@v7
        with:
          python-version: "3.14"
          cache: pip
          cache-dependency-path: requirements-dev.txt

      - name: Install test dependencies
        run: python -m pip install -r requirements-dev.txt

      - name: Run pytest
        run: python -m pytest -q

Should you use GitHub’s Python workflow template?

You can. Open the repository’s Actions tab and choose the Python application workflow template. GitHub provides it as a starting point when the repository contains Python code.

Treat the generated YAML as a scaffold. Check its triggers, action versions, dependency command, and pytest command against the repository before you commit it. The explicit workflow above keeps the local and CI commands easy to compare.

Commit the file and push a branch. Open a pull request and GitHub will create a workflow run with one pytest job.

The sequence is simple:

  1. actions/checkout places the repository in the runner workspace.
  2. actions/setup-python selects Python 3.14 and restores the pip download cache when possible.
  3. The install step creates the environment from requirements-dev.txt.
  4. The final step runs the same pytest command used locally.

GitHub recommends setup-python because it gives consistent behavior across hosted runners and Python versions. The current GitHub guide to building and testing Python shows the same checkout, setup, install, and test lifecycle.

The workflow grants the generated GITHUB_TOKEN read access to repository contents and nothing more. persist-credentials: false also removes the checkout token from later Git commands. A test-only job does not need permission to push code, write pull requests, or access deployment credentials.

How do you install and cache Python dependencies?

Run checkout first, configure setup-python with cache: pip and a cache-dependency-path that matches the committed dependency file, then install from that file on every run.

The cache stores pip’s downloaded packages. It does not preserve an installed virtual environment, so the install command must still run. cache-dependency-path ties the cache key to the dependency file. Checkout must happen first because setup-python needs that file in the workspace.

Do I need a dedicated pytest Marketplace action?

Not for this workflow. A normal run: python -m pytest step is easier to reproduce locally and keeps the test command under your control. A dedicated action can be useful when it adds behavior you have deliberately chosen, but it is another dependency to review and update. Start with the direct command and add an action only when its extra contract earns that complexity.

04 / PYTHON MATRIX

How do you test multiple Python versions?

Use a strategy.matrix with the exact minor versions your project supports. GitHub expands one job definition into a separate job for each version. A single runner only proves that the suite works on one interpreter.

Replace the single-version job configuration with a matrix:

jobs:
  pytest:
    name: pytest (Python ${{ matrix.python-version }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.12", "3.13", "3.14"]

    steps:
      - name: Check out repository
        uses: actions/checkout@v7
        with:
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@v7
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
          cache-dependency-path: requirements-dev.txt

      - name: Install test dependencies
        run: python -m pip install -r requirements-dev.txt

      - name: Run pytest
        run: python -m pytest -q

GitHub expands the job into three independent runs. fail-fast: false lets every version finish after one fails, which gives you the complete compatibility picture in a single workflow run.

Do not copy the versions blindly. Test the versions declared by your package metadata or operational policy. An application deployed only on Python 3.14 may need one CI version. A reusable package may need several. Add an operating-system matrix only when your code or support contract contains operating-system-specific behavior.

Keep version values quoted. YAML parsers can interpret unquoted values in surprising ways, and an explicit string is what setup-python expects.

05 / COVERAGE AND REPORTS

How do you keep pytest coverage and test reports?

Run pytest-cov in the test step and upload its XML output together with pytest’s JUnit report. The job log gives immediate feedback, while artifacts preserve machine-readable files after the runner disappears.

A green pytest exit status is still the primary signal. Coverage and reports add two different kinds of evidence:

  • branch coverage shows which decisions the test suite did not exercise;
  • JUnit XML records individual test outcomes for later inspection or another reporting system.

Replace the pytest step and add an artifact step:

      - name: Run pytest with coverage
        run: |
          mkdir -p test-results
          python -m pytest \
            --cov=shop \
            --cov-branch \
            --cov-report=term-missing \
            --cov-report=xml:coverage.xml \
            --cov-fail-under=100 \
            --junitxml=test-results/pytest.xml

      - name: Upload test reports
        if: ${{ always() }}
        uses: actions/upload-artifact@v7
        with:
          name: pytest-python-${{ matrix.python-version }}
          path: |
            coverage.xml
            test-results/pytest.xml
          if-no-files-found: warn
          retention-days: 7

pytest-cov exits with a non-zero status when total coverage is below --cov-fail-under. The tiny example can reasonably require 100% branch coverage because every decision is easy to exercise. That does not make 100% the correct starting threshold for every codebase. Choose a level you can defend, then raise it as useful tests bring important behavior under protection.

The pytest-cov reporting options can produce terminal, XML, HTML, JSON, LCOV, and other formats. This workflow keeps the terminal report readable in the job log and saves XML for tools that consume structured output.

if: always() asks GitHub to upload reports even when pytest fails. It cannot upload a file that was never created, so if-no-files-found: warn avoids replacing the original test failure with a second artifact error.

Each matrix run uses a unique artifact name. Current artifact actions treat an uploaded artifact as immutable, so three parallel jobs must not write to the same name.

Coverage is not proof that the assertions are useful. It tells you which code ran, not whether the tests protected the right contract. Review the failure modes, boundaries, and assertions before making a percentage a merge gate.

06 / REQUIRED CHECK

How do you create one stable required check?

Add one downstream job that depends on the pytest matrix and succeeds only when the complete matrix succeeds. Require that stable job name in branch protection.

Matrix jobs produce several check names. Those names can change when you add or remove a Python version, which makes them awkward as a long-lived branch rule.

Add a small aggregation job after pytest:

  tests:
    name: tests
    if: ${{ always() }}
    needs: pytest
    runs-on: ubuntu-latest
    steps:
      - name: Verify the complete matrix
        run: test "${{ needs.pytest.result }}" = "success"

The job runs after every matrix entry finishes. It succeeds only when the complete matrix succeeds. Configure the repository’s ruleset or branch protection to require the stable tests check instead of coupling the rule to a changing list of Python versions.

The final workflow is:

name: Python tests

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  pytest:
    name: pytest (Python ${{ matrix.python-version }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.12", "3.13", "3.14"]

    steps:
      - name: Check out repository
        uses: actions/checkout@v7
        with:
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@v7
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
          cache-dependency-path: requirements-dev.txt

      - name: Install test dependencies
        run: python -m pip install -r requirements-dev.txt

      - name: Run pytest with coverage
        run: |
          mkdir -p test-results
          python -m pytest \
            --cov=shop \
            --cov-branch \
            --cov-report=term-missing \
            --cov-report=xml:coverage.xml \
            --cov-fail-under=100 \
            --junitxml=test-results/pytest.xml

      - name: Upload test reports
        if: ${{ always() }}
        uses: actions/upload-artifact@v7
        with:
          name: pytest-python-${{ matrix.python-version }}
          path: |
            coverage.xml
            test-results/pytest.xml
          if-no-files-found: warn
          retention-days: 7

  tests:
    name: tests
    if: ${{ always() }}
    needs: pytest
    runs-on: ubuntu-latest
    steps:
      - name: Verify the complete matrix
        run: test "${{ needs.pytest.result }}" = "success"

Before protecting the branch, prove that the gate can fail. Temporarily change the expected cost at the threshold from 0 to 5, push the commit, and inspect the failed matrix jobs. Restore the correct expectation, push again, and confirm that the stable tests check turns green.

07 / TROUBLESHOOTING

Diagnose the failed layer instead of editing random YAML.

A CI failure usually belongs to one of four layers: workflow syntax, environment setup, test discovery, or application behavior. Start with the first failing step and preserve the local command as your reference.

The workflow does not start

Confirm that the file is under .github/workflows/ and that the event matches pull_request or a push to main. GitHub reports invalid workflow syntax separately from a failed job. Read that message before changing test code.

The cache dependency file is missing

Run checkout before setup-python and make cache-dependency-path match the real repository path. In a monorepo that might be backend/requirements-dev.txt. A cache miss is not a correctness failure; the install should still work without a restored cache.

pytest reports that no tests ran

pytest returns a distinct non-zero exit code when it collects no tests. Check testpaths, file names such as test_shop.py, function names that start with test_, and the workflow’s working directory. Do not convert an empty suite into a successful job.

Imports work locally but fail in CI

CI starts from a clean checkout. It does not have an editable install, an uncommitted file, or a shell-specific PYTHONPATH from your laptop. Install the project as its packaging configuration requires, often with python -m pip install -e ., and use a consistent package layout. Avoid patching sys.path inside tests to hide an installation problem.

Coverage fails on only one Python version

Read the missing branch report for that interpreter. Version-specific code, optional imports, and exception paths can change which branches execute. Either test the supported behavior on that version or exclude code only when it is genuinely outside the coverage contract. Lowering the threshold hides the symptom without explaining it.

Reports are missing after a failure

always() runs the upload step after a failed pytest command, but not every earlier failure produces reports. A dependency installation error happens before pytest can create JUnit or coverage XML. The first failed step remains the source of truth.

A pull request from a fork cannot read a secret

This test workflow does not need secrets, which is a useful default. GitHub deliberately restricts secrets for untrusted fork pull requests. Do not switch to pull_request_target and execute the fork’s code with privileged credentials. Split trusted operations from untrusted tests and grant each job only the permissions it needs.

A rerun passes without a code change

Treat that as evidence of a flaky test, timing dependency, external service, or uncontrolled state. Automatic retries can reduce noise, but they can also normalize a broken signal. Reproduce the condition and fix the source before relying on the gate.

08 / NEXT STEP

Make one test command the delivery contract.

The workflow is successful when developers can reproduce it without reverse-engineering GitHub Actions. Keep dependency installation explicit, run the same pytest command locally and remotely, and let every added gate protect a requirement you can explain.

As the project grows, the next changes should follow actual risk:

  1. Install the application from its committed lockfile.
  2. Add services such as a database only when integration tests require them.
  3. Split fast and slow suites when their feedback belongs at different stages.
  4. Run operating-system jobs only for supported platform behavior.
  5. Pin third-party actions to reviewed commit SHAs when your security policy requires immutable references.
  6. Keep deployment in a separate job or workflow with its own permissions and environment controls.

Automation alone does not create a testing culture. If several developers need to adopt the workflow, make pytest part of the team’s daily engineering practice instead of treating the YAML file as the finish line.

Start with one real pull request. Watch the local suite pass, push the branch, inspect all matrix jobs, deliberately break one expectation, and confirm that the protected tests check blocks the merge. Then restore the behavior and keep that feedback loop in every change.