Module 1: From Your Machine To The Pipeline
2. "It works on my machine"
Description
By the end of this lesson you'll understand the most famous —and most dangerous— phrase in software development: "it works on my machine". It's said by someone whose code broke for another person or in production, and whose only evidence that it "works" is that on their machine the suite was green. It sounds like a defense, but it's the opposite: it's the confession that the test was done in the one place where it doesn't quite count —your own machine, with everything you installed, configured, and forgot—. The phrase doesn't close the case; it opens it. It says: "I tested in an environment only I have, and I don't know what about that environment made the test pass".
You're going to see it not in theory but measured: the same test, with the same code, giving two opposite verdicts —green for you, red in a clean environment— without changing a single line of the test or of the function it tests. The only thing that will change between the green and the red is the environment. And when you see that real output, you'll understand all at once why "it works on my machine" isn't an excuse to forgive, but a symptom to diagnose: somewhere your environment is giving the suite something that another environment doesn't have. You'll also walk away with a map of the places where the environment sneaks into your tests, so you can recognize them before they bite you.
Connection to the module: this lesson is the problem in the flesh. Lesson 1 named it —"your tests only protect the place where they run"—; here you watch it happen, with real output. Lesson 3 will present the solution: a clean and shared environment that runs the suite for everyone, i.e., CI. Lesson 4 will explain why it's worth having that environment catch the failure as early as possible. And the entire module 3, later on, is dedicated to reproducing on your machine a failure that only appears in the clean environment —the art of closing this gap—. In other words: here we open the wound that the rest of the guide closes.
The recipe that only turns out in your kitchen
Think of it this way. Your grandmother gives you her bread recipe, written in full detail: the grams, the times, the oven temperature. You follow it to the letter in her kitchen, with her beside you, and the bread comes out perfect. You take the recipe home, follow it just as exactly… and the bread comes out flat and raw inside. Was the recipe lying? No. It's that the recipe took for granted a hundred things about her kitchen that she never wrote down: that her gas oven runs twenty degrees hotter than the dial, that her flour is a different grind, that in her town the water is harder, that her kitchen is a thousand meters above sea level and yours is at sea level. The recipe worked —in her kitchen—. Outside of it, it depended on things not even she knew mattered.
"It works on my machine" is exactly that bread. The test is the recipe; your machine is grandma's kitchen. The test passes because your machine, without you ever having written it anywhere, is giving it something —an environment variable, a version, a package, a time zone— that the test needs and takes for granted. In someone else's kitchen, that something isn't there, and the bread comes out raw. The recipe wasn't lying; it was incomplete, propped up by invisible assumptions of the place where it was written.
A good professional baker solves this in a very concrete way: they write the complete recipe, with no assumptions —"oven at 220 °C actual, measured with a thermometer; strong flour W300; 70% hydration"— and they test it in a different kitchen from their own to discover what they'd taken for granted. That act —taking the recipe to a neutral kitchen and seeing if it turns out— is exactly what CI does with your code. It takes your suite, moves it to a machine that isn't yours, that starts empty, and sees if the bread turns out. If it does, the recipe was complete. If not, you just discovered an invisible assumption —before a customer discovers it with raw bread—.
"It works on my machine" isn't a defense: it's a half-done diagnosis. It means "something about my environment makes the test pass, and I don't know what". The job is to find that something and make it explicit —or let a clean environment find it for you.
Worked example: the same test, two verdicts
We're going to build the cleanest possible scene of the phenomenon, and actually run it. We need a test that depends on something in your environment another machine doesn't have guaranteed. The perfect culprit, because it's common and treacherous: an environment variable.
Imagine that someone, instead of leaving the pro discount fixed in Reservo's code the way price_cents does, decides to read it from an environment variable —"that way I can change it without touching the code", they told themselves—. They write this fragile version:
# env_pricing.py — a FRAGILE version of price_cents that reads the discount
# from the environment. Anti-pattern: the result depends on a shell variable.
import os
from reservo.models import Room, Member
def price_with_env(room, member, hours):
# Reads the pro discount percentage from an environment variable.
# If it's not defined, falls back to 0 (no discount).
pro_discount = int(os.environ.get("RESERVO_PRO_DISCOUNT", "0"))
subtotal = room.hourly_cents * hours
if member.tier == "pro":
subtotal -= subtotal * pro_discount // 100
return subtotal
And they write a perfectly reasonable test for it, asserting the usual anchor number: a pro pays 3 hours of Focus at 6000 cents.
# test_env_pricing.py
from env_pricing import price_with_env
from reservo.models import Room, Member
focus = Room(id="r-focus", name="Focus", capacity=1, hourly_cents=2500)
bruno = Member(id="m-2", name="Bruno", tier="pro")
def test_pro_3h_is_6000():
# Guide anchor: a pro pays 3h of Focus at 6000 cents.
assert price_with_env(focus, bruno, 3) == 6000
Our developer, months ago, exported export RESERVO_PRO_DISCOUNT=20 in their shell —to test something— and forgot. That line is still alive in their session. So when they run the test, their environment gives it the 20 the function needs, and the bread comes out perfect.
What to expect (case A: their machine, with the variable exported). Running RESERVO_PRO_DISCOUNT=20 python3 -m pytest test_env_pricing.py -v:
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
test_env_pricing.py::test_pro_3h_is_6000 PASSED [100%]
============================== 1 passed in 0.01s ===============================
Green. 1 passed. Our developer sees this, breathes easy, and pushes the code. To them, "it works". And here's the trap: they're right, it works —on their machine—. They're not lying or being careless on purpose. Their evidence is real. The problem is that their evidence only covers their kitchen.
Now that code reaches another machine. It could be a teammate's who never exported that variable. It could be the CI runner, which —like any CI machine— starts clean, without the variables you have in your shell. In that environment, RESERVO_PRO_DISCOUNT doesn't exist, so the function falls back to its default —0, no discount— and charges the pro the full rate: 7500 instead of 6000.
What to expect (case B: a clean environment, without the variable). Running the same test, with the same code, but in a shell where the variable isn't set —env -u RESERVO_PRO_DISCOUNT python3 -m pytest test_env_pricing.py -v—:
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
test_env_pricing.py::test_pro_3h_is_6000 FAILED [100%]
=================================== FAILURES ===================================
_____________________________ test_pro_3h_is_6000 ______________________________
def test_pro_3h_is_6000():
# Guide anchor: a pro pays 3h of Focus at 6000 cents.
> assert price_with_env(focus, bruno, 3) == 6000
E AssertionError: assert 7500 == 6000
E + where 7500 = price_with_env(Room(id='r-focus', name='Focus', capacity=1, hourly_cents=2500), Member(id='m-2', name='Bruno', tier='pro'), 3)
test_env_pricing.py:11: AssertionError
=========================== short test summary info ============================
FAILED test_env_pricing.py::test_pro_3h_is_6000 - AssertionError: assert 7500 == 6000
============================== 1 failed in 0.03s ===============================
Stop here, because this is the central scene of the whole module. Not a single line of the test changed. Not a single line of the code changed. The only thing that changed was the environment —the presence or absence of a shell variable— and the verdict flipped: from 1 passed to 1 failed. The assert 7500 == 6000 shows you exactly the raw bread: without the variable, the discount wasn't applied, and the pro paid 7500.
This is, in its purest form, the anatomy of "it works on my machine". The developer in case A isn't a liar or careless: their green is real. But their green depended on something in their environment —that forgotten variable— that they never declared and that someone else doesn't have. CI is, no more and no less, case B running automatically: a clean machine that says, without diplomacy, "your recipe was incomplete; in a neutral kitchen, the bread comes out raw". Better that CI tells you today, in a red check on a PR, than a customer tomorrow with an overcharge.
Where the environment sneaks in: the map of the leaks
The environment variable is just one of the doors through which your machine hands a test something another machine doesn't have. It's worth knowing the main ones, because module 3 is dedicated to closing them and here we name them so you recognize them. We call each one an environment leak: an invisible assumption your test depends on without declaring it.
- Environment variables. The one you just saw. Your shell has
RESERVO_PRO_DISCOUNT,DATABASE_URL,API_KEY,TZ… and the test leans on one without saying so. On another machine, or on a clean runner, they aren't there. - The Python version. Your code runs on
3.14; your teammate has3.11; the runner,3.12. A function that exists in 3.14 and not in 3.11, or a behavior that changed between versions, makes it green here and red there. Theplatform ... -- Python 3.14.0header of each pytest output is exactly the datum that gives this leak away. - The installed packages and their versions. You have
pytest 9.1.1andrequests 2.31; someone else hasrequests 2.20, where a function behaved differently. Or you have a package installed that the project uses but never declared in its dependencies: on your machine it's there "by coincidence", on the clean one it isn't. - The operating system and its paths. You're on macOS (
platform darwin), the runner on Linux. File paths (/Users/...vs/home/...), the separator (/vs\on Windows), the line ending, case sensitivity in file names: all of that differs, and a test that hardcodes a path breaks when it crosses systems. - The time zone and the language (locale). Your machine is on Mexico City time; the runner, on UTC. A test that formats a date, or that compares times carelessly, gives a different result depending on the zone. The system language changes how text is sorted or how a decimal number is written.
- The wall clock. A test that reads "now" with
datetime.now()internally depends on when you run it. It passes today and fails tomorrow, or passes at 23:00 and fails at 00:00. It's the most slippery leak because the environment that changes is time. (The fundamentals guide attacks it by controlling the clock with aClock; here it's enough to know it exists.) - The order and shared state. If one test leaves "garbage" —a file, a global variable, an entry in a list— and another test depends on that garbage, the suite passes when they run in a certain order and fails in another. Your machine and the runner can discover the tests in a different order, and there the leak springs.
Notice the common pattern: in all cases, the test depends on something that isn't written in the test. It's in your shell, in your installation, in your system, in your clock. A truly robust test declares —or controls— everything it needs, so that its verdict doesn't depend on where it runs. CI is the machine that forces that discipline on you, because it starts without any of your assumptions and shows you, one by one, which ones you'd taken for granted.
Common mistakes
Treating "it works on my machine" as the end of the conversation. What happens: someone reports that the code fails, the author runs it on their machine, sees green, replies "well it works on mine" and closes the ticket. The bug is still there for everyone else. Why it happens: the local green feels like conclusive proof, and it's more comfortable to close the case than to investigate an environment difference. How to spot it: if your only evidence that something works is "I ran it on my machine", you don't have a conclusion, you have a starting point. How to fix it: treat the phrase as the start of a diagnosis. The right question isn't "does it work on my machine?" but "what does my machine have that the other one doesn't?". And the systematic way to answer it is to run in a clean environment —CI—, which is where the whole guide goes.
Blaming the messenger when CI turns red and you're green. What happens: CI reports a failure, the developer runs the suite locally, sees it green, and concludes "CI is misconfigured / CI is broken". Sometimes it is, but most of the time CI is right and your machine is fooling you with an assumption only you have. Why it happens: it's easier to suspect someone else's machine than your own. How to spot it: if your automatic reaction to a red CI is "but on my machine it passes", you're about to make this mistake. How to fix it: reverse the suspicion. CI starts clean; your machine drags months of configuration. When they differ, the most likely candidate to be "dirty" is you. Reproducing that red on your machine is exactly the topic of module 3.
Confusing "the test is fragile" with "the code is correct". What happens: someone sees the example's test fail in CI and concludes they need to "fix the test" so it passes —for example, by exporting the variable in CI too—. Sometimes that's legitimate, but often the fragile test is signaling a real problem in the code: that price_with_env depends on a shell variable is a bad idea, the variable can be missing in production and overcharge a customer. Why it happens: when a test bothers you, the temptation is to silence it, not to listen to it. How to spot it: ask yourself "if this variable is missing in production, what happens?". If the answer is "an incorrect charge", the test isn't overly fragile: it's warning of a real bug. How to fix it: distinguish the two causes. If the test depends on the environment out of carelessness (a hardcoded path), fix the test. If it depends on the environment because the code depends on the environment in a dangerous way (a price decided in the shell), fix the code —or at least declare and control that environment explicitly—.
Exercises
Exercise 1 — Classify the leak. For each test that fails when changing machines, say which of the environment leaks from the map is the culprit (variable, Python version, package, OS/path, time zone/locale, clock, order/state). (a) It passes on your Mac and fails on the Linux runner because it opens C:\Users\...\data.csv… no, because it opens /Users/ana/data.csv, which doesn't exist over there. (b) It passes today and fails on January 1st because it compares against the current year. (c) It passes when you run the whole suite and fails when you run only that test. (d) It passes with your pandas 2.2 and fails with your teammate's pandas 1.5.
See solution
- (a) OS / paths. The path
/Users/ana/data.csvis specific to your machine (your user, your file system). On the Linux runner that file doesn't exist at that path. The fix: never hardcode absolute paths; build them relative to the project or pass them via configuration. - (b) Wall clock. The test reads the current year (something like
datetime.now().year), so its result depends on when it's run. Today it gives one thing, on January 1st another. The fix: control the "now" instead of reading it from the real clock (theClockfrom fundamentals). - (c) Order / shared state. Passing with the full suite and failing in isolation (or the reverse) is the signature of a test that depends on state another left, or on the discovery order. The fix: each test should set up and clean up its own, without leaning on what another did.
- (d) Package and its version. The behavior changed between
pandas 1.5and2.2. Your version makes it green; your teammate's, red. The fix: pin the dependency versions so everyone —and CI— uses the same one (topic of module 3).
The lesson: "it works on my machine" isn't a single failure, it's a family. Knowing which leak is the culprit is the first step to closing it, and each one is closed differently.
Exercise 2 — Predict the verdict. Go back to the worked example. Our developer, to "fix" the red in CI, decides to run unset RESERVO_PRO_DISCOUNT on their machine and run the test locally again. Without running it, predict: (a) what verdict will they see now on their machine, green or red? (b) What does that teach them about the CI failure? (c) What would a real fix be, not a patch?
See solution
- (a) Red. By doing
unseton the variable, their shell becomes like CI's clean environment: withoutRESERVO_PRO_DISCOUNT. The function falls back to the default discount (0), charges the pro 7500, and theassert 7500 == 6000fails. They'll see the same1 failedas CI. - (b) It teaches them that CI was right. They just reproduced the CI failure on their own machine, by removing the invisible assumption that was hiding it. CI wasn't broken: their machine was "dirty" with a variable they'd forgotten. Reproducing the failure this way is exactly the technique of module 3.
- (c) The real fix is to remove the environment dependency, not to fit the environment to the test. The pro discount shouldn't live in a shell variable that can be missing in production: it should be a code constant (like the
PRO_DISCOUNT_PERCENT = 20of Reservo's realprice_cents), or an explicit configuration value validated at startup. Exporting the variable "in CI too" would be a patch: it would leave the bug latent for the day it's missing in production.
The lesson: unset on your machine is the cheapest way to simulate CI's clean environment and reproduce its red. And the correct fix is almost never "give the test the environment it's missing", but "remove the test's (or the code's) dependency on the environment".
Exercise 3 — Write the honest confession. "It works on my machine" is a phrase that hides an assumption. Rewrite it as an honest and complete confession for the worked example's case: a sentence that says exactly what the green depends on. Then explain why that honest version, said out loud, practically fixes itself.
See solution
An honest confession would be something like:
"The test passes on my machine because I have the environment variable
RESERVO_PRO_DISCOUNT=20exported in my shell, which theprice_with_envfunction needs to apply the discount. I haven't verified that this variable is present on anyone else's machine, nor in CI, nor in production."
Why it almost fixes itself: the moment you say the dependency out loud, the next question is obvious and you ask it yourself —"and is that variable in production?"—. The short version, "it works on my machine", hides exactly that question, and that's why it's dangerous: it's not that it lies, it's that it silences the assumption. The honest version puts it on the table, and once on the table, no one in their right mind leaves a price depending on a shell variable that can be missing. CI does this for you without asking for your honesty: since it starts without your assumptions, it brings them to light one by one, with a red check that can't be silenced.
Summary and next step
In this lesson you saw, measured and not told, the phenomenon that gives meaning to the whole guide: "it works on my machine". The same test, with the same code, gave 1 passed in an environment with a shell variable and 1 failed in a clean environment without it —assert 7500 == 6000—. The only thing that changed was the environment. That's why the phrase isn't a defense but a half-done diagnosis: it means "something about my environment makes the test pass, and I don't know what". It's grandma's recipe that only turns out in her kitchen, propped up by assumptions she never wrote down.
You also walk away with the map of the leaks where the environment sneaks in: variables, Python version, packages, operating system and paths, time zone and locale, wall clock, and order/shared state. They all share the same root —the test depends on something not written in the test— and they're all closed by taking the test to a clean and declared environment.
Before moving on you should be able to: explain why "it works on my machine" is a symptom and not an excuse; name at least four environment leaks; and say, for the worked example, what changed between the green and the red (the environment, not the code).
What's next is putting a name and a shape to the solution. In lesson 3 you'll see what Continuous Integration is exactly: the clean and shared machine that runs "case B" automatically, for everyone, on every change —so the failure is found by a red check today and not a customer tomorrow—.
Resources
- Environment variables in GitHub Actions — GitHub documentation — how CI handles (and doesn't inherit) your shell's variables. Read it to understand why the runner starts "clean" of the variables you have exported, which is the worked example's leak.
os.environ— Python documentation — how Python reads environment variables, the door through whichprice_with_envlets the leak in. Knowing thatos.environ.get(key, default)falls back to a default when the variable is missing explains exactly the red in case B.- How to write deterministic tests — pytest documentation — good practices so a test doesn't depend on the environment where it runs. It's the general antidote against the map's leaks.
datetime— Python documentation — the type behind the "wall clock" leak. In the fundamentals guide you'll see how to control "now" with aClock; here it's enough to recognize that readingdatetime.now()internally makes a test dependent on the moment it runs.