Here's the diff that started it:
- <button id="submit-btn" class="btn btn-primary">Submit</button> + <button data-action="confirm" class="btn btn-brand">Confirm</button> A designer wanted consistent naming across the design system. A frontend developer made the change in five minutes. It looked harmless, and visually it was.
By the time the pipeline finished, the QA lead, Anjali, was staring at a wall of red:
FAILED checkout.spec.ts > places an order FAILED profile.spec.ts > updates address FAILED onboarding.spec.ts > completes wizard FAILED invoices.spec.ts > downloads invoice ... (59 more) 63 failed, 112 passed, 175 total Not one of those 63 failures was a bug. The product worked perfectly. The tests were broken.
This case study follows what happened next: the real cost of test maintenance, the decision framework the team used, and a practical plan to cut maintenance work without losing coverage. Reading time: roughly 6 minutes.
The Background
| Detail | Value |
|---|---|
| Product | Multi-tenant HR platform |
| Team | 14 engineers, 2 QA engineers |
| Test suite | 175 Selenium/Playwright E2E tests |
| Maintenance load | ~35% of QA time spent fixing tests |
Anjali kept a simple spreadsheet tracking where her team's hours went. When she finally totaled it for the quarter, one line stood out:
Writing NEW tests: 22% Investigating real bugs: 27% Triaging false failures: 16% Fixing broken locators/waits: 35% More than a third of the QA team's capacity went to keeping existing tests alive rather than creating new value. That's the hidden tax of locator-based automation.
Why Locator-Based Tests Are Fragile
Traditional scripted tests find elements through selectors. A typical page object looks like this:
// pages/CheckoutPage.ts export class CheckoutPage { constructor(private page: Page) {} submitButton = '#submit-btn'; couponInput = 'input[name="coupon_code"]'; totalLabel = 'div.summary > span.total-amount'; async placeOrder() { await this.page.click(this.submitButton); await this.page.waitForSelector('.order-confirmation'); } } Every one of those strings is a contract with the DOM, and nobody told the DOM it signed one. Here's how the fragility scales:
1 UI change x N tests that touch that element = N broken tests If your "Submit" button appears in 63 tests (login, checkout, profile, onboarding), a single rename is 63 failures. Page objects reduce the edit effort to one file, but they don't eliminate the detection and triage effort: someone still has to open each failure and figure out whether it's real.
The Decision Framework
Anjali presented three options to the team. Here's the comparison she used:
| Option | Upfront cost | Ongoing cost | Risk |
|---|---|---|---|
| A. Keep patching locators | None | High and growing | Team burnout |
B. Add data-testid everywhere | Medium (devs must adopt) | Medium (needs discipline) | Drift when teams forget |
| C. Move to intent-based, self-healing tests | Medium (pilot + migration) | Low | Tool evaluation needed |
Option B is genuinely good practice, and you should do it regardless. Stable test IDs make any suite more resilient:
// Stable, explicit hooks for tests <button data-testid="checkout-confirm" className="btn btn-brand"> Confirm </button> But the team noticed a problem with relying on it alone: it requires every developer to remember, on every change, forever. The suite's stability depended on human consistency.
So they went with a hybrid: adopt data-testid conventions and pilot a tool that doesn't depend on a single hard-coded locator in the first place.
The Pilot
They picked their 20 most maintenance-heavy tests and ran them through an AI-driven QA platform. This is where BotGauge's AI testing platform entered the picture. The pitch that made them try it: tests are written as plain-English scenarios, and the platform finds elements using multi-modal AI rather than a brittle chain of CSS selectors, with self-healing when the UI shifts. You can run a similar pilot on your own flakiest tests to judge it for yourself.
The same checkout scenario, expressed by intent:
Scenario: Customer places an order Given I am signed in as a customer with items in my cart When I go to checkout And I apply the coupon "WELCOME10" And I confirm the order Then I should see an order confirmation And the total should reflect a 10% discount Notice what's missing: no #submit-btn, no div.summary > span.total-amount, no explicit waits. The step "confirm the order" doesn't care whether the button says Submit or Confirm, or what its ID is.
The "rename test"
To validate the claim, the team did the most honest experiment possible. They reproduced the original incident on a branch:
git checkout -b experiment/rename-submit-button # apply the exact same diff that broke 63 tests last month git apply rename-button.patch npm run build && ./scripts/run-pilot-suite.sh Results on the 20 migrated tests (illustrative):
Legacy scripted suite (same 20 tests): 14 failed, 6 passed Intent-based suite (20 tests): 0 failed, 20 passed That experiment, more than any vendor deck, is what convinced the skeptical senior engineers. The lesson is to replay your own worst incident against any tool you evaluate.
Rolling It Out Safely
They avoided a big-bang rewrite. Their sequence:
Sprint 1 Instrument: tag each test with maintenance cost (edits per month). Sprint 2 Migrate the 20 costliest tests. Run old and new side by side. Sprint 3 Compare: false failures, runtime, and bugs caught by each. Sprint 4 Retire migrated legacy tests. Plan the next 40. Ongoing New tests are written intent-first. Legacy shrinks on its own. A tiny script helped them pick what to migrate first:
# maintenance_cost.py import subprocess, collections # Count how often each spec file was edited in the last 90 days log = subprocess.check_output( ["git", "log", "--since=90.days", "--name-only", "--pretty=format:", "tests/"], text=True, ) counts = collections.Counter(line for line in log.splitlines() if line.strip()) for path, edits in counts.most_common(10): print(f"{edits:>3} edits {path}") Files at the top of that list are your highest-ROI migration candidates: they're the ones costing you the most to keep alive.
The Results After One Quarter
| Metric | Before | After |
|---|---|---|
| QA time spent fixing broken tests | 35% | 8% |
| QA time spent writing new tests | 22% | 41% |
| False failures per week | 18 | 3 |
| Time from UI change to trusted CI result | Half a day | Same pipeline run |
| Designer-requested UI tweaks blocked by "it'll break tests" | Several | None |
That last row mattered more than the team expected. Engineers had been quietly avoiding small UI improvements because of the test fallout. Removing that friction improved the product, not just the test suite.
Takeaways
- Track maintenance as a first-class metric. If you don't measure it, 35% of your QA capacity can vanish unnoticed.
- Adopt
data-testidregardless. It's cheap, it helps any tool, and it documents testable surfaces. - Don't depend on human discipline alone. Systems that need everyone to remember something forever will eventually fail.
- Replay your worst incident. Any evaluation of a testing tool should include reproducing the change that hurt you most.
- Migrate by pain, not by alphabet. Start with the tests that cost the most, not the ones that are easiest.
Try This Today
Run the maintenance_cost.py script on your own test folder. If the top five files account for most of your edits, you've found your pilot candidates. Then ask the question this team asked: "If a designer renamed our most-used button tomorrow, how many of our tests would fail?"
If the honest answer is "a lot," that's your sign.
What's the most absurd reason one of your tests has ever failed? Tell me in the comments.