A pull request can look completely reasonable and still break login in production.
You review the diff. The auth middleware change is small. The API contract looks unchanged. Unit tests pass. End-to-end smoke tests pass too, because they start from a fresh browser, log in once, hit one page, and exit. CI is green. The feature ships.
Then users get stuck in a redirect loop after their session refreshes. Or they log in successfully, open a second tab, and get logged out in the first. Or checkout empties the cart because the auth token rotation logic resets client state. Or the app keeps retrying a failed request with a stale cookie and silently bounces the user back to the login screen.
None of this was visible in the PR.
That’s the uncomfortable reality of modern debugging and testing: many failures are not code-path failures. They are state-evolution failures. They appear only after a browser has accumulated cookies, local storage, in-memory caches, redirect history, retry decisions, partially refreshed tokens, and assumptions spread across client, edge, and backend layers.
This gets worse as AI writes more application code. Generated code often looks plausible. It patches tests. It satisfies type checks. It may even add coverage. But it usually does not verify how a real authenticated session behaves over time. It proves the code can compile and some assertions can pass. It does not prove the workflow survives contact with state.
If you care about reliability, developer productivity, and honest CI/CD signals, you have to test user workflows at the action level, not just functions at the code level.
The bug class PR review keeps missing
There is a recurring category of production failures that lives between “the code is correct” and “the user can complete the task.” These bugs only emerge after multiple steps, usually involving authentication, browser storage, retries, and navigation.
Typical examples:
- login succeeds, but the next redirect sends the user back to login
- silent token refresh updates a cookie, but cached user state remains stale
- one tab logs out, another tab continues using expired auth and corrupts UI state
- cart or draft data resets after auth middleware rehydrates application state incorrectly
- an SSR page reads auth from a cookie, while client hydration reads from local storage, causing a split-brain session
- retry middleware resubmits a request after session expiry and triggers an infinite redirect chain
- role changes take effect on the server but not in the browser until a full refresh
- a new SameSite or domain cookie setting works locally but breaks behind production redirects
What these failures have in common is simple: they are temporal.
You cannot understand them from a single snapshot. A diff is a snapshot. A unit test is usually a snapshot. A static code review is definitely a snapshot. Even many end-to-end tests are snapshots because they validate one happy path from a clean state.
But session-level failures are about what changes after step 2, 5, or 20.
That distinction matters. Teams often say “we have end-to-end coverage,” but what they really have is page coverage or endpoint coverage. A real workflow is not “visit dashboard after login.” A real workflow is:
- user logs in
- token expires or is refreshed
- user opens another tab
- user performs a mutation
- backend returns 401 once
- frontend retries
- redirect callback executes
- local state rehydrates
- user returns to original page
- action either succeeds or gets trapped in a loop
If your testing does not model that progression, you are not validating the behavior users depend on.
Why CI/CD gives false confidence here
Most CI/CD pipelines are optimized for speed, determinism, and isolated assertions. That is good engineering practice for many classes of bugs. It is also exactly why session failures slip through.
A standard pipeline usually includes:
- linting
- type checking
- unit tests
- integration tests against mocked services
- maybe a small set of browser tests
- deployment preview validation
All useful. None sufficient.
The problem is not that CI is bad. The problem is that CI tends to validate components while users experience sequences.
A green pipeline often means:
- individual functions behaved as expected under controlled inputs
- components rendered correctly in expected states
- a fresh login worked once in a clean browser
- an API endpoint returned a valid response in isolation
It does not mean:
- browser state remained coherent over time
- redirects preserved user intent across auth boundaries
- token refresh worked after real expiry, not mocked expiry
- cached client data was invalidated correctly after re-authentication
- cross-tab synchronization didn’t corrupt state
- retries didn’t amplify auth failures into loops
This is the false confidence problem in CI/CD. The pipeline reports confidence about the wrong thing.
That mismatch gets expensive because session bugs are exactly the kind users interpret as total failure. A slightly wrong CSS rule is annoying. A broken auth loop means “the app is down” from the user’s perspective.
Why unit tests and component tests are structurally bad at this
Unit tests are not failing you because your team is lazy. They are failing you because they are structurally unable to model the environment where these bugs happen.
A typical auth utility test in JavaScript might look like this:
jsimport { refreshTokenIfNeeded } from './auth'; test('refreshes token when expired', async () => { const api = { refresh: jest.fn().mockResolvedValue({ token: 'new-token' }) }; const session = { token: 'old-token', expiresAt: Date.now() - 1000 }; const result = await refreshTokenIfNeeded(session, api); expect(api.refresh).toHaveBeenCalled(); expect(result.token).toBe('new-token'); });
This test is fine. It proves the function does what it claims.
It does not prove:
- the new token is actually persisted where the app reads it later
- old requests in flight use the right credentials
- page navigation after refresh does not trigger another auth check with stale state
- a failed refresh correctly clears session without losing unrelated state
- SSR and client-side code agree on which token is authoritative
Component tests have a similar boundary. They can validate that a login form submits and that the UI updates after a mocked success response. But the browser is not really evolving. Cookies are often mocked. Redirects are faked. storage events do not behave exactly like production. The test harness rarely reflects the timing weirdness that causes real bugs.
This is why a large test suite can coexist with fragile user workflows.
Why manual QA also misses it
Teams often assume QA will catch these issues. Sometimes QA does. Often QA cannot, for three reasons.
First, session bugs are intermittent. They depend on expiry timing, previous navigation, browser storage leftovers, or race conditions. Intermittent bugs are easy to miss in scripted verification.
Second, manual QA rarely exercises enough permutations of accumulated state. A tester might verify login, logout, and checkout individually. They may not test “login, idle for 31 minutes, open a new tab, apply a coupon, get redirected through SSO, return to checkout, then retry payment after a 401.”
Third, manual QA is not a scalable reliability strategy in fast-moving teams. If AI-generated changes increase PR volume, you need automated testing that tracks real workflow risk. Otherwise the number of “looks okay” changes outpaces the number of meaningful user-state validations.
QA is valuable, especially for exploratory debugging and identifying edge cases. But it cannot be the primary control for session-level regressions.
The core insight: test state transitions, not just rendered states
The key shift is this:
Stop thinking of reliability as verifying outputs from isolated inputs. Start thinking of reliability as verifying state transitions across user actions.
That means your tests should assert things like:
- after login, the intended destination is preserved
- after token expiry, the app refreshes once and continues the original action
- after logout in tab A, tab B transitions to logged-out state predictably
- after a 401 during checkout, cart state survives re-authentication
- after redirect callback, cookies, local storage, and in-memory user state agree
These are not traditional “does function X return Y” assertions. These are action-level assertions about workflow continuity.
That is the level where users experience quality.
This is also the level where AI-generated code is weakest. An agent can update a reducer, patch middleware, and regenerate tests around expected code behavior. It usually will not decide, on its own, to verify whether a user who starts checkout before token expiry can still complete payment after refresh and redirect. That requires a workflow model, not just code synthesis.
A realistic failure: login loop caused by stale post-auth state
Consider a common pattern in single-page apps with server-side rendering:
- server checks auth cookie and renders protected page
- client hydrates using user state from local storage or an in-memory store
- if client store says unauthenticated, frontend router redirects to
/login
Now imagine a PR that changes how user state is rehydrated after login.
The diff might look harmless:
js// before export function loadSession() { return JSON.parse(localStorage.getItem('session') || 'null'); } // after export function loadSession() { const raw = localStorage.getItem('session_v2'); return raw ? JSON.parse(raw) : { user: null }; }
No obvious issue. Tests pass. But production login now does this:
- auth provider redirects back with a valid cookie
- server renders dashboard because cookie is valid
- client hydrates and reads
session_v2, which is empty - client thinks user is logged out
- router redirects to
/login /loginsees valid cookie and redirects back to dashboard- loop
No unit test will naturally catch this unless you explicitly simulate SSR + client hydration + storage state mismatch + redirect behavior.
That is the pattern: individually correct parts compose into a broken workflow.
What action-level validation in CI should actually do
If you want CI/CD to catch this class of issue, your browser automation needs to act less like a smoke test and more like a user session auditor.
In practical terms, action-level validation should exercise:
- authenticated navigation across multiple pages
- redirect chains through login or SSO callbacks
- cookie creation, update, expiry, and deletion
- local storage and session storage consistency
- token refresh behavior after forced expiry
- retries after 401 or network interruption
- persistence of cart/draft/form state across re-authentication
- cross-tab session synchronization
- back-button and return-to-origin behavior after auth redirects
This does not mean “test everything in the browser.” It means target the workflows where accumulating state creates risk.
Playwright is a better fit than most teams use it for
A lot of teams use Playwright as a fancy page-load checker. They log in, verify a heading, and move on. That leaves most of the value on the table.
Playwright is powerful because it can inspect browser behavior over time: cookies, storage, tabs, network responses, redirects, and request retries.
Here is a stronger example than a simple login smoke test.
Example: preserve destination across auth redirect
tsimport { test, expect } from '@playwright/test'; test('user returns to intended page after auth redirect', async ({ page, context }) => { await page.goto('/billing'); await expect(page).toHaveURL(/\/login/); await page.fill('[name="email"]', 'user@example.com'); await page.fill('[name="password"]', 'secret123'); await page.click('button[type="submit"]'); await expect(page).toHaveURL(/\/billing/); await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible(); const cookies = await context.cookies(); const sessionCookie = cookies.find(c => c.name === 'session'); expect(sessionCookie).toBeTruthy(); });
That is a baseline. Now improve it by validating state continuity after expiry.
Example: force token expiry and verify action recovery
tsimport { test, expect } from '@playwright/test'; test('checkout survives token expiry and re-auth flow', async ({ page, context }) => { await page.goto('/login'); await page.fill('[name="email"]', 'user@example.com'); await page.fill('[name="password"]', 'secret123'); await page.click('button[type="submit"]'); await page.goto('/cart'); await page.click('text=Add warranty'); await expect(page.getByText('Warranty added')).toBeVisible(); await context.addCookies([ { name: 'session', value: 'expired-token', domain: 'localhost', path: '/', httpOnly: true, secure: false, sameSite: 'Lax' } ]); await page.goto('/checkout'); await page.click('button:has-text("Place order")'); await expect(page).toHaveURL(/\/login|\/auth\/callback/); await page.fill('[name="email"]', 'user@example.com'); await page.fill('[name="password"]', 'secret123'); await page.click('button[type="submit"]'); await expect(page).toHaveURL(/\/checkout/); await expect(page.getByText('Warranty added')).toBeVisible(); await expect(page.getByRole('button', { name: 'Place order' })).toBeVisible(); });
The point is not that this exact test is perfect. The point is that it validates business continuity after auth interruption. That is what matters.
Example: cross-tab logout synchronization
tsimport { test, expect } from '@playwright/test'; test('logout in one tab invalidates session in another tab cleanly', async ({ browser }) => { const context = await browser.newContext(); const tabA = await context.newPage(); const tabB = await context.newPage(); for (const page of [tabA, tabB]) { await page.goto('/login'); await page.fill('[name="email"]', 'user@example.com'); await page.fill('[name="password"]', 'secret123'); await page.click('button[type="submit"]'); await expect(page).toHaveURL(/\/dashboard/); } await tabA.click('button:has-text("Logout")'); await expect(tabA).toHaveURL(/\/login/); await tabB.reload(); await expect(tabB).toHaveURL(/\/login/); await expect(tabB.getByText('Your session has ended')).toBeVisible(); });
This is the sort of thing static review simply cannot see.
Add network-level assertions, not just DOM assertions
A common mistake in browser testing is to assert only what appears on screen. But session failures often start in network behavior before the UI obviously breaks.
Use Playwright to inspect:
- 302 chains
- repeated 401s
- retry counts
- callback parameters
- request headers after refresh
Example:
tsimport { test, expect } from '@playwright/test'; test('app does not enter infinite auth redirect loop', async ({ page }) => { const redirects: string[] = []; page.on('response', async (response) => { const status = response.status(); if (status >= 300 && status < 400) { redirects.push(response.url()); } }); await page.goto('/dashboard'); await expect(page).toHaveURL(/\/dashboard|\/login/); expect(redirects.length).toBeLessThan(6); });
That assertion is crude, but useful. Many auth bugs are visible first as pathological redirect patterns.
Python example: API/session verification outside the UI layer
You should not rely only on browser tests. Some session behaviors can be validated faster in API-level workflow tests while still preserving state progression.
For example, verify that login, refresh, and logout state transitions behave consistently at the HTTP layer.
pythonimport requests BASE_URL = "http://localhost:3000" def test_login_refresh_logout_flow(): session = requests.Session() login = session.post(f"{BASE_URL}/api/login", json={ "email": "user@example.com", "password": "secret123" }) assert login.status_code == 200 assert "session" in session.cookies me = session.get(f"{BASE_URL}/api/me") assert me.status_code == 200 assert me.json()["email"] == "user@example.com" refresh = session.post(f"{BASE_URL}/api/refresh") assert refresh.status_code == 200 assert "session" in session.cookies logout = session.post(f"{BASE_URL}/api/logout") assert logout.status_code == 200 me_after_logout = session.get(f"{BASE_URL}/api/me", allow_redirects=False) assert me_after_logout.status_code in (401, 302)
This still does not replace browser validation, but it helps isolate whether failures are rooted in HTTP/session semantics or client-side state handling.
Put these checks into CI the right way
If these tests only run on laptops before major releases, they will not change reliability. They need to become part of normal delivery.
A practical GitHub Actions example:
yamlname: workflow-reliability on: pull_request: push: branches: [main] jobs: session-flows: runs-on: ubuntu-latest timeout-minutes: 20 services: postgres: image: postgres:15 env: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd="pg_isready -U app" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install --with-deps - name: Start app run: | npm run db:migrate npm run start:test & npx wait-on http://localhost:3000 - name: Run session workflow tests run: npx playwright test tests/session --reporter=line - name: Upload traces if: failure() uses: actions/upload-artifact@v4 with: name: playwright-traces path: test-results/
The important part is not the YAML itself. It is that these tests are first-class citizens in CI/CD, with traces preserved for debugging.
When a session-level test fails, the value is not just the red build. The value is reproducible evidence: redirect history, screenshots, network trace, browser storage state. That reduces time-to-debug dramatically.
What to test first if you can’t test everything
You do not need a giant suite on day one. In fact, if you try to model every workflow immediately, the suite will become noisy and ignored.
Start with the workflows most likely to cause user-visible outages:
- unauthenticated user requests protected page and returns correctly after login
- authenticated user continues task after token expiry or 401
- logout invalidates all tabs cleanly
- cart/draft/form state survives re-authentication
- role/permission changes take effect without inconsistent UI state
- retry logic does not create loops or duplicate writes
Those six tests catch an outsized amount of real-world pain.
Tools comparison: what each layer catches and misses
Every testing layer has value. The mistake is expecting one layer to provide guarantees it cannot provide.
Unit tests
Best for:
- pure business logic
- reducers, utilities, validators
- edge-case enumeration
- fast feedback during development
Weak at:
- browser storage behavior
- redirects
- cookie semantics
- multi-step state accumulation
- race conditions across tabs or navigation
Integration tests
Best for:
- contracts between modules or services
- database and API interactions
- middleware behavior in controlled environments
Weak at:
- real browser session evolution
- hydration mismatches
- redirect chain correctness
- UX continuity after auth interruptions
Browser automation with Playwright or Cypress
Best for:
- real workflow validation
- cookies, storage, navigation, tabs
- authenticated flows
- evidence-rich debugging through traces and videos
Weak at:
- speed compared with lower layers
- maintainability if overused for trivial assertions
- deterministic behavior if environment control is poor
Manual QA
Best for:
- exploratory debugging
- spotting weird user experience failures
- validating unfamiliar edge cases
Weak at:
- repeatability
- scale
- reliable enforcement in CI/CD
Static PR review
Best for:
- architecture
- code quality
- security smells
- maintainability concerns
Weak at:
- any behavior requiring time, state accumulation, or real browser semantics
This is why “the PR looked fine” is not a defense. Review is necessary. It is not observationally capable of seeing session-level bugs.
Actionable practices that actually improve reliability
Here is the practical playbook.
1. Define workflow contracts explicitly
Do not just say “login should work.” Write down invariants such as:
- protected navigation preserves return URL
- one expired request triggers at most one refresh attempt
- logout clears cookie, in-memory user state, and local storage markers
- cart state persists across re-auth within the same session
- auth failure surfaces a user-visible message instead of looping silently
If the contract is vague, the tests will be vague.
2. Test from dirty state, not only clean state
Fresh browser tests are useful, but many production bugs come from existing state.
Add scenarios with:
- stale local storage keys
- partially expired cookies
- outdated tabs
- pending requests during logout
- pre-populated cart or draft state
Clean-state-only testing is one reason teams overestimate reliability.
3. Force failure modes intentionally
Do not wait for natural token expiry or flaky network conditions. Simulate them on purpose.
Examples:
- inject expired cookies
- intercept a request and return 401 once
- delay callback responses
- open a second tab before logout
- clear one storage mechanism but not another
Good debugging starts when you can force the system into the dangerous state on demand.
4. Assert on continuity, not just success
Instead of only asking “did the user eventually land on the page?”, ask:
- was the original action preserved?
- was state duplicated or reset?
- how many redirects happened?
- was the user prompted clearly?
- were duplicate mutations avoided?
A test that merely proves eventual success can still miss deeply broken user experience.
5. Keep browser tests focused on risk-heavy workflows
Do not push every detail into Playwright. That creates slow, brittle suites and hurts developer productivity.
Use unit and integration tests for local logic. Use browser automation where browser state matters.
A simple rule:
- if the bug can exist without cookies, storage, redirects, tabs, or navigation timing, test it lower in the pyramid
- if it cannot, test it in the browser
6. Capture traces and storage snapshots on failure
When a session test fails, developers need evidence quickly.
Store:
- Playwright traces
- screenshots
- videos if needed
- console logs
- network logs
- storage state dumps
The difference between a hated E2E suite and a trusted one is often not flake rate alone. It is debuggability.
7. Run a small critical suite on every PR, a broader suite on main
Not every workflow test needs to gate every commit.
A good pattern:
- PR suite: 5–10 critical session workflows
- main branch or nightly suite: broader auth, billing, cart, and role-transition scenarios
This balances reliability with CI/CD speed.
8. Treat auth and session handling as a product surface, not infrastructure glue
Many teams treat session behavior like plumbing. That is a mistake. Login, redirect continuity, draft persistence, and logout consistency are core user experience features.
If they break, users do not care that your domain logic had 98% coverage.
9. Review generated code with workflow skepticism
As AI contributes more code, reviewers should ask:
- what state does this change introduce or invalidate?
- what happens after the first successful action?
- what browser storage does this depend on?
- what happens if auth changes mid-flow?
- which user workflow test proves this works beyond the happy path?
That is a better review question than “do the tests pass?”
A note on flakiness
Whenever engineers hear “more browser tests,” they reasonably worry about flake.
That concern is legitimate. Bad end-to-end suites become expensive theater.
But flake is often used as an excuse to avoid testing the failures users actually encounter. The right response is not to give up on workflow testing. The right response is to design workflow tests well:
- control environment setup tightly
- seed deterministic data
- test stable user-visible contracts
- avoid brittle selectors
- isolate external dependencies where possible
- use traces to fix flaky timing rather than ignore it
There is a meaningful difference between brittle UI vanity tests and high-value workflow reliability tests. Do not confuse the two.
The bigger shift for teams shipping AI-assisted software
As code generation accelerates, more teams will learn a painful lesson: code review quality does not scale linearly with code volume, and passing tests do not necessarily represent real user safety.
AI can help produce implementation faster. It can also produce more surface area for subtle session bugs:
- duplicate auth logic in client and server layers
- inconsistent storage key migration
- incomplete retry handling
- generated middleware that looks idiomatic but composes badly
- tests that validate mocked assumptions instead of production behavior
That means the old confidence model gets weaker over time.
You cannot compensate for this by demanding more careful PR review. Humans are not going to infer every emergent browser-state failure from a diff. And AI is not going to reliably self-audit session continuity unless you force that verification into the pipeline.
So the durable answer is not better guessing. It is better testing.
Not more assertions for their own sake. Better assertions about what users are actually trying to do.
Conclusion
A clean diff does not prove a workflow works. A green build does not prove a session survives real usage. And a happy-path login check does not prove authentication behaves correctly once state starts accumulating.
The failures that hurt users most often happen across steps: redirects, refreshes, retries, tabs, storage rehydration, and auth transitions. Static review cannot see them. Unit tests usually cannot model them. Manual QA cannot scale to cover them consistently.
If you want honest reliability, your testing strategy has to validate stateful user workflows in CI/CD. That means exercising authenticated flows, redirect chains, cookies, local storage, expiry behavior, retry handling, and cross-tab state transitions. It means asserting continuity, not just isolated correctness.
This is not glamorous. It is not AI hype. It is just what production systems require.
Because users do not care whether the PR looked fine.
They care whether they can log in, stay logged in, and finish what they started.
