Flaky Tests Are a Design Signal, Not a CI Problem
Flaky tests rarely mean your CI is broken. They usually expose hidden coupling, nondeterminism, or a test suite that models the wrong boundaries. Treat them as a design signal, and you can cut false failures, speed up delivery, and improve system architecture at the same time.
Nesqual Tech AI
Flaky tests are telling you something your pipeline cannot fix
A test that fails 1 out of 20 runs is not a "CI issue"; it is a production-quality signal that your codebase has hidden nondeterminism, weak boundaries, or shared state you have not isolated. Teams that ignore that signal often spend 30 to 50 engineer-hours a week rerunning pipelines, only to keep shipping brittle systems that fail under load later.
In one enterprise platform team we worked with, 14% of failed builds were flaky test reruns. The CI platform was healthy. The real problem was a test suite that mixed network calls, time-based logic, and shared database fixtures across 1,800 integration tests. Once the team treated flakiness as a design smell, they cut false failures by 82% in six weeks and reduced median pipeline time from 41 minutes to 24 minutes.
Why flaky tests are usually a design problem
Flaky tests appear when a test depends on something the code does not control. That can be wall-clock time, random IDs, external APIs, shared state, order dependence, or concurrency timing. CI only exposes the issue because it runs the suite at scale and in parallel.
The four most common design smells behind flakiness
- Shared mutable state: One test changes a singleton, cache, or database row another test expects to be clean.
- Hidden time dependence: Assertions compare timestamps, TTLs, or scheduled jobs without freezing time.
- External coupling: Tests hit live services, DNS, message brokers, or rate-limited APIs.
- Race-prone concurrency: Async tasks, retries, and eventual consistency are asserted as if they were synchronous.
A useful rule: if the same test passes locally and fails in CI, the test is probably describing an unstable boundary, not a broken runner.
A concrete example: the order-service incident
An order-service team at a large retailer had a test that failed only on GitHub Actions runners with 16 parallel jobs. The test created an order, then immediately queried an analytics projection that was updated by Kafka consumers. Locally, the consumer usually caught up in under 200 ms. In CI, p95 lag was 1.8 seconds, so the test failed about 9% of the time.
The fix was not "retry until green." The team split the test into two parts:
- A unit test that verified the order event payload synchronously.
- A contract test that verified the consumer projection separately with an explicit wait condition and a 3-second bounded timeout.
Flakiness dropped to near zero, and the team also discovered a real design issue: the projection was not idempotent under duplicate events.
What flaky tests reveal about system architecture
Flaky tests are often the first visible symptom of architecture drift. They tell you where your system has crossed a boundary without making that boundary explicit.
Hidden coupling is usually the root cause
If changing one module breaks unrelated tests, you likely have:
- A shared database schema used by too many suites
- Global config loaded at import time
- A cache or singleton with state that leaks across tests
- Test helpers that depend on execution order
This is not just a testing issue. It means your production code has poor encapsulation too. A suite that depends on order often mirrors a service that depends on tribal knowledge.
Nondeterminism is a design choice, not a mystery
Randomness, timestamps, and concurrency are valid in production, but tests need control points. If you do not inject a clock, seed your RNG, or control async boundaries, your suite becomes a probabilistic system.
A 2026-ready engineering standard is to make every source of nondeterminism injectable:
Clockfor timeRandomwith a fixed seed for IDs and samplingHttpClientinterfaces for external calls- Message broker adapters with deterministic test doubles
Example: freezing time in a service test
from datetime import datetime, timezone, timedelta
class FixedClock:
def __init__(self, now):
self._now = now
def now(self):
return self._now
clock = FixedClock(datetime(2026, 3, 14, 9, 0, 0, tzinfo=timezone.utc))
expires_at = clock.now() + timedelta(minutes=15)
assert expires_at.isoformat() == "2026-03-14T09:15:00+00:00"
This looks small, but it changes the design of the service. Time is no longer implicit global state; it is a dependency you can reason about and test.
How to fix flaky tests by changing the design
Treating flaky tests as a design signal means you fix the system around the test, not the CI job around the failure.
1. Separate unit, contract, and integration responsibilities
A common anti-pattern is a single "integration test" that does everything: HTTP, auth, DB writes, queue publishing, and UI assertions. That makes failures ambiguous and slow.
Use a layered test strategy:
- Unit tests: pure logic, no network, no disk, no clock drift
- Contract tests: validate request/response shapes and versioned APIs
- Integration tests: verify real wiring for a small number of paths
- End-to-end tests: cover only critical user journeys
A team using this split at a SaaS billing company reduced the suite from 2,400 mixed tests to 1,100 unit tests, 140 contract tests, and 38 end-to-end flows. The result was a 57% drop in total CI time and a 73% reduction in flaky reruns.
2. Make test data disposable and unique
Shared fixtures are a flake factory. If two tests touch the same tenant, customer, or bucket, they will eventually collide.
Prefer:
- Per-test database schemas
- Unique tenant IDs generated from a deterministic prefix plus test name
- Ephemeral containers for stateful dependencies
- Cleanup hooks that verify no residue remains
# Example: isolated test database in CI
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports:
- 5432:5432
steps:
- run: createdb -h localhost -U postgres app_test_${CI_JOB_ID}
- run: pytest tests/unit
- run: pytest tests/integration --db app_test_${CI_JOB_ID}
- run: dropdb -h localhost -U postgres app_test_${CI_JOB_ID}
This pattern costs a little more in setup time, usually 20 to 40 seconds per pipeline, but it saves far more time than reruns and manual triage.
3. Bound asynchronous behavior with explicit contracts
If your test waits for "eventual consistency" without a timeout and a clear success condition, it is not a test; it is a hope.
Use bounded polling with diagnostics:
async function waitForProjection(checkFn, timeoutMs = 3000, intervalMs = 100) {
const start = Date.now();
let lastError;
while (Date.now() - start < timeoutMs) {
try {
if (await checkFn()) return true;
} catch (err) {
lastError = err;
}
await new Promise(r => setTimeout(r, intervalMs));
}
throw new Error(`Projection did not converge within ${timeoutMs}ms. Last error: ${lastError?.message || 'none'}`);
}
This makes the async boundary explicit and produces useful failure data instead of a random timeout.
Common Pitfalls
Flaky-test cleanup often fails because teams attack symptoms instead of causes.
Pitfall 1: Adding retries everywhere
Retries can reduce noise, but they also hide design defects. If a test passes on the second try, the underlying behavior is still unstable. Use retries only as a temporary shield while you remove the nondeterminism.
Pitfall 2: Marking failures as "non-blocking"
When teams disable flaky tests to keep release velocity, they usually create a shadow tax. The suite loses trust, engineers stop reading failures, and real regressions slip through. Track every disabled test with an owner and a sunset date.
Pitfall 3: Over-mocking the wrong layer
Excessive mocks can make tests green while production breaks. If you mock the database, cache, queue, and auth layer in the same test, you are validating your stubs, not your system. Keep mocks at stable boundaries and use real components where the integration risk is highest.
Pitfall 4: Ignoring execution order and parallelism
A suite that only passes in serial mode is telling you that test isolation is broken. Run a parallelism stress job nightly with randomized order and higher concurrency than your default CI setting. In 2026, this is cheap insurance: a 15-minute stress job can prevent days of incident response later.
Pitfall 5: Measuring only pass/fail rate
Track flake rate, rerun rate, median triage time, and test ownership. One fintech team found that 62% of flaky tests were concentrated in 8% of files. That made remediation much faster than treating the suite as uniformly broken.
A practical operating model for engineering teams
If you want flaky tests to become a design signal instead of a morale drain, create a simple operating model.
Use a flake budget
Set a threshold such as "no more than 0.5% flaky failures per week" for critical pipelines. Anything above that triggers a design review, not just a rerun.
Triage by boundary, not by file
Classify each flaky test as one of these:
- Time
- State
- Network
- Concurrency
- Environment
That classification tells you where the design problem lives. A time flake is usually solved by injecting a clock. A network flake is usually solved by contract testing or local service virtualization.
Build a flake dashboard
A simple dashboard should show:
- Flake rate by repo and suite
- Top 20 failing tests by rerun count
- Mean time to isolate root cause
- Tests disabled longer than 14 days
- Parallelism-related failures by runner type
One enterprise platform team using this approach found that ARM-based runners had 2.3x more timing-sensitive failures than x86 runners because a few tests assumed fixed sleep durations. That insight led to a code fix, not a runner upgrade.
Key Takeaways
- Treat flaky tests as evidence of hidden coupling, nondeterminism, or weak boundaries.
- Fix the design first: inject time, isolate state, and separate unit, contract, and integration concerns.
- Do not rely on retries or disabled tests except as short-lived mitigation.
- Make asynchronous behavior explicit with bounded waits and clear success criteria.
- Measure flake rate, rerun rate, and ownership so you can target the real source of instability.
- If a test only passes when the environment is "just right," the system design is asking for a redesign.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Written by
Nesqual Tech AI
Nesqual Tech
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI