Why my tests pass locally but fail in CI — 3 real causes

A classic. You run the tests locally — all green. You push — and the robot paints the build red. Same tests, same code.
First thought: "CI is broken." It almost never is.
Here's what's actually happening: CI is the first honest run of your project. A clean machine, an empty folder, nothing extra. Your machine, meanwhile, has accumulated months of stuff — packages installed once, forgotten files, environment variables from a previous project. You aren't testing the project. You're testing the project plus your machine.
A red CI usually means your code wouldn't run for anyone else either. Let's take the three causes in order, most common first.
Cause 1: your machine has something CI doesn't
Roughly half of all cases.
The usual escapees:
- Environment variables. You have a
.envfile with keys. It's in.gitignore(correctly so). CI doesn't have it — the test reaches for a key, gets nothing, and dies. What that file is and why it stays out of the repo: covered separately. - Files that were never committed. A test image, a database dump, a config you created by hand six months ago and forgot to add to git.
- Globally installed things. A database that just quietly runs on your machine. A tool you installed once, system-wide.
How to check. The most reliable method, and the most underused: clone your repo into a fresh empty folder and run the tests there.
git clone <your-repo-url> /tmp/clean-check
cd /tmp/clean-check
npm ci && npm test
If it fails — congratulations, you reproduced CI locally in a minute, and now you can debug properly instead of through pushes.
A quick extra look at what's sitting nearby but not in git:
git status --ignored
Everything on that list your tests need is absent in CI.
How to fix. Depends on the escapee:
- Keys — put them in CI secrets, and stop hitting live services from tests (stub the responses).
- Files — commit them if they're test data. Or create them inside the test instead of assuming they exist.
- Services — declare them explicitly in the CI config instead of hoping they're "just there."
Cause 2: you're on different versions
Second most common. One codebase, two different projects assembled.
The mechanics are simple. Your packages were installed long ago and never touched since. CI installs everything from scratch, today. And if your project file records versions as a range (^4.17.1 means "take fresh minor updates"), today's install isn't what arrived six months ago.
Plus the human bit: you installed a package but forgot to commit the changed project files. You have it; CI doesn't.
How to check. Compare the versions actually installed locally against what the robot installs in the build log. And confirm the lock file is committed:
git ls-files | grep lock
Empty output? There's your cause. package-lock.json (or its equivalent) belongs in the repository.
How to fix. In CI, install strictly from the pinned versions:
npm ci
The difference from the familiar npm install matters. npm ci requires a lock file, deletes the existing dependency folder, and installs exactly the recorded tree. It also refuses to run if the list file and the lock file have drifted apart — which is good: an honest error now beats "works on my machine" later. Symptoms of that drift: why installing dependencies fails.
Cause 3: time, order and parallelism
Rarer, but more miserable: the test fails only sometimes. Three classic sources.
Time zone. CI machines nearly always live in UTC; you don't. A test comparing a date against "today" passes for you and lands on yesterday in CI. Check by running the tests in UTC yourself:
TZ=UTC npm test
Fix: don't read the system clock inside tests. Pin the date explicitly to a concrete value instead of "now."
Test order. One test left something behind — a database row, a file, a changed global setting — and the next one leans on it. Your order differs from CI's, and the chain snaps.
Check: run the tests one at a time in a single thread (in Jest, npx jest --runInBand), and separately run the suspect file alone. Passes alone, fails in the crowd — diagnosis made.
Fix: every test cleans up after itself and depends on no neighbour. Boring, but there's no alternative.
Races. On your machine a request takes 50 ms; on a busy runner, 800. A test that waited "a moment" stops waiting too early. The tell: it fails intermittently and in different places.
Fix: wait for an event, not for a duration. Instead of "sleep 300 ms," use "wait until the result appears." Raising the timeout treats the symptom, not the disease.
If none of these fit
Work in order, not at random:
- Read the whole build log, not the last line. The real cause is often twenty lines higher, where dependencies were installed.
- Reproduce cleanly (clone into an empty folder) — that kills half the hypotheses in a minute.
- Give a model the entire log, not your summary of it. Build logs are long and boring — exactly what models read better than we do.
- Don't debug by pushing. Twenty "fix CI" commits means step 2 got skipped.
One closing thought. A red CI on green local tests isn't a broken robot. It's a free report that your project doesn't yet travel to someone else's machine. Fix it once and you also fix "it won't run for me" for everyone who joins later. Why this robot exists at all: we covered that here.
Can CI actually be broken?
It can, but rarely: a runner image updated, an external service went down, disk space ran out. The tell is builds failing where the code didn't change. One-minute check: re-run a build on a commit that was green before.
Why does a test fail in CI only sometimes?
That's a flaky test, and it always has a cause from section three: time, order, or a race. Re-running until green is a bad idea — you're hiding a real bug that will eventually fire for a user.
How do I reproduce the CI environment locally?
Easiest path: clone the repo into a clean folder and install strictly from the lock file. An exact match needs the same image (via a container), but it rarely comes to that — a clean clone catches most cases.
Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.





