Running Tests in CI
Linting proves your code is tidy. It says nothing about whether send_file.py and receive_file.py actually do what they’re supposed to. That’s what the test suite in tests/ is for — and this chapter is where it finally runs on every push, not just when you remember to run it yourself.
From Local pytest To CI pytest
You already know how to run this locally:
python3 -m pytest tests/The CI version is nearly identical — same command, different machine. The only real question is what to point it at.
Adding The Test Step
- name: Run Tests
run: python -m pytestNote the bare python -m pytest, with no path argument. That’s deliberate, and worth understanding rather than copying blindly.
Test Discovery: Why python -m pytest Just Works Here
Pytest doesn’t need to be told where your tests live — it searches the current directory (and subdirectories) for files matching its discovery rules: anything named test_*.py or *_test.py, containing functions named test_*. In our project, that means tests/test_send_file.py gets found automatically the moment you run pytest from the project root, no path needed.
Passing tests/ explicitly (like the local command above does) isn’t wrong — it’s just being specific about where to look, which is a fine habit when a project has test files scattered in more than one place. For a project this size, letting discovery do the work keeps the CI step identical to what you’d type locally, minus the extra argument.
Tip
If a future project has tests mixed in with non-test code, or you don’t want pytest walking the whole repo, that’s when pointing it at a specific path (pytest tests/) or a config file (pyproject.toml with a [tool.pytest.ini_options] section) starts to matter. For our project, plain python -m pytest is exactly enough.
When Tests Fail: Reading CI Output Vs Local Output
The failure output itself looks the same in CI as it does on your machine — pytest’s traceback formatting doesn’t change based on where it’s running. What’s different is what you can do with a CI failure versus a local one.
Locally, a failing test is right there — you can drop into a debugger, print variables, rerun with -v for more detail, poke at things interactively. In CI, you only get whatever pytest printed to the log before the runner tears itself down. That’s a strong argument for writing test failure messages and assertions that are informative on their own — a bare assert result that fails gives you nothing to go on in a log; assert result == expected, f"got {result}" gives you the actual value without needing to reproduce the failure locally first.
Hands-On: The Full Pipeline, Lint + Test Gate
Update .github/workflows/ci.yml with the test step added after linting:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: "pip"
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint (ruff)
uses: astral-sh/[email protected]
- name: Run Tests
run: python -m pytestThat’s the shape of the pipeline this chapter set out to build:
flowchart LR
A[Checkout] --> B[Setup Python]
B --> C[Install Dependencies]
C --> D[Lint - ruff]
D --> E[Run Tests - pytest]
Commit and push:
git add .github/workflows/ci.yml
git commit -m "Add pytest step: full lint + test CI pipeline"
git pushOpen the Actions tab and confirm all five steps go green, with the test log showing the same 1 passed result you saw running it locally.
To see the gate actually catch something, break a test on purpose. Open tests/test_send_file.py and change an assertion to something false, commit, and push — watch the “Run Tests” step fail while “Lint (ruff)” still passes, which confirms the two gates are checking genuinely different things. Then revert the change:
git revert HEAD
git pushYou now have a real pipeline: every push gets a fresh runner, a pinned Python version, cached dependencies, a lint gate, and a test gate — running the same checks, the same way, every single time, with no step depending on anyone remembering to run it by hand.
Right now this pipeline only proves the code works on one Python version, on one operating system. The next chapters take this exact workflow and stretch it across multiple versions and platforms at once — and along the way, walk through two real mistakes that are easy to make when you do.