Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 6 min read

Our CI Failed 40% of the Time and the Code Was Fine: A Flaky Test

"Just re-run it." Four words that quietly destroyed the trust of an entire engineering team. If you've ever typed those words into a Slack thread, this story is for you. A Red Build Nobody Believed It was 4

"Just re-run it."
Four words that quietly destroyed the trust of an entire engineering team.

If you've ever typed those words into a Slack thread, this story is for you.

A Red Build Nobody Believed

It was 4:47 PM on a Thursday. Priya, a backend engineer at a 12-person B2B SaaS startup, pushed a one-line fix for a typo in an email template. The pipeline went red.

She didn't read the logs. She didn't even open the failed job. She clicked Re-run all jobs, grabbed a coffee, and came back to a green build.

Nothing in the code had changed. The build had simply decided to pass the second time.

That moment, repeated dozens of times a week, is what we'll dissect in this case study. By the end of this post you will have:

  • A clear definition of what a flaky test actually costs you (in hours, not vibes)
  • A repeatable way to measure flakiness in your own pipeline
  • A practical migration path from brittle scripted tests to a more resilient approach
  • Code you can paste into your own repo today

The team's product was a subscription management dashboard. Their stack looked like most modern startups:

Layer Technology
Frontend React + TypeScript
Backend Node.js + PostgreSQL
CI GitHub Actions
E2E tests 140 Playwright scripts
Deploys 8-10 per week

On paper, this was a healthy setup. In practice, the end-to-end suite had become the thing everybody worked around instead of relying on.

The Problem: Quantifying the Pain

The team's engineering manager finally did what every engineering manager should do before complaining: she measured.

She pulled 30 days of CI history and wrote a small script to classify every failed run by whether a re-run of the same commit later passed.

#!/usr/bin/env bash
# flaky-report.sh
# Counts runs that failed, then passed on retry for the same commit SHA.

gh run list --limit 500 --json databaseId,headSha,conclusion,attempt \
  | jq -r '
      group_by(.headSha)[]
      | select(any(.[]; .conclusion=="failure")
               and any(.[]; .conclusion=="success"))
      | .[0].headSha
    ' | sort -u | wc -l

The result was uncomfortable:

Total pipeline runs (30 days):     412
Runs that failed at least once:    171   (41.5%)
Failed-then-passed on same SHA:    139   (81% of failures)
Real failures (code was broken):    32

Eighty-one percent of the failures were not bugs. They were noise.

The real cost

She then did the math that got leadership's attention:

# cost_of_flakiness.py
flaky_failures_per_month = 139
avg_minutes_lost_per_failure = 22   # context switch + rerun + wait
engineers_blocked_ratio = 1.4       # others waiting on the same pipeline
hourly_cost = 55                    # blended, USD

hours_lost = (flaky_failures_per_month
              * avg_minutes_lost_per_failure
              * engineers_blocked_ratio) / 60

print(f"Hours lost per month: {hours_lost:.1f}")
print(f"Approx. monthly cost: ${hours_lost * hourly_cost:,.0f}")

Output:

Hours lost per month: 71.3
Approx. monthly cost: $3,923

That is nearly half of one engineer's monthly capacity, burned on re-running tests that were not even telling the truth. And the invisible cost was worse: engineers had started ignoring red builds. Eventually, a real bug slipped through because everyone assumed "it's probably flaky again."

What They Tried First (And Why It Didn't Work)

Before looking for a new approach, the team tried the usual suspects.

Attempt 1: Add retries. They set retries: 2 in the Playwright config.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  timeout: 30_000,
});

Result: the pipeline went green more often, but run time increased by 35%, and the flakiness was now hidden rather than fixed.

Attempt 2: Add hard waits. Somebody sprinkled await page.waitForTimeout(3000) everywhere.

Result: slower tests, same flakiness, and a codebase full of magic numbers.

Attempt 3: A "flaky test quarantine." They tagged unstable tests and skipped them in CI.

Result: the quarantine list grew from 9 tests to 31 in six weeks. A quarantine that only grows is just a graveyard.

Diagnosing the Root Cause

The team finally sorted the 139 flaky failures by cause. This is the table that changed their thinking:

Root cause Share of flaky failures
Brittle selectors (CSS/XPath changed after UI tweaks) 46%
Timing / race conditions on async UI 29%
Shared test data collisions 14%
Environment hiccups (network, container startup) 11%

Nearly half of the pain came from tests that were coupled to implementation details of the UI. A selector like this one was typical:

// Looks innocent. Breaks the moment a designer touches the DOM.
await page.click('#root > div.layout > main > section:nth-child(2) button.btn-primary');

A developer changes a wrapper div, nothing visible changes for the user, and the test dies.

The Turning Point

The insight was simple but uncomfortable: the tests were asserting on how the page was built, not on what a user could do.

The team started evaluating tools that describe tests in terms of user intent and use AI to locate elements, instead of hard-coding locators. That is where they tried an AI-driven QA platform. If you want to try the same approach on your own flaky suite, you can start with BotGauge here and run your first AI-generated tests before committing to any large migration.

The workflow they adopted looked like this:

  1. Describe the scenario in plain English instead of writing selectors.
  2. Let the platform generate and execute the test across the UI, API, and database layers.
  3. Rely on self-healing so that cosmetic UI changes don't produce false failures.
  4. Wire it into CI so results gate the merge just like unit tests do.

A scenario that used to be 40 lines of Playwright became something like this:

Scenario: Customer upgrades from Basic to Pro plan
  Given I am logged in as a Basic plan customer
  When I open the Billing page
  And I choose the Pro plan and confirm payment
  Then I should see a confirmation message
  And my plan should show as "Pro" in Account Settings

No nth-child. No waitForTimeout. The test reads like the requirement it protects.

The Migration Playbook

They did not rewrite everything at once. Here is the order that worked, and it's a good template for you:

Week 1  Measure flakiness (the scripts above). Rank tests by failure count.
Week 2  Migrate the 10 flakiest critical-path tests (login, checkout, upgrade).
Week 3  Run old and new suites in parallel. Compare signal, not just speed.
Week 4  Retire migrated Playwright tests. Keep the rest until their turn.
Week 5+ Continue by risk tier. Delete the quarantine list as you go.

Wire the new suite into the pipeline like any other quality gate:

# .github/workflows/ci.yml
name: CI
on: [pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  e2e:
    needs: unit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Trigger AI end-to-end suite
        run: ./scripts/run-e2e-suite.sh
        env:
          E2E_API_TOKEN: ${{ secrets.E2E_API_TOKEN }}

The Results (After 8 Weeks)

Illustrative numbers from the scenario, tracked using the same scripts from earlier:

Metric Before After
Pipeline runs failing at least once 41.5% 9%
Failures that were real bugs 19% 88%
Hours lost per month to re-runs ~71 ~9
Quarantined tests 31 0
Engineers saying "just re-run it" Daily Rarely

The most important number is "Failures that were real bugs." When a build turns red now, people read the logs. That is what a healthy signal looks like.

Key Lessons You Can Steal

  1. Measure before you migrate. Without the 30-day report, this would have stayed an anecdote.
  2. Retries hide problems, they don't solve them. Use them as a temporary bandage and track them as debt.
  3. A growing quarantine is a smell. Set a hard cap and an expiry date for every quarantined test.
  4. Test intent, not implementation. If a designer's CSS tweak can break your test, your test is testing the wrong thing.
  5. Trust is the real product of a test suite. Speed and coverage matter, but only if people believe the result.

Your Turn

Run the flaky-report.sh script against your own repository this week. If more than 15% of your failures disappear on re-run, you don't have a testing problem, you have a trust problem.

Have a flaky-test horror story of your own? Drop it in the comments. I read every one.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.