How to debug an intermittent test failure.

•

•

One Test in Fifty Runs Fails

I’ve lost count of how many times I’ve seen some “expert” suggest buying a more expensive CI/CD suite or implementing a complex observability layer just to figure out why a single integration test is failing once every fifty runs. It’s nonsense. You don’t need a new dashboard or a fancy distributed tracing tool to solve this; you need to stop treating symptoms and start looking at the actual environment. Most people spend days chasing ghosts, but learning how to debug an intermittent test isn’t about adding more tooling—it’s about understanding the subtle, ugly race conditions and resource leaks that only show up when the runner is under pressure.

I’m not here to give you a theoretical lecture on test theory or a list of “best practices” that sound good in a slide deck. I’ve spent enough late nights in the trenches of logistics and payments to know that the real answers are usually found in the uncomfortable trade-offs we make in our setup. I’m going to walk you through the specific, manual steps I use to isolate these flakes, from inspecting local container state to forcing timing issues, so you can stop the cycle of “just re-run the pipeline” and actually fix the problem for good.

Table of Contents

Hunting Test Non Determinism Causes in the Wild

Hunting Test Non Determinism Causes in the Wild

When you’re staring at a CI pipeline that fails once every fifty runs, the instinct is to just hit “re-run” and hope for the best. I used to do that. It’s a trap. If you aren’t actively hunting for the root cause, you aren’t fixing the problem; you’re just training your team to ignore red builds. Most of the time, I find that race conditions in automated testing are the primary culprits. It’s usually a classic case of a test assuming a database write has finished before the next assertion kicks in, or a background worker hasn’t quite caught up with the state change. These aren’t “glitches”—they are logical errors that only reveal themselves when the CPU is under load.

Another common headache is a complete lack of test environment consistency. I once spent a weekend chasing a failure that only happened on the Jenkins runners and never on my local machine. Turns out, the runner had a slightly different timezone configuration and a different version of a shared library. If your environment isn’t a mirror of your production constraints, you aren’t testing your code; you’re testing your luck. To actually make progress, you have to stop treating the failure as a fluke and start looking for the shared state or the hidden dependency that’s making the outcome unpredictable.

Why Race Conditions in Automated Testing Ruin Sleep

Why Race Conditions in Automated Testing Ruin Sleep

The problem with race conditions in automated testing isn’t just that they fail; it’s that they lie to you. A test fails, you rerun it, it passes, and you move on, thinking it was just a hiccup in the CI runner. But that failure was a warning. These tiny, timing-dependent gaps in your logic are the precursors to actual production outages. When you’re dealing with race conditions in automated testing, you aren’t just fighting a red build; you’re fighting a ghost that only appears when the CPU is under a specific load or the network latency hits a certain threshold.

I’ve spent too many nights staring at logs trying to figure out why a suite passed locally but choked in the pipeline. Usually, it comes down to a lack of test environment consistency. Your laptop is a beast with 32GB of RAM and zero latency, but the CI runner is a constrained container fighting for cycles. When your code relies on “just enough time” for an asynchronous process to finish, you’ve built a house of cards. If you don’t address the underlying test non-determinism causes now, you aren’t just dealing with a flaky test—you’re dealing with a ticking time bomb that will eventually trigger a real-world incident.

My survival kit for the non-deterministic loop

  • Stop rerunning the same CI job and hoping for a pass. If a test fails once, it’s a bug; if it passes on the second try, it’s a ghost. You need to isolate the environment. I usually try to pull the exact container image and run it locally in a loop—`while ./run_test; do :; done`—until it breaks. If you can’t reproduce it locally, you aren’t debugging, you’re just guessing.
  • Watch your clock and your entropy. A lot of “intermittent” failures are just tests that are too sensitive to execution speed. If a test passes on your 16-core workstation but fails on a constrained CI runner, you have a timing issue or a hidden dependency on a system clock. Check for any code that assumes `sleep(100)` is enough time for an async process to finish. It never is.
  • Audit your shared state like it’s a security vulnerability. The most common culprit I’ve seen is a test that leaves a row in a database or a file in `/tmp` that the next test unexpectedly finds. I’ve spent entire afternoons chasing a failure that only happened when the test suite ran in a specific, alphabetical order. Clean up after yourself, or better yet, use fresh containers for every run.
  • Log everything, even the boring stuff. When a test flakes, your standard “AssertionError” is useless. I’ve learned to wrap my test runners to dump the stdout and stderr of every single process involved at the moment of failure. I want to see the database connection pool exhaustion logs and the network timeouts, not just a message saying `expected true, got false`.
  • Check for external side effects that aren’t mocked. If your test hits a real staging API, a local Redis instance, or even a system time provider, you’ve introduced non-determinism. I once spent a week debugging a flaky test only to realize it was failing because the staging server had a rate limit that kicked in once every fifty runs. If it’s not in your control, it shouldn’t be in your test.

What I’ve learned about not chasing ghosts

Stop trying to “fix” the test by rerunning the CI pipeline; if you aren’t actively looking for the shared state or the unawaited promise that caused the flake, you’re just gambling with your time.

If a test fails once every fifty runs, it’s not “flaky”—it’s a bug in your test environment or your concurrency logic that is currently hiding from you.

The goal isn’t a green checkmark; the goal is a deterministic test suite that actually tells you when something is broken, rather than one you’ve learned to ignore.

The Long Game

Debugging flaky tests is The Long Game.

At the end of the day, debugging a flaky test isn’t about finding a magic flag or a better CI runner. It is about the tedious, often unglamorous work of isolating non-determinism—whether that means hunting down a shared database state, unmasking a hidden race condition, or realizing your test suite is just too sensitive to the slightest timing jitter. I have learned that if you try to fix the symptom instead of the underlying cause, you aren’t actually solving anything; you are just delaying the next 2 AM wake-up call. You have to be willing to slow down, add the extra logging, and actually look at the traces until the pattern emerges.

I don’t write these notes to be a hero or to advocate for a specific testing philosophy. I write them because I am tired of solving the same concurrency bug every eighteen months. If you take nothing else away from this, remember that a test suite that lies to you is more dangerous than no test suite at all. Treat your intermittent failures as valuable signals rather than annoying noise. Once you start viewing them as breadcrumbs leading to a deeper understanding of your system’s architecture, you stop fighting the machine and start actually engineering something reliable.

Frequently Asked Questions

How do I actually reproduce a race condition locally if it only shows up on the CI runner?

The hardest part is that CI runners are usually slower and more resource-constrained than your MacBook, which actually helps expose timing issues. If you can’t see it locally, stop trying to run the test “normally.” I usually try to force the issue by artificially throttling my CPU or using a tool like `stress-ng` to hog resources. Alternatively, I’ll wrap the suspect code in a loop that runs it 1,000 times in a row. If it’s a race, it’ll eventually blink.

At what point do I stop trying to fix the test and just accept that the underlying architecture is fundamentally non-deterministic?

You stop when the cost of fixing the test exceeds the value of the signal it provides. If you’re spending three days chasing a race condition in a legacy integration test just to prove a component is “mostly” stable, you’ve lost. At that point, the test isn’t a safety net; it’s noise. Stop fighting the test. Instead, acknowledge the architectural debt, document the known non-determinism, and move toward a more predictable design.

How much overhead is too much when adding extra logging or instrumentation just to catch a flake?

If the logging changes the timing enough to make the flake disappear, you’ve just hit a Heisenbug. That’s a signal that your instrumentation is too heavy. I usually aim for “low-impact telemetry”—think structured logs or lightweight counters rather than dumping entire object states to stdout. If you’re worried about overhead, you’re probably right to be. I’d rather have a slightly slower test suite than a suite that lies to me about why it failed.

Is it worth the engineering time to fix a test that fails once every thousand runs, or should we just quarantine it and move on?

Quarantining is a debt you’re taking out at a high interest rate. If it fails once every thousand runs, it’s not a “flaky test”—it’s a bug hiding in your logic or your infrastructure. If you just hide it, you’re teaching your team that green builds don’t actually mean the code works. Fix it. If you can’t find the root cause this week, at least document exactly why it’s in quarantine so we don’t forget.

Tomasz Wierzbicki

About Tomasz Wierzbicki

I am not interested in arguing about frameworks. I am interested in why the thing broke at two in the morning and what would have prevented it. Most of what I know came from being wrong in production and writing it down afterwards.