Before you start
- A GitHub repository with Python code and pytest tests (see the pytest guide)
- A requirements.txt that lists pytest and your dependencies
- Permission to add files to the repository
Continuous integration (CI) means every change is built and tested automatically before it is merged. With GitHub Actions the whole setup is one file in your repository.
What you will build
A workflow that, on every push to main and on every pull request:
- checks out your code,
- installs Python (three versions, in parallel),
- installs your dependencies, with caching,
- runs
pytest.
Step 1: Create the workflow file
GitHub reads workflows from .github/workflows/. Create .github/workflows/tests.yml:
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install dependencies
run: python -m pip install -r requirements.txt
- name: Run the tests
run: python -m pytest -q
Step 2: Understand each part
on:lists what triggers the workflow. A push tomainand any pull request is a sensible default. Withoutpull_requestyou would only learn a branch is broken after it is merged.runs-on: ubuntu-latestpicks the machine image. Each job gets a fresh machine, so nothing carries over from your laptop. That is the point: it shows whether your project works from a clean start.strategy.matrixrepeats the job for each Python version, in parallel.fail-fast: falselets the others finish when one fails, so you see the whole picture at once.actions/checkout@v4downloads your repository onto the runner.actions/setup-python@v5installs the requested Python.cache: pipstores downloaded packages between runs, which speeds up later ones.run:executes a shell command. A command that exits with a non-zero status fails the step and the job, which is how pytest reports failing tests.
Step 3: Make sure requirements.txt is complete
CI starts from nothing, so every package the code and tests import must be in requirements.txt:
pytest>=7
requests>=2.31
The most common first failure is a ModuleNotFoundError for something that is installed on your own machine but was never written down.
Step 4: Push and watch it run
Commit the workflow and push it, or open a pull request. In the repository, open the Actions tab: you will see the run named tests with one job for each Python version. Open a job to read its log, line by line, as it ran.
A red cross means a step failed. The log shows which one, and for a test failure it contains the same pytest report you see locally.
Step 5: Make the check count
A check that people can ignore is a suggestion. In the repository's settings, add a branch protection rule (or a ruleset) for main that requires the status checks to pass before merging. From then on a pull request with failing tests cannot be merged by accident.
Good habits
- Keep the pipeline fast. Developers stop waiting for a ten-minute check and stop trusting it. Cache dependencies and run the slowest tests separately.
- Keep secrets out of the file. Use the repository's encrypted secrets and read them through
${{ secrets.NAME }}; never write a key into a workflow. - Treat a flaky test as a bug. A test that fails now and then teaches everyone to rerun and ignore failures.
- Run the same command locally. If
python -m pytest -qis what CI runs, it is also what you run before pushing.
Workflow syntax and the available actions change over time, so check the linked documentation when you build your own.
Expected result
The Actions tab shows a run named 'tests' for each push and pull request, with one job per Python version, each ending in a green tick when pytest passes.
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
| The workflow does not run at all | The file is not in .github/workflows/, is not valid YAML, or its on: section does not match what you did. | Check the path and the file extension (.yml or .yaml). Look at the Actions tab for a parsing error. |
| ModuleNotFoundError in CI but not on your machine | The package is installed locally but not listed in requirements.txt. | Add it to requirements.txt, ideally with a version, and push again. |
| A job fails only on one Python version | The code or a dependency uses something not available on that version. | Fix it, or remove that version from the matrix if you do not support it. |
| Tests pass in CI but the pull request can still be merged when they fail | The check is not marked as required. | In the repository settings add a branch protection rule (or ruleset) and require the check to pass. |
Frequently asked questions
Does this cost money?
Public repositories get free usage. Private repositories get a monthly allowance of free minutes that depends on your plan, and beyond it minutes are billed. Check the current limits in GitHub's documentation.
Why pin the action versions (v4, v5)?
A version tag means your pipeline does not change under you when an action publishes a new major version. For stricter control, some teams pin an action to a full commit hash.
Next steps
- Add linting (for example ruff) as a second job
- Upload test reports or coverage as artifacts
- Require the check in a branch protection rule
References
From Grokem: https://www.grokem.com/devops-and-platform/ci-cd/github-actions-python-tests


