At 2:17 PM, every dashboard in the engineering org was green.
CI passed. The merge queue was healthy. The deploy completed without rollback. The team had done everything “right” by the standards of modern software delivery.
At 2:24 PM, new users stopped completing signup.
Not because the backend was down. Not because a critical test failed. Not because someone merged obviously broken code. Signup broke because three safe-looking pull requests landed near each other: one updated the auth page copy, another cleaned up a shared form wrapper, and a third refactored route transitions generated mostly by an AI assistant. Each change passed review. Each change passed unit tests. Each change passed CI. The merge queue stitched them together efficiently and shipped a path that no one had actually exercised from a user’s perspective.
That is the modern failure mode.
It is not one bad commit. It is not one reckless engineer. It is not even necessarily poor code quality. It is approved, typed, linted, tested, continuously integrated software that fails when real steps are taken in the real order users take them.
Merge queues are excellent at maximizing throughput. They are much worse at proving that merged software still works as a product.
In AI-heavy teams, this gets worse. More code is produced. More “small” PRs move through review. More refactors preserve types and local behavior. More changes look low-risk because they are mechanically correct. The queue moves faster, the checks stay green, and confidence rises exactly when it should be questioned.
The problem is not merge queues themselves. The problem is what teams think merge queues verify.
They verify that code changes can merge cleanly and satisfy the checks you defined. They do not verify that the resulting application still supports the workflows your users depend on.
If your testing strategy stops at pull-request-level validation, your merge queue is very likely shipping broken UX faster than ever.
The problem: throughput is being mistaken for reliability
Most teams adopted merge queues for good reasons. Without them, busy repositories become a coordination mess. Developers rebase constantly. CI results go stale. Main breaks because two harmless changes collide after approval. Queues reduce that pain by serializing merges, re-running checks, and ensuring that what lands has at least been validated in a fresh integration context.
That is useful. It is also limited.
A merge queue answers a narrow question:
“Can this approved change set be merged into the current branch state while passing the configured checks?”
That is not the same as asking:
“Does the resulting system still let a user complete the critical flows that matter to the business?”
Those are different questions, and modern teams often confuse them because the same infrastructure appears to support both. There is a pipeline. There are checks. There is a gate. There is automation. It feels rigorous.
But most merge queues are only as good as the validations attached to them, and those validations are usually biased toward code-level correctness:
- unit tests
- component tests
- type checks
- linters
- contract tests
- build verification
- sometimes a light smoke suite
All of those have value. None of them reliably capture workflow integrity across multiple merged changes.
That gap matters because production failures increasingly happen at the seams:
- a selector changes when copy changes
- a route transition interrupts state persistence
- a payment button mounts correctly but becomes unclickable under a layout shift
- a background loading spinner races with form submission
- a generated refactor preserves interfaces while changing event timing
- two approved changes each maintain local invariants while jointly violating a user expectation
This is what makes the current moment different. The system can be locally correct and globally broken.
And merge queues are exceptionally good at accelerating locally correct changes.
Why CI/CD gives false confidence here
CI/CD is often described as a safety net. In practice, it is closer to a programmable filter. It catches what you explicitly model. It ignores what you do not.
That distinction matters because many teams built CI around the failure modes of an earlier era:
- syntax errors
- failing builds
- broken imports
- flaky dependencies
- unit regressions
- obvious integration issues
Those are still real. But they are no longer the dominant risk in many product teams.
Today’s bigger risk is that your software passes all those checks while the experience itself degrades. The path through the system breaks, not the system primitives in isolation.
Consider a typical queue configuration:
yamlname: CI on: pull_request: merge_group: jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm run test:unit - run: npm run test:components - run: npm run build
This looks disciplined. It is disciplined. It is just not sufficient.
Nothing in that workflow confirms that a user can:
- sign up
- verify email
- create a workspace
- add a payment method
- invite a teammate
- recover from an auth redirect
- complete onboarding after a route change
The pipeline validates implementation artifacts, not outcomes.
Even when teams add end-to-end tests, they often run them in ways that preserve the false confidence:
- only on nightly builds
- only against main after merge
- only for a tiny smoke path
- only against PR branches, not queue candidates
- only against mocks, not integrated dependencies
- only in environments unlike production
Then a workflow breaks after the queue merges a batch of “safe” changes, and everyone asks the wrong question: “Why didn’t CI catch this?”
CI did exactly what it was configured to do. The issue is that the team asked CI to verify software structure, not user behavior.
Why unit tests and component tests miss the real failures
Unit tests are excellent at defending logic. Component tests are useful for UI states. Neither is a substitute for workflow verification.
A signup button can render correctly in a component test and still fail in production because the page transition clears pending form state. A billing form can validate input correctly in unit tests and still break because a route guard re-mounts the page after auth. A modal can satisfy visual assertions while blocking keyboard navigation in the only sequence that matters.
Here is a harmless-looking React component test:
javascriptimport { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { SignupForm } from './SignupForm' test('submits email and password', async () => { const user = userEvent.setup() const onSubmit = vi.fn() render(<SignupForm onSubmit={onSubmit} />) await user.type(screen.getByLabelText(/email/i), 'user@example.com') await user.type(screen.getByLabelText(/password/i), 'correct-horse-battery-staple') await user.click(screen.getByRole('button', { name: /create account/i })) expect(onSubmit).toHaveBeenCalledWith({ email: 'user@example.com', password: 'correct-horse-battery-staple' }) })
This test proves the form logic works in isolation. Good.
It does not prove:
- the button is still discoverable after copy changes
- the auth callback returns to the correct route
- the post-submit loading state doesn’t deadlock navigation
- the selector used by another automation layer still resolves
- the page shell doesn’t reflow and hide the submit CTA on smaller viewports
- the form wrapper refactor didn’t swallow the submit event in a parent boundary
Component tests tell you whether a part behaves. Users care whether the journey completes.
This is the same reason unit coverage can reach 90% while support tickets spike. The tests are not lying. They are answering a smaller question than the business needs answered.
Why manual QA cannot keep up with queue velocity
A common reaction is: “That’s what QA is for.”
No. Not at merge queue scale.
Manual QA can still be valuable for exploratory testing, release candidates, and edge-case investigation. But it is structurally mismatched to fast queue-based delivery, especially in AI-assisted teams where code volume and PR frequency increase.
Here is the throughput problem:
- dozens of PRs are approved daily
- queue composition changes continuously
- merged candidates differ from reviewed branches
- interactions emerge only after multiple changes combine
- the set of possible workflow breakages shifts every hour
No manual process can reliably exercise the right merged combinations at the right time. By the time a QA pass finishes, the queue candidate has changed.
This is not a criticism of QA engineers. It is an architectural fact. Human verification does not compose with high-velocity integration unless automation is doing the repetitive path checking continuously.
Teams that rely on manual QA in this context end up with one of two bad outcomes:
- QA checks too little and misses regressions.
- QA becomes a bottleneck and slows delivery without materially improving confidence.
Neither solves the workflow integrity problem.
The core insight: verify actions, not just changes
The key shift is simple:
Stop treating the PR as the primary unit of confidence. Treat the user workflow as the primary unit of confidence.
That means your most important automated checks should answer questions like:
- Can a new user sign up right now?
- Can an existing user log in and reach their dashboard?
- Can a team owner upgrade billing and return to settings?
- Can a user complete onboarding across redirects and async loading states?
- Can someone invite a teammate and have that teammate accept successfully?
These are action-level guarantees. They cut across components, routes, copy, data, state, auth, browser behavior, and timing. They are exactly where merge queue failures appear.
And crucially, they must run against the code the queue is actually proposing to merge, not just each PR branch independently.
Why? Because the bug often does not exist on any single PR branch.
PR A: updates auth copy and button text. PR B: refactors page transitions. PR C: replaces test IDs with semantic roles in one shared wrapper.
Individually, all pass.
Together, the flow fails because the automation locator no longer matches, the redirect timing changes, and the CTA becomes hidden during a transient loading state.
There is no “bad PR” to blame. The failure belongs to the merged system.
So the verification must target the merged system.
What action-level verification looks like in practice
The practical form of action-level verification is a small, high-value set of end-to-end workflow checks executed against merge queue candidates.
Not 5,000 brittle browser tests. Not every permutation of every feature. Not visual pixel obsession.
A focused suite of business-critical user journeys that answer, with confidence, whether the product still works.
Playwright is a good fit because it models user behavior directly and provides the observability needed for debugging when flows fail.
Here is a signup flow example:
javascriptimport { test, expect } from '@playwright/test' test('new user can sign up and reach onboarding', async ({ page }) => { const email = `user-${Date.now()}@example.com` await page.goto('/signup') await page.getByLabel('Work email').fill(email) await page.getByLabel('Password').fill('CorrectHorseBatteryStaple123!') await page.getByRole('button', { name: /create account/i }).click() await page.waitForURL('**/onboarding') await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible() await expect(page.getByText(/create your workspace/i)).toBeVisible() })
A billing workflow example:
javascriptimport { test, expect } from '@playwright/test' test('owner can upgrade from settings', async ({ page }) => { await page.goto('/login') await page.getByLabel('Email').fill(process.env.E2E_OWNER_EMAIL) await page.getByLabel('Password').fill(process.env.E2E_OWNER_PASSWORD) await page.getByRole('button', { name: /log in/i }).click() await page.goto('/settings/billing') await expect(page.getByRole('heading', { name: /billing/i })).toBeVisible() await page.getByRole('button', { name: /upgrade plan/i }).click() await page.frameLocator('iframe[title="Secure payment input frame"]').getByPlaceholder('1234 1234 1234 1234').fill('4242424242424242') await page.getByRole('button', { name: /confirm upgrade/i }).click() await expect(page.getByText(/plan updated/i)).toBeVisible() })
A route resilience example after auth redirect:
javascriptimport { test, expect } from '@playwright/test' test('user returns to intended page after auth redirect', async ({ page }) => { await page.goto('/settings/integrations') await page.getByLabel('Email').fill(process.env.E2E_USER_EMAIL) await page.getByLabel('Password').fill(process.env.E2E_USER_PASSWORD) await page.getByRole('button', { name: /log in/i }).click() await page.waitForURL('**/settings/integrations') await expect(page.getByRole('heading', { name: /integrations/i })).toBeVisible() })
These tests are not about implementation details. They assert user-relevant outcomes.
That is the point.
Run them on merge queue candidates, not just PRs
This is where many teams still fail. They already have Playwright tests, but they run them in the wrong place.
Running workflow tests only on pull requests catches branch-local regressions. It does not catch queue-combination regressions.
If your platform supports merge queue events, attach the workflow suite there. For GitHub Actions, that usually means handling merge_group in addition to pull_request.
Example:
yamlname: Queue Workflow Verification on: pull_request: merge_group: jobs: e2e-critical-paths: runs-on: ubuntu-latest timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run build - run: npm run start:test & - run: npx wait-on http://127.0.0.1:3000 - name: Run critical Playwright flows run: npx playwright test --grep @critical env: E2E_OWNER_EMAIL: ${{ secrets.E2E_OWNER_EMAIL }} E2E_OWNER_PASSWORD: ${{ secrets.E2E_OWNER_PASSWORD }} E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }} E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }} - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/
Then tag only the flows that matter most:
javascriptimport { test, expect } from '@playwright/test' test('@critical signup completes', async ({ page }) => { // ... })
The goal is not to make the queue unbearably slow. The goal is to make it honest.
A five-to-fifteen minute critical workflow suite run against queue candidates provides more real confidence than hundreds of unit tests pretending to represent user behavior.
Add debugging signals where failures actually happen
One reason teams underinvest in browser-level testing is that they remember old end-to-end frameworks: flaky, opaque, painful to debug.
That is a tooling problem, not a reason to avoid verifying user workflows.
Modern debugging capabilities make action-level failures much easier to diagnose if you capture the right artifacts:
- traces
- videos
- screenshots
- console logs
- network logs
- DOM snapshots
In Playwright:
javascriptimport { defineConfig } from '@playwright/test' export default defineConfig({ use: { trace: 'retain-on-failure', video: 'retain-on-failure', screenshot: 'only-on-failure' }, retries: 1, reporter: [['html'], ['list']] })
When a queue candidate fails, you want to know whether it was:
- a selector drift from copy changes
- a route timing issue
- a hidden element due to layout shift
- an API contract mismatch
- an environment data problem
- a real product regression
Without artifacts, browser failures look random. With artifacts, they become debuggable engineering work.
That matters for developer productivity. Teams do not reject workflow testing because they hate reliability. They reject it when failure triage wastes time.
Good debugging support is what keeps the suite trusted.
Use stable selectors, but don’t hide from reality
A common failure pattern in AI-heavy teams is accidental selector drift. Copy changes, DOM structure changes, semantic updates, wrapper abstractions, and generated refactors can all break brittle tests.
The answer is not to avoid user-flow tests. The answer is to write them with stable, intention-revealing locators.
Prefer this:
javascriptawait page.getByRole('button', { name: /create account/i }).click()
Or this when needed:
javascriptawait page.getByTestId('signup-submit').click()
Avoid this:
javascriptawait page.locator('div > div:nth-child(2) button.primary').click()
The right balance is important:
- use accessible roles and labels where they reflect real UX
- use explicit test IDs for elements whose copy or layout changes frequently
- keep selectors attached to user intent, not incidental DOM shape
This is not test trivia. It is part of maintaining a workflow verification layer that survives normal product evolution.
Python example for backend-assisted workflow checks
Some teams want browser-level assurance plus backend validation. That is often useful for debugging workflow failures that span systems.
For example, after a signup flow, confirm the user record and onboarding state exist correctly.
pythonimport os import requests BASE_URL = os.environ["API_BASE_URL"] ADMIN_TOKEN = os.environ["ADMIN_TOKEN"] def fetch_user_by_email(email: str) -> dict: response = requests.get( f"{BASE_URL}/internal/users", params={"email": email}, headers={"Authorization": f"Bearer {ADMIN_TOKEN}"}, timeout=10, ) response.raise_for_status() return response.json() def assert_onboarding_initialized(email: str): user = fetch_user_by_email(email) assert user["status"] == "active" assert user["onboardingStep"] == "workspace_setup"
This should not replace user-path testing. It complements it by helping isolate whether a failure occurred in UI behavior, API mutation, or post-signup state setup.
Tools comparison: what each layer is good at
Teams get stuck because they want one testing tool to solve every reliability problem. That does not exist. Different tools answer different questions.
| Layer | Best for | Catches | Misses |
|---|---|---|---|
| Unit tests | Logic correctness | edge cases, pure functions, business rules | workflow issues, browser behavior, integration timing |
| Component tests | UI states in isolation | rendering, local interactions, props/state behavior | navigation, redirects, cross-page flows |
| API/integration tests | service contracts | backend behavior, serialization, auth rules | frontend wiring, layout, real UX |
| Manual QA | exploratory discovery | weird edge cases, design issues, human judgment | continuous verification at queue scale |
| Playwright/Cypress E2E | user workflows | route issues, selector drift, async timing, integrated regressions | low-level logic edge cases unless targeted |
| Synthetic prod checks | post-deploy confidence | environment-specific failures, uptime paths | pre-merge prevention |
The mistake is not using unit tests. The mistake is expecting them to protect what only workflow tests can protect.
What AI-generated code changes about this
AI is not the root problem, but it amplifies it.
AI-assisted development increases the volume of code changes that are syntactically correct, type-safe, and locally plausible. It is very good at producing refactors that preserve interfaces while subtly changing behavior at boundaries. It is also good at making broad consistency edits that look safe in review because each individual diff appears mechanical.
That creates specific risks:
- shared abstractions change more frequently
- copy and structure are modified together
- event handlers are rewritten without full workflow awareness
- route and state logic gets “cleaned up” with local correctness but altered timing
- test suites get patched to pass rather than to represent user reality
In other words, AI increases the supply of mergeable changes whose combined UX impact is under-verified.
That makes merge queues even more dangerous as a confidence signal. The queue sees approved PRs and green checks. The product sees an unexercised composition of changes.
If AI is increasing throughput, then action-level verification must increase proportionally. Otherwise you are scaling code production faster than reliability production.
Actionable practices for teams using merge queues
Here is the practical playbook.
1. Define your critical workflows explicitly
List the user journeys that matter if they fail:
- signup
- login
- password reset
- checkout or billing upgrade
- onboarding completion
- invite/accept teammate
- core task completion path
If a broken flow would cause revenue loss, activation loss, or support spikes, it belongs in the critical suite.
2. Keep the queue suite small and high-signal
Do not dump your entire end-to-end suite into the merge queue.
Aim for a minimal set of tests that represent business-critical actions. Usually 5 to 20 flows are enough for the queue gate. Run broader suites nightly or pre-release if needed.
3. Run the suite on merge queue candidates
This is the non-negotiable part. If the queue merges combined changes, the verification must target combined changes.
Support merge_group or your provider’s equivalent event. Treat branch-only verification as incomplete.
4. Use production-like environments where possible
Workflow regressions often depend on realistic auth, routing, cookies, redirects, third-party iframes, and async behavior. A mocked environment may hide exactly the class of issue you care about.
Use test tenants, sandbox payment providers, seeded accounts, and disposable data.
5. Instrument for debugging from day one
Capture traces, screenshots, videos, console logs, and network failures automatically. Make failed queue candidates easy to inspect.
If debugging a workflow failure takes an hour, engineers will bypass the suite. If it takes five minutes, they will trust it.
6. Build selector discipline
Standardize on roles, labels, and test IDs. Document when each should be used. Treat unstable selectors as a reliability bug, not just a test annoyance.
7. Watch for flaky tests, but classify failures honestly
Yes, flake exists. But many so-called flaky tests are real race conditions, loading issues, or timing bugs that users also feel.
Do not use “flaky” as a universal escape hatch. Triage failures into:
- product regression
- test bug
- environment issue
- true nondeterminism
Then fix the category, not just the symptom.
8. Make workflow health visible in the same place as CI health
Do not hide critical flow status in a separate dashboard nobody checks. Put it in the merge gate, deployment summary, and incident review process.
If a signup flow matters as much as unit tests, it should be surfaced as a first-class signal.
9. Add post-deploy synthetic checks too
Pre-merge workflow verification reduces risk. It does not eliminate environment-specific failures. Run lightweight synthetic checks in production or production-like environments after deploy:
- log in
- hit dashboard
- create a draft object
- complete a safe no-op workflow
This catches what pre-merge cannot.
10. Optimize for trust, not test count
The best reliability systems are not the ones with the most tests. They are the ones whose failures correlate strongly with real user pain.
A queue gate that fails only when something meaningful is broken is valuable. A giant suite full of noise is not.
A better CI/CD model for the merge queue era
The healthy model looks like this:
-
PR checks defend code quality and local correctness.
- lint
- types
- unit tests
- component tests
- targeted integration tests
-
Merge queue checks defend critical workflow integrity on the merged candidate.
- signup
- auth redirects
- billing
- onboarding
- key product actions
-
Post-deploy checks defend against environment-specific breakage.
- synthetic production probes
- health checks
- alerting tied to user actions
This layered approach aligns tools with actual failure modes.
PR checks answer: “Is this change locally sane?” Queue checks answer: “Does the merged product still work?” Post-deploy checks answer: “Does it still work in the real environment?”
That is a much more honest CI/CD strategy than pretending one level covers all three.
Conclusion
Merge queues are not broken. They are doing their job.
The problem is that teams have quietly assigned them a job they were never built to do: certify user experience integrity.
A merge queue optimizes throughput. It reduces integration churn. It serializes approved changes. It keeps main moving.
What it does not do, by default, is prove that users can still complete the workflows your business depends on.
That gap used to be easier to ignore when code moved slower, PRs were larger, and fewer changes were mechanically generated. It is much harder to ignore now. In AI-heavy teams, many more changes look safe in isolation, and many more regressions emerge only in composition.
That means the modern reliability question is no longer “Did a bad commit get merged?”
It is:
“Did our approved changes combine into a user flow nobody actually tested?”
If your answer today is “probably,” then your green merge queue is giving you false confidence.
The fix is not more ceremony, more review, or more faith in CI dashboards. The fix is to verify actions where failures actually happen: at the workflow level, against the merged candidate, before it ships.
That is the standard teams should adopt if they care about debugging real failures, improving testing quality, making CI/CD honest, and protecting developer productivity from the endless cost of avoidable regressions.
Because shipping faster only matters if users can still get through the product.
