Module 2: Your First Pipeline Pytest In Ci

5. Installing the dependencies on the runner

Description

After lesson 4, the runner has your code (checkout) and the correct Python (setup-python). But it's missing something to be able to run the suite: your dependencies. The runner ships "bare" Python, without pytest or any third-party library your project uses. This lesson installs those packages. By the end you'll understand why the runner starts without your libraries, how requirements.txt is the canonical list of what your project needs, and how the two commands python -m pip install --upgrade pip and pip install -r requirements.txt turn it into packages installed on the Python you pinned.

This is the first lesson of the module where a part runs for real. Installing dependencies with pip is something you do in your own terminal, so the pip output you'll see is real, captured from an authentic installation. It's still true that the complete workflow is content we explain; but the install-dependencies step you can reproduce yourself and get the same, which connects directly with the local parity you'll build in the mini-project.

Connection to the module: this step leans on lesson 4's (it installs on the Python setup-python activated) and prepares lesson 6's (pytest can only run if pytest is installed). There's a boundary we touch carefully: why it's worth pinning exact versions in requirements.txt —so CI installs today the same as tomorrow— we mention as motivation, but the topic in depth (the environment gap, deterministic installs, reproducing a CI failure your local doesn't have) is module 3. Here we install the dependencies; module 3 deals with that installation being identical everywhere.

The recipe that brings its own ingredient list

Let's go back to lesson 2's cooking recipe, but focus on something we left out: the ingredients. A well-written recipe doesn't just say the steps ("mix, bake"); it first brings a list of ingredients with exact quantities: "2 cups of flour, 3 eggs, 200 g of butter". That list exists because whoever's going to cook doesn't necessarily have those ingredients in the pantry already. The hotel kitchen —the runner— is clean and equipped with the basics, but it doesn't have your special flour or your eggs; you bring them, and the list tells you exactly which and how many.

requirements.txt is that ingredient list. It's a text file, part of your project, that lists each Python package your code needs to function, with its version. When the runner reaches the install step, it doesn't guess which libraries are needed: it reads the list and brings exactly what it says. And the beauty of having the list inside the project is that anyone —the runner, you on a new machine, a teammate who just cloned the repo— installs the same ingredients in the same quantities. The list is what makes "install the dependencies" a repeatable operation instead of a hunt for "what was I missing?".

For Reservo the list is short, almost embarrassingly short, and that's on purpose: Reservo is pure standard-Python logic, with no external libraries for its code. Its only ingredient is the tool it's tested with.

requirements.txt: the list of what's needed

This is Reservo's requirements.txt, in full:

# requirements.txt
pytest==9.1.1

One line. Reservo's code uses no third-party library —only Python's standard library—, so the only thing to install to test it is pytest, the tool that runs the tests. The line pytest==9.1.1 says "install pytest, exactly version 9.1.1".

Notice the ==9.1.1. That double equals pins (fixes) the exact version. You could write just pytest with no version, and pip would install the most recent it finds the day it runs —which sounds convenient until a new version of pytest changes something and your CI, green yesterday, comes up red today without you touching anything—. Pinning with ==9.1.1 guarantees the runner installs today the same as tomorrow: the version you tested, not "whatever comes out". That's the first brushstroke of a big topic —deterministic installs— that module 3 develops in depth; for now hold on to the intuition: a list with exact versions is a reproducible list.

A real project would have more lines —one for each library it uses, each with its ==version—, but the form is always this: one package per line, with its version pinned. The list can grow; the mechanics of installing it don't change.

The two commands that install the list

The step that installs the dependencies runs two commands, in this order:

- run: python -m pip install --upgrade pip
- run: pip install -r requirements.txt

Or, more commonly, the two together in a single step with YAML's vertical bar |, which allows several command lines within one run:

- name: Install dependencies
  run: |
    python -m pip install --upgrade pip
    pip install -r requirements.txt

The | after run: means "what follows is several lines, run them in order". It's the clean way of grouping commands that go together conceptually ("install dependencies") into a single step with a single name. The two commands do this:

One: python -m pip install --upgrade pip. It updates the pip tool itself to its latest version before using it. The pip the runner ships by default may be old, and an old pip sometimes has trouble resolving dependencies or shows annoying warnings. Updating it first is hygiene: you make sure to install your packages with a recent and healthy version of the tool. It's a cheap step that prevents a class of hard-to-diagnose failures. (We use python -m pip instead of just pip for the same reason we use python3 -m pytest in the fundamentals guide: it guarantees you update the pip of this Python, the one setup-python activated, without ambiguity.)

Two: pip install -r requirements.txt. Here the real installation happens. The -r flag means "read requirements from this file": pip opens requirements.txt, reads each line, and downloads and installs each package with the indicated version. It's the direct translation of "bring the ingredients from the list". After this command, pytest (and any other dependency the list had) is installed on the runner's Python, ready for the next step.

Worked example: the real output of pip install -r requirements.txt

Here's the part that runs for real. On a clean machine with Python 3.14 —the same state setup-python leaves the runner in—, we ran the two commands. This is the real output of installing requirements.txt:

pip install -r requirements.txt

What to expect:

Collecting pytest==9.1.1 (from -r requirements.txt (line 1))
  Using cached pytest-9.1.1-py3-none-any.whl.metadata (7.6 kB)
Collecting iniconfig>=1.0.1 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached iniconfig-2.3.0-py3-none-any.whl.metadata (2.5 kB)
Collecting packaging>=22 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached packaging-26.2-py3-none-any.whl.metadata (3.5 kB)
Collecting pluggy<2,>=1.5 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached pluggy-1.6.0-py3-none-any.whl.metadata (4.8 kB)
Collecting pygments>=2.7.2 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached pygments-2.20.0-py3-none-any.whl.metadata (2.5 kB)
Installing collected packages: pygments, pluggy, packaging, iniconfig, pytest
Successfully installed iniconfig-2.3.0 packaging-26.2 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1

Read it in layers, because each part says something:

  • The Collecting lines are pip resolving the list. You asked for pytest==9.1.1 (the first), but pytest depends on other libraries to work —iniconfig, packaging, pluggy (its plugin engine), pygments (to color the output)—, and pip brings them on its own. Notice each one says where it came from: from pytest==9.1.1->-r requirements.txt (line 1) means "this one is needed by pytest, which you asked for on line 1 of the file". You listed one ingredient; pip brought the five that are needed.
  • Using cached means pip found the package already downloaded in its local cache and didn't have to fetch it from the internet. On a totally fresh machine it would say Downloading instead. It's a speed detail; the result is the same. (Caching dependencies in CI to speed up runs is, by the way, exactly module 5's topic —here you're only seeing pip's local cache, another thing—.)
  • Installing collected packages: ... lists, in install order, everything pip is going to put in place. Note it installs the dependencies before pytest (pytest goes last), because pytest needs them already present.
  • Successfully installed ... is the verdict, the line that matters. It confirms what got installed and with which version: pytest-9.1.1, the one you asked for, plus its four dependencies. If you see this line, the runner already has everything needed to run the suite.

The exact numbers of the dependencies (packaging-26.2, pygments-2.20.0) can vary a bit depending on the day, because we asked for them with >= (pytest accepts "this or newer" for its dependencies). What doesn't vary is pytest-9.1.1, because we pinned it with ==. There you see, live, the difference between a fixed version and a flexible one: pytest always comes out the same; its dependencies, "the newest compatible". And there you also see why pinning what matters to you gives reproducibility.

You can reproduce this output on your machine today: create a virtual environment with Python 3.14, put pytest==9.1.1 in a requirements.txt, and run the command. You'll get essentially this. Since the runner reaches the install step in that same state (clean Python, dependencies to install), what you see locally is what CI would see.

Why install from a file and not by hand

You could, in theory, write run: pip install pytest==9.1.1 directly in the workflow, without a file. For Reservo, with a single dependency, it's almost the same. But installing from requirements.txt is better practice for a reason that scales:

The list lives in a single place, and everyone uses it. Your workflow installs from requirements.txt; you, on your machine, install from the same requirements.txt; a teammate who clones the repo, too. There's a single source of truth about "what this project needs", and it's versioned alongside the code. When you add a dependency, you put it in the file once, and automatically CI, you, and the whole team install it. If instead you listed the packages by hand inside the YAML, you'd have two lists (the workflow's and the one you install locally) that have to be kept in sync —and that sooner or later diverge, producing the classic "CI is missing a package I have locally"—.

That divergence between CI's environment and yours is, precisely, the number-one source of "it works on my machine but not in CI", and it's the heart of module 3. Installing from a shared requirements.txt is the first defense against it: if both install from the same list, they start from the same place. It doesn't solve everything (module 3 will see there are more nuances), but it's the base habit.

Where a requirements.txt comes from

A natural question: if the list has to enumerate each dependency with its version, do you write it by hand one by one? For Reservo, with one line, yes, it's trivial. For a project with twenty dependencies, writing them by hand would be tedious and error-prone. The tool that helps is pip freeze:

pip freeze

What to expect (in Reservo's environment, after installing pytest):

iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
Pygments==2.20.0
pytest==9.1.1

pip freeze prints everything installed in the current environment, each package with its exact version, in requirements.txt format. Notice something interesting: it lists the five packages —pytest and its four dependencies—, not just the one you asked for. That's on purpose: capturing the complete state of the environment, including the dependencies of your dependencies, is what makes an installation truly reproducible. You can dump that output to a file with pip freeze > requirements.txt to "freeze" (hence the name, freeze) the exact environment you tested.

Here there's a tension worth naming, even though its resolution is module 3's: a hand-written requirements.txt usually lists only what you ask for directly (pytest==9.1.1), letting pip resolve the rest with ranges; a requirements.txt generated with pip freeze lists everything, pinned in detail, so the environment rebuilds identically down to the last transitive dependency. The first is more readable; the second, more reproducible. Which is best, and the newer tools that separate "what I ask for" from "what gets resolved" (lock files), is precisely the ground of module 3's deterministic installs. For now hold on to the mechanism: pip freeze shows you —and, redirected to a file, saves for you— the exact state of your environment, and that snapshot is the basis of reproducibility.

Common mistakes

Running pytest without installing the dependencies and hitting "no module named pytest" (absent dependency). What happens: someone writes a workflow with checkout and setup-python, jumps straight to run: pytest, and it fails because pytest isn't installed —the runner ships bare Python, without pytest—. Why it happens: on your machine pytest is "always there" because you installed it once long ago and forgot; the clean runner isn't. How to spot it: "no module named pytest" (or any other library) in CI almost always means the install step is missing. How to fix it: include pip install -r requirements.txt (with pytest in the list) before the pytest step. The runner only has what you install explicitly.

Not pinning the versions and watching CI break "on its own" (floating dependency). What happens: someone puts pytest without ==version in requirements.txt; for weeks everything goes fine, and one day CI comes up red without anyone touching the code —a new version of pytest came out and changed a behavior—. Why it happens: without a pin, pip installs "the most recent" each time, so the environment changes under your feet. How to spot it: a CI that breaks without your changes, and a diff that explains nothing, points to a dependency that moved. How to fix it: pin the versions that matter with == (like pytest==9.1.1), so CI always installs the same. It's the base intuition; module 3 goes deeper into fully deterministic installs.

Keeping two dependency lists that diverge (duplicated source). What happens: someone lists the packages by hand in the YAML (run: pip install pytest requests) and, separately, in a requirements.txt they use locally; over time they add a library to one but not the other, and CI fails for a package that "is there on my machine". Why it happens: two lists that should say the same but are edited separately always end up disagreeing. How to spot it: if your workflow installs packages with names written by hand instead of -r requirements.txt, you have a hidden second list. How to fix it: a single source of truth —the requirements.txt— and have both the workflow and you install from it with -r. When it changes, it changes in one place and everyone stays in sync.

Exercises

Exercise 1 — Read pip's output. Look at this final line of an installation and answer: how many packages were installed in total?, which one did you ask for explicitly in requirements.txt and which are dependencies pip brought on its own?, and which of them all has the version guaranteed to be exactly that?

Successfully installed iniconfig-2.3.0 packaging-26.2 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1
See solution
  • Total installed: five packages (iniconfig, packaging, pluggy, pygments, pytest).
  • Asked for explicitly: only pytest, which is the only line of your requirements.txt (pytest==9.1.1). The other four —iniconfig, packaging, pluggy, pygments— are pytest's dependencies that pip resolved and brought on its own, because pytest needs them to work.
  • Version guaranteed to be exactly that: pytest 9.1.1, because you pinned it with ==9.1.1. The other four are asked for by pytest with >= (a minimum or newer version), so their numbers can vary day to day; that's why you could see packaging-26.2 today and another patch tomorrow, but pytest will always come out 9.1.1.

The lesson: a single == in your list propagates to a tree of dependencies, and you control precisely what matters to you (pytest) while letting pip resolve the rest within the ranges each package accepts.

Exercise 2 — Write the install step. A project needs, besides pytest, the httpx library in its version 0.28.1. Write (a) the two lines of requirements.txt and (b) the workflow step, with a name, that upgrades pip and installs from the file using YAML's |.

See solution

(a) requirements.txt:

pytest==9.1.1
httpx==0.28.1

One package per line, each with its version pinned with ==.

(b) The workflow step:

- name: Install dependencies
  run: |
    python -m pip install --upgrade pip
    pip install -r requirements.txt

Notice what didn't change from Reservo's: the step is identical. Even though the project now has two dependencies instead of one, the install step isn't touched —only the requirements.txt changed—. That's the advantage of installing from the file: the list grows, the mechanics don't. The | groups the two commands under a single step named "Install dependencies", which will read nicely in the CI log (lesson 7).

Exercise 3 — Diagnose the "on my machine it does". A teammate has this step in their workflow and CI fails with "no module named requests", even though on their machine the tests run perfectly. Their requirements.txt contains only pytest==9.1.1. What happened and how is it fixed?

- name: Install dependencies
  run: |
    python -m pip install --upgrade pip
    pip install -r requirements.txt
- run: pytest
See solution

What happened: the teammate's code uses the requests library, but requests isn't in their requirements.txt (which only lists pytest). On their machine the tests run because they installed requests by hand a while ago and have it "loose" in their environment —they forgot—. The runner, on the other hand, installs only what the list says, so it doesn't bring requests, and when running the tests the import fails with "no module named requests".

This is module 1's "it works on my machine" in its purest form: the teammate's local environment has a package their dependency list doesn't declare, so CI —which starts from the list— reveals the omission. CI isn't broken; it's doing its job, exposing an undeclared dependency.

The fix is to add the missing dependency to the list:

pytest==9.1.1
requests==2.32.5

Now the requirements.txt declares everything the project needs, and the runner (and any new machine) will install requests too. The moral: if a package is needed for the code to run, it has to be in requirements.txt, not just "installed somewhere" on your machine. Module 3 is dedicated entirely to closing this class of gaps between your environment and CI's.

Summary and next step

In this lesson you installed the dependencies on the runner —the step that turns "I have Python" into "I have something to run the suite with"—. You understood that the runner ships bare Python, without your libraries, and that requirements.txt is your project's ingredient list: one package per line, with the version pinned with == so CI installs today the same as tomorrow. You saw the two commands —python -m pip install --upgrade pip for a healthy tool, and pip install -r requirements.txt to bring the list— and read the real output of pip: how a single pytest==9.1.1 drags in its four dependencies, how the Successfully installed is the verdict, and how the pinned always comes out the same while the flexible varies. And you understood why installing from a shared file —a single source of truth— is the first defense against "it works on my machine".

Before moving on you should be able to: explain why the runner needs an install step; write a requirements.txt with pinned versions and the step that installs it; read a pip output and distinguish what you asked for from what pip brought on its own; and recognize an undeclared dependency as a cause of a CI failure.

The runner is now completely equipped: code, correct Python, and installed dependencies. Only the step everything exists for is missing. In lesson 6 we reach the heart of the module: run: pytest. You're going to see how CI discovers and runs your suite exactly the same as locally —with the real green output, 11 passed, run for real—, and you're going to understand the exit code: the number pytest returns on finishing (0 if everything passed, 1 if something failed, 5 if it found no tests) and that GitHub reads to paint the job green or red. It's the lesson that connects "I ran the tests" with "the pipeline knows how it went".

Resources