GitHub Actions Python Testing: A Complete pytest CI Guide
Build a pytest workflow in GitHub Actions with dependency caching, multiple Python versions, branch coverage, reports, and a stable merge check.
Article summary
TL;DR
- Put the workflow in
.github/workflows/tests.ymland run the same pytest command locally and in CI. - Use
actions/setup-pythonwith 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 coursepython -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:
actions/checkoutplaces the repository in the runner workspace.actions/setup-pythonselects Python 3.14 and restores the pip download cache when possible.- The install step creates the environment from
requirements-dev.txt. - 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:
- Install the application from its committed lockfile.
- Add services such as a database only when integration tests require them.
- Split fast and slow suites when their feedback belongs at different stages.
- Run operating-system jobs only for supported platform behavior.
- Pin third-party actions to reviewed commit SHAs when your security policy requires immutable references.
- 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.
More field notes
Keep reading.
FastAPI Testing with Pytest: A Practical API Testing Tutorial
Build a small FastAPI endpoint, test its success and validation contracts with pytest and TestClient, and learn what a useful API test should prove.
API testingFastAPIHow to Onboard a Python Team to Testing with Pytest
Learn how a 15-developer Python team used shared practice, legacy-code pinch points, and CI to turn pytest into an everyday engineering habit.
pytestPython testingPytest for Beginners: Write and Run Your First Python Test
If you know how to write a Python function, you know enough to start testing. Build, run, and debug your first pytest test without creating a package.
pytestPython testing