The suite ran for twenty minutes and stayed green. 412 tests, nothing skipped, coverage above the threshold somebody picked years ago. The build passed, the image went to the registry, the container came up. Four minutes later the first real run stopped dead: EACCES: permission denied, open '/data/exports/report.csv'.
The process was running as uid 1001. The mounted directory belonged to root. One line in the Dockerfile, fixed in five minutes.
The fix was not the expensive part. The expensive part was that twenty minutes of green tests could not possibly have caught it.
A test owns its environment
Look at what the test covering that file write actually did. It created a temp directory with mkdtemp, wrote into it, read it back, compared, cleaned up. It ran as whichever user the test runner ran as. The directory it wrote to, it had created itself a millisecond earlier.
That is not a sloppy test. That is exactly what a unit test is supposed to be: hermetic, repeatable, free of shared state, identical on your laptop and in the pipeline. To be that, it has to build its own environment - the temp directory, the fake HTTP client, the container that is really just an object with the right methods on it.
Which leads to a property nobody says out loud: if you build your own environment, you cannot come into conflict with it. A directory you created a moment ago belongs to you. A network made of mocks has no firewall. A container that is an object has no user.
The conflicts that cannot arise there
It is worth walking through this concretely, because the list is always the same one.
A uid mismatch needs two parties with different ideas about who owns what - the host directory created by uid 1000, the container running as 1001. In the test there is only one party.
A shared mount needs two processes writing to the same volume and pulling the file out from under each other. In the test exactly one process writes, into a directory of its own.
root versus non-root needs a hardened image that starts differently from the dev image where everything worked comfortably. The test runner is always the same user.
A network with no route out needs an egress rule, a proxy, an internal DNS, a certificate signed by a CA your runtime has never heard of. The fake HTTP client answers 200 with the JSON you put into it yourself.
The same goes for a read-only root filesystem, for a secret that production mounts as a file rather than setting as an environment variable, for a memory limit, for a timezone. None of these are exotic. They are the most common reasons software falls over on its first real start, and they all have the same shape: a conflict between your process and something that was already there and does not belong to you.
That is the thing a fake cannot do. A fake reproduces an interface: the same methods, the same return values, the same failure behaviour if you put the work in. It does not reproduce what surrounds that interface. A fake is a promise you made to yourself, and it answers the way you assumed the world would answer.
What a green test actually claims
So “the tests are green” is a true statement, and a narrower one than it sounds. It says: given these inputs, this function does the right thing. My assumptions are consistent with each other.
It does not say: this function will ever be called with a directory it is allowed to write to.
None of which is an argument against tests. We write them on every project, and we write them first, because they are the only check fast enough to help while you are still writing the code. A suite that finishes in twenty seconds changes how you work. An integration run that takes twelve minutes does not. Tests check logic, and logic is the thing you break when you refactor.
What they do not check is boundaries. For that you need a run where nothing is faked.
A run that touches the boundary
It does not have to be a full staging environment. For most projects, one pipeline run that takes three things seriously is enough.
It starts the image you would actually ship, not the dev image with the debugging tools and the convenient entrypoint. It mounts a directory created by somebody other than the process writing into it - and if that is root in production, then root here too. And it takes away whatever production does not have: no egress if there is no egress, read-only if the root filesystem is read-only.
Then you exercise the one path that runs first in production. Not every path, not every edge case - that is what the unit tests are for. The main path, once, all the way through, against a real filesystem as a real user.
The failure above would have surfaced right there. In the pipeline, with the same message, three minutes after the commit instead of four minutes after the deployment.
Having only one of the two
With only unit tests you get fast feedback about logic and complete blindness to anything environmental. Green means: my assumptions do not contradict each other.
With only integration runs it is the other way round. The run sees the real world, but it is slow, it turns flaky over time, and when it goes red it will not tell you which of forty components is at fault. You end up debugging backwards through a system instead of forwards through a function.
Together they do something neither manages alone: the unit test localises, the integration run reveals. One tells you where the bug is, the other tells you there is one at all.
And the difference that really matters is not “fewer bugs”. It is which kind of surprise is left over. With both, you have gaps you know about - things you consciously chose not to cover. With only one of them, you have gaps you know nothing about, and the place where you find out is production.