Module 2: Automated Testing in CI
2. pytest in GitHub Actions
Overview
You have tests. You run them locally when you remember. Sometimes they pass, sometimes they fail, and sometimes you simply don't run them because "I only changed one line." This is the problem CI solves: taking the tests off your machine and moving them to a neutral environment that runs them automatically on every push, no excuses, no forgetting.
In this capsule you connect pytest with GitHub Actions. It isn't complicated, but there are details that matter: how to structure the workflow, which pytest flags to use in CI vs locally, how to read the logs when something fails, and how to handle the differences between your local environment and the runner. When you finish, every push to your repo will run your tests automatically and you'll see the result directly on GitHub.
The core idea is simple: if a test passes on your machine but fails in CI, it doesn't pass. The CI runner is the neutral judge. Your machine has configurations, environment variables, and specific versions that can hide bugs. The runner starts clean every time — that's its advantage.
The complete workflow: From checkout to pytest
The pattern for running pytest in GitHub Actions has 4 steps that always repeat in the same order:
1. Checkout → Bring your code to the runner
2. Setup Python → Install the Python version you need
3. Install deps → Install the project's dependencies
4. Run pytest → Run the tests
Let's look at the complete workflow:
# .github/workflows/test.yml
name: Tests
# Both triggers needed: push catches broken main, pull_request validates before merge
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
# ubuntu-latest is cheapest and fastest — use macOS/Windows only if your app requires them
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
# -v shows each test name — critical in CI where you can't re-run interactively
run: pytest tests/ -v
Why this order?
Each step depends on the previous one:
| Step | Without the previous step... |
|---|---|
| Checkout | The runner is empty — there's no code |
| Setup Python | The system's Python might be 3.8 or not have pip |
| Install deps | pytest doesn't exist on the runner |
| Run pytest | With no tests installed, you can't test |
If you change the order, the workflow fails. This order is so standard that you'll see it in practically every Python repo with CI.
Setting up the project for CI
Before configuring the workflow, your project needs a minimal structure:
my-ai-project/
├── .github/
│ └── workflows/
│ └── test.yml
├── src/
│ └── ai_app/
│ ├── __init__.py
│ ├── chain.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_chain.py
│ └── test_utils.py
├── requirements.txt
└── pyproject.toml
requirements.txt for CI
Your requirements.txt should include the testing dependencies:
# requirements.txt
langchain==0.3.14
openai==1.58.1
pydantic==2.10.4
pytest==8.3.4
pytest-cov==6.0.0
Separating production and testing dependencies
# requirements.txt (production)
langchain==0.3.14
openai==1.58.1
pydantic==2.10.4
# requirements-dev.txt (testing)
-r requirements.txt
pytest==8.3.4
pytest-cov==6.0.0
pytest-timeout==2.3.1
If you use separate files, the install step changes:
- name: Install dependencies
run: pip install -r requirements-dev.txt
pytest flags for CI
When you run pytest locally, the default is fine. In CI you need more information and more control.
-v (verbose)
# Without -v
tests/test_chain.py ..F.
# With -v
tests/test_chain.py::test_chain_initializes PASSED
tests/test_chain.py::test_chain_processes_input PASSED
tests/test_chain.py::test_chain_handles_empty_input FAILED
tests/test_chain.py::test_chain_returns_structured_output PASSED
In CI you always want -v. When a test fails, you need to know exactly which one it was.
--tb=short (short traceback)
# With --tb=long (default) — 20+ lines of traceback
# With --tb=short — one line with the concrete error:
FAILED tests/test_chain.py::test_chain_handles_empty_input - ValueError: Input cannot be empty
--tb=short reduces noise in the CI logs. You see what failed and why, without excessive traceback.
-x (stop on first failure)
pytest tests/ -v -x
Stops at the first test that fails. Useful when you have 200 tests and the first one already failed.
Recommended combination for CI
- name: Run tests
run: pytest tests/ -v --tb=short
If you have a large test suite and want fast feedback:
- name: Run tests (fail fast)
run: pytest tests/ -v --tb=short -x
Comparison table of flags
| Flag | Effect | When to use? |
|---|---|---|
-v | Shows the name of each test | Always in CI |
-vv | More detail in assertion diffs | Debugging in CI |
--tb=short | Summarized traceback | Recommended default in CI |
--tb=long | Full traceback | Debugging complex failures |
--tb=no | No traceback | You only want pass/fail |
-x | Stops at the first failure | Fast feedback |
-q | Minimal output | Huge test suites |
-rf | Summary of failures at the end | Always useful |
Reading pytest output in Actions logs
When a test fails in CI, you need to read the logs in GitHub Actions' UI:
Run pytest tests/ -v --tb=short
============================= test session starts ==============================
platform linux -- Python 3.12.8, pytest-8.3.4, pluggy-1.5.0
collected 12 items
tests/test_utils.py::test_format_prompt PASSED [ 8%]
tests/test_utils.py::test_validate_input PASSED [ 16%]
tests/test_chain.py::test_chain_processes_input FAILED [ 41%]
FAILED tests/test_chain.py::test_chain_processes_input - AssertionError: assert 'error' == 'success'
=========================== short test summary info ============================
FAILED tests/test_chain.py::test_chain_processes_input
========================= 1 failed, 4 passed in 0.28s =========================
What to look for in the logs
collected N items— Were all the tests found? If it says0 items, pytest didn't find your tests.FAILED— Which specific test failed.- The error message — After
FAILED, the line with-gives you the concrete error. passed in Xs— The total time. If it's suspiciously long, there may be slow tests.
PYTHONPATH: The most common problem in CI
This is the most frequent error when you move tests from local to CI:
ModuleNotFoundError: No module named 'src.ai_app'
Why does it happen?
Locally, your IDE configures the PYTHONPATH automatically. The CI runner doesn't have that configuration. When your test does from src.ai_app.chain import AIChain, Python doesn't know where to look for src.
Solution 1: PYTHONPATH in the workflow
- name: Run tests
run: pytest tests/ -v --tb=short
env:
PYTHONPATH: ${{ github.workspace }}
Solution 2: pyproject.toml with pythonpath
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
It works both locally and in CI — you don't need to configure anything in the workflow.
Solution 3: Install your project in editable mode
- name: Install project
run: pip install -e .
- name: Run tests
run: pytest tests/ -v --tb=short
It requires a pyproject.toml or setup.py with your package's config.
Which one to choose?
| Solution | Complexity | Works locally and in CI? | Recommended for |
|---|---|---|---|
PYTHONPATH in the workflow | Low | CI only | Simple projects |
pythonpath in pyproject.toml | Low | Yes | Most projects |
pip install -e . | Medium | Yes | Projects with packaging |
Recommendation: Use pyproject.toml with pythonpath = ["."]. It works in both environments and doesn't require changes to the workflow.
conftest.py: Shared fixtures in CI
The conftest.py file is where you define fixtures that multiple tests share. In CI, this is especially useful for configurations that depend on the environment:
# tests/conftest.py
import os
import pytest
@pytest.fixture
def api_key():
"""Return the API key, or skip if it doesn't exist."""
key = os.environ.get("OPENAI_API_KEY")
if not key:
pytest.skip("OPENAI_API_KEY not set — skipping integration test")
return key
@pytest.fixture
def sample_prompt():
"""Example prompt for tests."""
return "Explain what machine learning is in one sentence."
@pytest.fixture
def mock_llm_response():
"""Mock response from an LLM for unit tests."""
return {
"content": "Machine learning is a branch of AI.",
"model": "gpt-4o-mini",
"tokens_used": 42,
"cost": 0.00021,
}
Separating unit tests from integration tests
The api_key fixture lets you separate unit tests from integration tests automatically:
# tests/test_chain.py
import pytest
from src.ai_app.chain import AIChain
def test_chain_initializes():
chain = AIChain()
assert chain is not None
def test_chain_validates_input(mock_llm_response):
chain = AIChain()
assert chain.validate("test input") is True
@pytest.mark.integration
def test_chain_calls_llm(api_key):
"""This test only runs if there's an API key."""
chain = AIChain(api_key=api_key)
result = chain.process("Hello")
assert "content" in result
In CI without an API key:
tests/test_chain.py::test_chain_initializes PASSED
tests/test_chain.py::test_chain_validates_input PASSED
tests/test_chain.py::test_chain_calls_llm SKIPPED (OPENAI_API_KEY not set)
2 passed, 1 skipped in 0.15s
The unit tests always run. The integration tests get skipped when there's no API key.
Local vs CI: The differences that matter
These differences cause 90% of the "it works on my machine":
| Aspect | Local | CI (GitHub Actions) |
|---|---|---|
| Python version | Whichever one you have installed | The one you specify in the workflow |
| PYTHONPATH | Your IDE configures it | You have to configure it explicitly |
| Dependencies | Whatever you have in your venv | Only the ones in requirements.txt |
| Environment variables | .env, shell exports | Only the ones in the workflow |
| OS | Your OS (macOS, Windows) | Ubuntu (linux) |
| Filesystem | Your local files | Only what's in git |
| State | Persistent between runs | Clean every time |
Classic error: unlisted dependency
import rich # Installed with pip install rich, but it's not in requirements.txt
ModuleNotFoundError: No module named 'rich'
Locally it works because rich is in your virtualenv. In CI it fails because only what's in requirements.txt gets installed.
Checklist before pushing
- ✅ Are all the imports in
requirements.txt? - ✅ Are the paths relative to the project, not absolute paths from your machine?
- ✅ Do the tests that need files use fixtures or create them in the test?
- ✅ Are the required environment variables documented?
- ✅ Are there files in
.gitignorethat the tests need?
The complete recommended workflow
The best practice is to move the pytest configuration to pyproject.toml:
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-v --tb=short"
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
# Upgrade pip first — runner's pip can be months old with known bugs
python -m pip install --upgrade pip
pip install -r requirements.txt
# Bare "pytest" works because addopts in pyproject.toml injects -v --tb=short
- name: Run tests
run: pytest
With addopts in pyproject.toml, the -v --tb=short flags are applied automatically. The workflow stays clean and the configuration lives with the project.
Troubleshooting
"collected 0 items"
Cause: pytest didn't find any tests. The most common reasons:
- The test files don't start with
test_(e.g.chain_tests.pyinstead oftest_chain.py) - The test functions don't start with
test_(e.g.def chain_test()instead ofdef test_chain()) - The
tests/path doesn't exist or is spelled differently (e.g.test/vstests/)
# Locally, check what pytest finds
pytest tests/ --collect-only
"ModuleNotFoundError: No module named 'src'"
Cause: PYTHONPATH isn't configured in CI.
Solution: Add pythonpath in pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
Or add PYTHONPATH in the workflow:
- name: Run tests
run: pytest tests/ -v
env:
PYTHONPATH: ${{ github.workspace }}
"The test passes locally but fails in CI"
Cause: Differences between your local environment and the runner:
- Missing dependency in
requirements.txt - Local file that isn't in git (
.gitignore) - Environment variable that isn't in the workflow
- Hardcoded absolute path (
/Users/your-name/...)
Solution: Reproduce the clean environment locally:
python -m venv .venv-test
source .venv-test/bin/activate
pip install -r requirements.txt
pytest tests/ -v --tb=short
If it fails in this clean venv, it fails in CI.
Exercises
Exercise 1: Your first workflow with pytest
Create a workflow that runs pytest when there's a push to main or a pull request. The project has this structure:
my-app/
├── src/
│ └── calculator.py
├── tests/
│ └── test_calculator.py
└── requirements.txt
# src/calculator.py
def add(a: float, b: float) -> float:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# tests/test_calculator.py
from src.calculator import add, divide
import pytest
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_divide_normal():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
# requirements.txt
pytest==8.3.4
See solution
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/ -v --tb=short
env:
PYTHONPATH: ${{ github.workspace }}
Expected output:
tests/test_calculator.py::test_add_positive PASSED [ 25%]
tests/test_calculator.py::test_add_negative PASSED [ 50%]
tests/test_calculator.py::test_divide_normal PASSED [ 75%]
tests/test_calculator.py::test_divide_by_zero PASSED [100%]
4 passed in 0.08s
You need PYTHONPATH because the test does from src.calculator import... and the runner doesn't know where src is.
Exercise 2: Configure pyproject.toml to remove PYTHONPATH from the workflow
Take the workflow from the previous exercise and move the pytest configuration to pyproject.toml. The workflow should have neither PYTHONPATH nor pytest flags.
See solution
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-v --tb=short"
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest
Advantage: The pytest config lives with the project. It works the same locally and in CI. The workflow stays clean.
Exercise 3: Separate unit tests from integration tests with conftest.py
You have tests that call an LLM and tests that don't. Create a conftest.py that lets you run only the unit tests in CI (without an API key) and skip the integration tests automatically.
# tests/test_ai_chain.py
import pytest
def test_prompt_format():
"""Unit test - doesn't need an API key."""
prompt = f"Translate: {'hello'}"
assert "Translate:" in prompt
def test_response_parsing():
"""Unit test - doesn't need an API key."""
response = {"content": "hola", "tokens": 5}
assert "content" in response
assert response["tokens"] > 0
@pytest.mark.integration
def test_llm_call(api_key):
"""Integration test - needs an API key."""
assert api_key is not None
@pytest.mark.integration
def test_full_pipeline(api_key):
"""Integration test - needs an API key."""
assert len(api_key) > 10
See solution
# tests/conftest.py
import os
import pytest
def pytest_configure(config):
config.addinivalue_line(
"markers", "integration: marks tests as integration tests (require API key)"
)
@pytest.fixture
def api_key():
key = os.environ.get("OPENAI_API_KEY")
if not key:
pytest.skip("OPENAI_API_KEY not set")
return key
Output in CI without an API key:
tests/test_ai_chain.py::test_prompt_format PASSED [ 25%]
tests/test_ai_chain.py::test_response_parsing PASSED [ 50%]
tests/test_ai_chain.py::test_llm_call SKIPPED (OPENAI_API_KEY not set) [ 75%]
tests/test_ai_chain.py::test_full_pipeline SKIPPED (OPENAI_API_KEY...) [100%]
2 passed, 2 skipped in 0.05s
The api_key fixture does the skip automatically. You don't need to change anything in the workflow.
Exercise 4: Diagnose a broken workflow
This workflow fails in CI. Find the 3 errors and fix them:
# .github/workflows/test.yml
name: Tests
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Install dependencies
run: pip install -r requirements.txt
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Run tests
run: pytest tests/ -v
See solution
Error 1: actions/checkout@v4 is missing. Without checkout, requirements.txt and tests/ don't exist on the runner.
Error 2: The order is wrong. Install dependencies runs before Setup Python. Without Python configured, pip may not exist or may use the wrong version.
Error 3: Setup Python should come before Install dependencies.
Corrected version:
name: Tests
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/ -v
The order is always checkout → setup → install → run. Each step depends on the previous one.
Summary
- ✅ The 4-step pattern is always: checkout → setup python → install deps → run pytest
- ✅ Recommended flags in CI:
-v --tb=shortto see what failed without excessive noise - ✅ PYTHONPATH is the most common error when moving tests to CI — solve it with
pyproject.toml - ✅ conftest.py lets you separate unit tests from integration tests automatically
- ✅ Local vs CI differ in Python version, PYTHONPATH, dependencies, environment variables, and filesystem
- ✅ The Actions logs tell you exactly what happened: look for
collected,FAILED, and the error message - ✅
pyproject.tomlis better than flags in the workflow — the config lives with the project
Additional resources
- Building and testing Python - GitHub Actions - GitHub's official guide
- pytest Configuration - Configuring pytest with pyproject.toml
- actions/setup-python - Complete documentation for the action
- pytest Command Line Flags - Reference for all the flags
- conftest.py - pytest - Shared fixtures