Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 29
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 68
CI/CD 13
  • GitHub Actions
  • Slack Reporting
QA Strategy 11
Case Studies 9
Blog/QA Strategy
QA StrategyHalmurat T./November 10, 2026/10 min

Test Failure Triage Has One Question: App or Test?

Filed underqa-strategy/playwright/leadership
Test Failure Triage Has One Question: App or Test?

Table of Contents
  • Why is “how many failed” the wrong test failure triage question?
  • Why only two buckets: app or test?
  • ”But environment failures aren’t test bugs”
  • What about intended app changes?
  • How do you tell an app bug from a test bug in under 10 minutes?
  • Step 1: Isolate the failure
  • Step 2: Run it against last-known-good
  • What does it take to keep a last-known-good build around?
  • How do you make classification the report instead of the count?
  • Where does this protocol break down?
  • Your next step
  • FAQ

On this page

  • Why is “how many failed” the wrong test failure triage question?
  • Why only two buckets: app or test?
  • ”But environment failures aren’t test bugs”
  • What about intended app changes?
  • How do you tell an app bug from a test bug in under 10 minutes?
  • Step 1: Isolate the failure
  • Step 2: Run it against last-known-good
  • What does it take to keep a last-known-good build around?
  • How do you make classification the report instead of the count?
  • Where does this protocol break down?
  • Your next step
  • FAQ

The failure count is the least useful number in your nightly report. It tells you how loud the night was, not what broke. Test failure triage should put one question to every red test: did the app change, or did the test system? Until each failure has that answer, the count is noise, and a falling count can hide a rising number of real defects.

You can answer that question with two mechanical checks: rerun the test alone, then run it against the last build where it passed. Neither needs judgment, and together they classify most failures in under ten minutes each.

Why is “how many failed” the wrong test failure triage question?

Because a count can’t be acted on, and a classification can. “28 failed” doesn’t tell a developer to look at anything, and it doesn’t tell an SDET what to fix. “3 app, 25 test” sends three failures to developers today and puts 25 on the QA backlog.

I learned this at a Canadian lottery and gaming operator. We ran about 1,100 Playwright and API tests nightly across four SDETs, and every standup opened with a number: “28 red, down from 41.” The trend line looked healthy for a month. During that month a payout-rounding defect sat in the red pile for nine days. Its test had been failing the whole time, but it was one row among 28, and the pile was shrinking, so nobody asked what had broken.

The fix was a rule rather than a tool. No failure leaves standup without a verdict: app or test. Anything still unclassified at standup becomes the day’s first job, and the unclassified count replaced the failure count on the dashboard. It’s the same reason pass rate misleads leadership: it measures the suite’s behaviour, not the product’s.

Why only two buckets: app or test?

Two buckets force an owner. Every failure goes either to a developer, because the app’s behaviour changed, or to the test team, because something in the machinery that produces the signal changed. That machinery includes test code, test data, environments, and CI runners.

Most triage schemes I’ve inherited had six categories: app, test, environment, data, flaky, unknown. “Environment” and “flaky” were where failures went to die, because nobody owned them.

”But environment failures aren’t test bugs”

They aren’t bugs in your spec file, fair enough. They are still failures of the system QA exists to run. If staging’s payment stub was down at 2 AM, the app didn’t regress; your signal did. Somebody has to own a reliable signal, and if it isn’t the test team, it’s nobody. You can tag these “test / env” for reporting. The verdict that sets ownership is still test.

What about intended app changes?

If a developer renamed the button on purpose, the verdict is still app. The behaviour changed, so the person who changed it confirms it; then the SDET updates the test. Skip that five-minute conversation and QA quietly decides what correct behaviour is, which is how a self-healing locator hid a real bug for three weeks.

How do you tell an app bug from a test bug in under 10 minutes?

Change one variable at a time. First, remove the rest of the suite by running the test alone. Then remove the new app build by running the same test against the last build where it passed. Whatever variable makes the failure disappear owns it.

Step 1: Isolate the failure

Run the failing test by itself, with no retries, several times in a single worker:

isolate.sh
# Same test, alone, no retries, five times in one worker
npx playwright test tests/checkout/promo.spec.ts \
-g "applies 10% promo to cart total" \
--retries=0 --repeat-each=5 --workers=1 \
--trace=retain-on-failure

Each flag here is in the Playwright CLI reference. Here’s how to read the result:

  • Fails 5 of 5: the failure is deterministic. Go to step 2.
  • Passes 5 of 5, but failed in the full run: verdict test. Something in the suite caused it, such as shared state, ordering, a parallel worker reusing an account, or data left behind by an earlier test.
  • Mixed: it’s intermittent. Take the same --repeat-each into step 2 and compare failure rates between the two builds.
[ WARNING ]

Turn retries off for this. A retry that passes doesn’t clear the test. It only means the failure didn’t happen on that attempt. If your CI retries by default, the “flaky” label in your report is a guess, and quarantining guesses gets expensive.

Step 2: Run it against last-known-good

Last-known-good (LKG) is the most recent app build where this test passed. Keep that build deployed somewhere, then point the same test commit at it. In Playwright, that means making baseURL overridable:

playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.BASE_URL ?? 'https://qa.example.internal',
},
});
lkg-run.sh
# Same test commit, same data seed, previous app build
BASE_URL=https://lkg.qa.example.internal npx playwright test \
tests/checkout/promo.spec.ts -g "applies 10% promo to cart total" \
--retries=0 --repeat-each=5 --workers=1

Then read it like a truth table:

Current tests on LKG appCurrent tests on current appVerdict
PassFailApp: only the build changed
FailFailTest: test code, data, or environment changed
PassPassTest: the original failure came from run-time conditions

The first row is the one that pays off: same test, data, and runner, and only the build differs. Developers don’t argue with that for long.

[ TIP ]

Point both builds at the same seeded test data. If the LKG environment has its own database, you’ve changed two variables, and the truth table no longer means anything.

What does it take to keep a last-known-good build around?

A deployable artifact per green build and somewhere to run it. Teams with ephemeral Docker environments get this almost free: tag the image that last passed and start it next to the current build. At the gaming operator, we kept one “LKG” environment and redeployed it with whichever build passed the full nightly. That was one spare node and a script of about 40 lines.

If you can’t, use the inverse check: run the test commit from the last green run against the current app. If the old tests pass, your test change caused the failure. It’s weaker, since environment drift doesn’t show up in git, but it beats guessing.

How do you make classification the report instead of the count?

Generate a triage sheet automatically and leave the verdict column blank. This custom Playwright reporter writes one row per failed or flaky test:

triage-sheet-reporter.ts
import type { Reporter, TestCase } from '@playwright/test/reporter';
import { writeFileSync } from 'node:fs';
class TriageSheetReporter implements Reporter {
private tests = new Set<TestCase>();
onTestEnd(test: TestCase) { this.tests.add(test); }
onEnd() {
// outcome() is final only after all retries, so read it here
const rows = [...this.tests]
.filter((t) => t.outcome() === 'unexpected' || t.outcome() === 'flaky')
.map((t) => {
const err = t.results.at(-1)?.error?.message?.split('\n')[0] ?? '';
return `| ${t.titlePath().filter(Boolean).join(' › ')} | ${t.outcome()} | ${err.replace(/\|/g, '\\|')} | ? |`;
});
const header = '| Test | Outcome | First error line | App or test? |\n|---|---|---|---|';
writeFileSync('triage.md', [header, ...rows].join('\n'));
}
}
export default TriageSheetReporter;

Register it with reporter: [['list'], ['./triage-sheet-reporter.ts']]. Flaky tests get a row too, because they still need a verdict. The ? column is the whole point: the only number you report is how many question marks are left. If you use an LLM to cluster CI failures, feed it this sheet, but have the two checks fill in the verdict.

After six weeks of this, roughly seven in ten failures were on the test side, and the app side had produced 23 real defects. Before, those defects would have sat in a pile we judged by its size. Triage dropped from the first 90 minutes of two people’s mornings to about 25 minutes for one person.

Where does this protocol break down?

It struggles when both sides changed at once, for example a release that ships an app change and a schema migration together, where the LKG build can’t run against the new data. In that case, rule out the test side first with the inverse check, and treat whatever survives as app until a developer shows otherwise. A false “app” verdict costs a developer ten minutes. A false “test” verdict ships a defect.

That asymmetry is my answer to “developers will get annoyed.” Some will, for about a week. After that, every failure that reaches them arrives with two runs differing in one variable, which beats most bug reports they’ve ever received.

Your next step

Tomorrow morning, pick the oldest red test in your nightly run. Run it alone with --retries=0 --repeat-each=5 --workers=1, then against the last build where it passed, and write down the verdict. If you can’t find or deploy that build, you’ve found the first thing to fix in your triage process.

FAQ

§ Frequently Asked FAQ
+ How do you tell if a test failure is an app bug or a test bug?

Rerun the test alone with --retries=0. If it passes alone, the problem is on the test side. If it still fails, run the same test commit against the last app build where it passed: old build passes means the app changed; both fail means the test, data, or environment changed.

+ Should environment failures count as test failures?

For ownership, yes. The app didn’t regress; the signal did, and a reliable signal is the test team’s job. Tag them “test / env” if you want the detail.

+ What is a last-known-good build in test triage?

The most recent app build on which the failing test passed, kept deployable so the build is the only variable between two runs.

+ Should I enable retries when triaging test failures?

No. Use --retries=0 with --repeat-each. A passing retry hides intermittent app defects behind a “flaky” label.

§ Further Reading 03 of 03
01QA Strategy

Agentic Testing vs Playwright Tests: Who Gets the PR Gate?

Agentic testing vs Playwright tests answer different questions. A 60-run demo plus Slack's 200-run study show which one earns the PR gate, and where agents fit.

Read →
02QA Strategy

We Budget 30% of Every Sprint for Flaky Tests

Your flaky test maintenance budget already exists — unbudgeted, invisible, billed to velocity. Here's the argument that gets leadership to fund it on purpose.

Read →
03QA Strategy

QA Lead Responsibilities: What the Role Actually Looks Like

QA lead responsibilities for 2026 — a clear breakdown of what the role really involves, how it differs from QA manager and SDET, and if it's worth pursuing.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.