A release goes green. CI passes. Unit tests are solid. The PR has screenshots, approvals, and maybe even an end-to-end test or two. Then the feature goes live in Europe for enterprise customers on a staged rollout, behind a flag that depends on a billing entitlement and an experiment assignment. Suddenly the primary action disappears, the fallback API rejects the request, and support starts collecting screenshots from users seeing a state no one tested.
Nothing was technically "untested" in the narrow sense. The code paths had coverage. The UI flow worked in the default environment. The API contract looked fine. The problem was that your tests never saw the product in the runtime context where users actually experienced it.
That is where a growing share of production failures now live.
As software delivery gets faster and product behavior gets more dynamic, breakages increasingly come from configuration, permissions, rollout conditions, region rules, data shape, and transient runtime state—not from the clean code paths exercised in CI/CD. And AI-generated code makes this worse, not because the code is always bad, but because it is usually plausible. Plausible code passes local checks. Plausible tests validate the obvious branch. Plausible implementations assume the environment they were written in is the environment users will have.
It usually isn't.
The real problem is not code coverage, it is context coverage
Teams still talk about test coverage like the main risk is whether a line ran. That was never sufficient, but it is especially misleading now. Modern applications are assembled from conditions:
- Feature flags
- Entitlements and plan restrictions
- Region-specific behavior
- Role and permission models
- A/B experiments
- Progressive rollouts
- Device capability checks
- Data-dependent rendering
- External service state
- Cached or stale configuration
Any one of these can redefine what a user sees and what actions are even possible. Combined, they create a runtime matrix that is often larger than the codebase itself.
A button does not merely exist because the component rendered in Storybook. It exists only if:
- the flag is on,
- the account has the right entitlement,
- the user has the right role,
- the experiment assigned the right variant,
- the region permits the behavior,
- the backend returned compatible data,
- and the rollout service answered before the UI defaulted.
That means your test suite can be "good" by conventional metrics and still completely miss the states that matter most.
This is the testing blind spot that modern CI/CD pipelines routinely ignore: not whether a feature works, but whether the feature exists, behaves, and remains actionable across the runtime contexts that determine its availability.
Why traditional CI/CD gives false confidence
CI/CD is excellent at one thing: verifying a known build against a controlled environment. The problem is that most production incidents do not happen in a controlled environment.
In many teams, CI runs with:
- one seed dataset,
- one region,
- one feature flag baseline,
- one user role,
- one browser configuration,
- one experiment default,
- one set of environment variables.
This is efficient, deterministic, and dangerously incomplete.
A typical pipeline answers questions like:
- Does the code compile?
- Do unit tests pass?
- Does the API respond under expected fixtures?
- Does the happy-path UI flow work for a default user?
Those are useful questions. They are not the same as:
- What changes when the flag flips after deploy?
- What happens for enterprise accounts with legacy entitlements?
- What does this flow look like in a region with different regulatory constraints?
- Does the action still exist for a user who is in variant B and has read-only permissions?
- Does the fallback path still work when the rollout service times out?
The hard truth is that most CI systems are optimized for build confidence, not runtime confidence.
That distinction matters. Build confidence says the software artifact is valid. Runtime confidence says the user experience remains valid when the application is subject to the messy state combinations of production. Those are not the same thing, and pretending they are is how teams ship broken features with green pipelines.
Unit tests are not built to reason about runtime matrices
Unit tests still matter. They are often the cheapest form of debugging and the fastest way to pin behavior to expectations. But unit tests are local truth, and many modern failures are global truth problems.
Consider a simple React component that shows a button when a flag and permission are present:
jsexport function ExportButton({ flags, permissions, region }) { const canExport = flags.exportCsv && permissions.includes('export:data') && region !== 'DE'; if (!canExport) return null; return <button>Export CSV</button>; }
You can write unit tests for that easily:
jsimport { render, screen } from '@testing-library/react'; import { ExportButton } from './ExportButton'; test('renders export button when allowed', () => { render( <ExportButton flags={{ exportCsv: true }} permissions={['export:data']} region="US" /> ); expect(screen.getByText('Export CSV')).toBeInTheDocument(); }); test('does not render export button in DE', () => { render( <ExportButton flags={{ exportCsv: true }} permissions={['export:data']} region="DE" /> ); expect(screen.queryByText('Export CSV')).toBeNull(); });
These are good tests. They are still not enough.
They do not tell you whether:
- the flag provider returns the expected value for real users,
- the permission payload shape changed upstream,
- the region was inferred differently on the server and client,
- the button disappeared only after hydration,
- the backend export endpoint enforces the same entitlement logic,
- analytics or experiment wrappers changed the component tree,
- or an AI-generated refactor duplicated business logic inconsistently across layers.
In other words, the unit test proves the component behaves correctly when handed the right local inputs. It does not prove the system produces those inputs correctly or consistently in production.
That is why code-path testing alone produces false confidence. The failure mode is not inside the function. It is in the conditions that determine whether the function matters.
Manual QA fails for the same reason, just slower
A lot of organizations try to cover this gap with QA checklists. The release manager says: test as admin, test as member, test in staging, test with the flag on. This works until the matrix grows beyond human memory.
The problem with manual verification is not that humans are careless. It is that the space is combinatorial.
If you have:
- 4 feature flags,
- 3 roles,
- 3 plan tiers,
- 2 regions,
- 2 experiment variants,
then you already have 144 combinations before you even consider browser differences, old accounts, migrated data, or external service failures.
No manual QA process covers that reliably. What usually happens is that teams test the combinations they remember, the ones that broke last time, and the ones easiest to set up. Everything else becomes a blind spot.
That blind spot gets worse when release logic is distributed across systems. One flag lives in LaunchDarkly. Permissions come from the auth service. Entitlements live in billing. Region is inferred from account metadata. Experiment variants come from analytics tooling. By the time a page renders, your product behavior is the result of multiple runtime authorities. QA cannot reason about that consistently without automation that sets, observes, and verifies context deliberately.
AI-generated code amplifies the configuration gap
AI code generation changes the economics of implementation. It does not change the physics of production.
An AI assistant can generate a perfectly plausible component, endpoint, migration, and test. It can infer the common case quickly. That is useful. But plausible is not reliable.
AI-generated code often reflects the default assumptions present in prompts, docs, and nearby code:
- default flag state,
- default permissions,
- default locale,
- default seed data,
- default service behavior.
If your prompt says, "add export button for premium users," the generated code may hide the button based on a frontend flag and call an endpoint that assumes the backend checks plan access. Or it may hardcode role assumptions from a nearby component. Or it may add tests for one happy-path fixture and call it complete.
None of this is malicious or irrational. It is exactly what pattern completion looks like.
The risk is that teams mistake generated completeness for verified completeness. They get more code, more tests, and more green checks, but not necessarily more confidence. If anything, AI can increase the rate at which unverified configuration assumptions enter the codebase.
That is why modern testing has to move up a level. Not just "did the code branch run" but "did the user action remain available and valid under the real context switches that govern the feature?"
The core insight: test actions under explicit runtime state
The most important shift is simple:
Do not test features as static UI flows. Test user actions under explicit runtime context.
That means every critical path should be verified against the state dimensions that control its existence and validity:
- flag state,
- role,
- entitlement,
- region,
- experiment variant,
- rollout condition,
- service availability,
- relevant data setup.
The question is not just, "Can a user export a report?"
It is:
- Can the right user export when the flag is on?
- Is the action absent when the flag is off?
- Is the action visible but blocked for insufficient entitlement?
- Does the backend reject unauthorized direct calls?
- Does region-specific policy change the UI and API consistently?
- What happens if rollout assignment changes between page load and action?
This is action-level verification. It is more realistic than pure UI snapshots and more useful than generic end-to-end smoke tests because it binds intent to context.
A workflow is only tested if the runtime state that makes the workflow possible has been tested too.
What this looks like in practice with Playwright
Playwright is useful here because it lets you verify real browser behavior while controlling setup aggressively. The goal is not to explode your suite with every combination. The goal is to codify the dimensions that matter and cover representative, high-risk state transitions.
Here is a simple example that verifies export behavior across contexts.
tsimport { test, expect } from '@playwright/test'; type Scenario = { name: string; flags: { exportCsv: boolean }; permissions: string[]; plan: 'free' | 'pro' | 'enterprise'; region: 'US' | 'DE'; expectedVisible: boolean; }; const scenarios: Scenario[] = [ { name: 'pro user in US with flag on sees export', flags: { exportCsv: true }, permissions: ['export:data'], plan: 'pro', region: 'US', expectedVisible: true, }, { name: 'user in DE does not see export even with flag on', flags: { exportCsv: true }, permissions: ['export:data'], plan: 'pro', region: 'DE', expectedVisible: false, }, { name: 'free plan user does not see export', flags: { exportCsv: true }, permissions: ['export:data'], plan: 'free', region: 'US', expectedVisible: false, }, { name: 'flag off hides export for eligible user', flags: { exportCsv: false }, permissions: ['export:data'], plan: 'enterprise', region: 'US', expectedVisible: false, }, ]; for (const scenario of scenarios) { test(scenario.name, async ({ page, request }) => { await request.post('/test/setup-runtime-context', { data: { flags: scenario.flags, permissions: scenario.permissions, plan: scenario.plan, region: scenario.region, }, }); await page.goto('/reports'); const exportButton = page.getByRole('button', { name: 'Export CSV' }); if (scenario.expectedVisible) { await expect(exportButton).toBeVisible(); } else { await expect(exportButton).toHaveCount(0); } }); }
The important detail is not the loop. It is the explicit setup of runtime context. Your test environment needs a supported way to declare flags, permissions, plan, and region without depending on manual toggles or hidden defaults.
This often means building test-only setup endpoints or fixtures that can:
- seed account entitlements,
- assign experiment variants,
- impersonate roles,
- stub or override flag values,
- control geolocation or region,
- inject service failure conditions.
Without that, most end-to-end tests are theater. They visit pages in a generic state and assert whatever happens to be visible.
Verify both availability and enforcement
A common testing mistake is checking only whether an action appears. But runtime-context bugs often split across frontend and backend.
For example, the UI may hide export correctly, while the API still allows unauthorized access. Or the UI shows the action, but the server rejects it because the entitlement model changed.
Test both.
tstest('backend rejects export when entitlement is missing', async ({ request }) => { await request.post('/test/setup-runtime-context', { data: { flags: { exportCsv: true }, permissions: ['export:data'], plan: 'free', region: 'US', }, }); const response = await request.post('/api/reports/export', { data: { reportId: 'rpt_123' }, }); expect(response.status()).toBe(403); });
And then connect it to the user workflow:
tstest('eligible user can export report end-to-end', async ({ page, waitForEvent, request }) => { await request.post('/test/setup-runtime-context', { data: { flags: { exportCsv: true }, permissions: ['export:data'], plan: 'enterprise', region: 'US', }, }); await page.goto('/reports'); await page.getByRole('button', { name: 'Export CSV' }).click(); const toast = page.getByText('Export started'); await expect(toast).toBeVisible(); });
Now your tests are no longer asking whether some isolated component behaved. They are verifying that a user action exists and succeeds under the runtime conditions that define its legitimacy.
Model the environment matrix instead of pretending it does not exist
You cannot test every combination. That is true, but it is often used as an excuse to test almost none.
A better approach is to model your environment matrix and choose coverage intentionally.
Start by identifying state dimensions for a workflow:
| Dimension | Example values |
|---|---|
| Feature flag | on, off |
| Plan/entitlement | free, pro, enterprise |
| Role | admin, editor, viewer |
| Region | US, EU, DE |
| Experiment | control, variant A, variant B |
| Backend mode | normal, degraded, timeout |
Then classify them:
- Gating dimensions: determine whether the feature exists at all.
- Behavior dimensions: change how the feature works once present.
- Failure dimensions: affect resilience, fallback, or partial breakage.
From there, test:
- baseline allowed path,
- baseline denied path,
- one representative path per gating dimension,
- high-risk cross-product pairs,
- known rollout transitions,
- degraded-state behavior.
This is pairwise-plus-risk coverage, not exhaustive coverage. It is realistic and far better than a single happy-path environment.
Add runtime-context testing to CI/CD without making it unusable
The usual objection is time. If we test more states, CI gets slower. That is true if you naïvely multiply every test by every context.
Do not do that.
Split your pipeline by confidence objective.
1. Fast PR checks
These should still run unit tests, type checks, and a small set of deterministic workflow tests in the default environment plus a few critical non-default contexts.
Example GitHub Actions config:
yamlname: pr-checks on: pull_request: jobs: app-tests: 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: npx playwright test tests/smoke/default-context.spec.ts - run: npx playwright test tests/smoke/critical-flag-context.spec.ts
2. Scheduled matrix runs
Run broader context suites on a schedule and before major rollouts. These catch drift in flags, permissions, and environment assumptions without blocking every PR.
yamlname: runtime-matrix on: schedule: - cron: '0 */6 * * *' workflow_dispatch: jobs: matrix-tests: runs-on: ubuntu-latest strategy: fail-fast: false matrix: region: [US, DE] plan: [free, enterprise] experiment: [control, variant-b] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: | REGION=${{ matrix.region }} \ PLAN=${{ matrix.plan }} \ EXPERIMENT=${{ matrix.experiment }} \ npx playwright test tests/runtime
3. Pre-rollout verification
Before flipping a high-impact flag, run targeted action-level tests in the exact rollout context you plan to ship.
This is where many teams should invest next. Treat flag flips and configuration changes as release events, not as harmless dashboard operations.
Use backend fixtures wisely
The easiest way to make runtime-context testing maintainable is to centralize context setup. A Python example makes this concrete.
Suppose you expose a test-only fixture endpoint in a staging-like environment:
pythonfrom fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class RuntimeContext(BaseModel): user_id: str flags: dict[str, bool] permissions: list[str] plan: str region: str experiment: str | None = None @app.post('/test/setup-runtime-context') def setup_runtime_context(ctx: RuntimeContext): save_flags_for_user(ctx.user_id, ctx.flags) save_permissions_for_user(ctx.user_id, ctx.permissions) save_plan_for_user(ctx.user_id, ctx.plan) save_region_for_user(ctx.user_id, ctx.region) if ctx.experiment: assign_experiment(ctx.user_id, ctx.experiment) return {'ok': True}
This does two things:
- It makes runtime state explicit and reproducible.
- It turns hidden environment assumptions into test inputs.
That is exactly what you want for debugging. When a test fails, you should be able to say, "export broke for enterprise users in DE with flag on and control variant," not "some end-to-end test timed out in staging."
Observable context is debuggable context.
Tools comparison: where different testing layers help and where they fail
No single tool solves this. The issue is not missing tooling in the abstract. It is using each layer for the right purpose.
| Tool/layer | Good at | Weak at |
|---|---|---|
| Unit tests | Fast validation of local logic, edge conditions in pure functions | Real runtime state, integration drift, feature availability across systems |
| Integration tests | Verifying service boundaries and contracts | User-visible context combinations, browser behavior, rollout timing |
| Playwright/Cypress E2E | Real workflow verification, action-level testing, UI + backend consistency | Suite sprawl if context setup is weak, slower feedback |
| Manual QA | Exploratory testing, spotting weird UX issues | Systematic matrix coverage, repeatability, CI/CD integration |
| Observability/replay tools | Debugging production failures, seeing real user impact | Preventing failures before release |
| Feature flag platforms | Controlled rollouts, segmentation, targeting | Not a substitute for verification of resulting states |
The pattern is straightforward:
- Unit tests protect logic.
- Integration tests protect contracts.
- Workflow tests protect user actions.
- Context setup protects realism.
- Production telemetry protects learning.
If you remove any of those, your debugging gets harder and your confidence becomes performative.
Actionable practices that reduce runtime-context failures
Here is what strong teams do differently.
1. Treat configuration changes as production releases
A flag flip can be as risky as a deploy. So can a new entitlement mapping, region rollout, or experiment launch.
Require:
- named owner,
- rollout checklist,
- pre-rollout verification,
- rollback path,
- observability for key actions.
If a dashboard toggle can break a workflow, it deserves release discipline.
2. Define context dimensions for every critical workflow
For each major action—checkout, invite, export, upgrade, publish, delete—document the runtime conditions that gate it.
A lightweight template:
- Action: Export report
- Gated by: exportCsv flag, paid plan, export permission, region policy
- Behavior modifiers: experiment variant, report size
- Failure dependencies: billing service, export worker queue
Now your testing strategy has something concrete to target.
3. Build test fixtures for flags, roles, plans, and regions
If your test suite cannot set runtime state explicitly, it will drift toward default-state fiction.
Invest in APIs, seed scripts, or provider mocks that let tests declare context in one place.
4. Test absence, not just presence
Many important failures are about things disappearing incorrectly, appearing incorrectly, or being inconsistently enforced.
Add tests for:
- action not visible,
- action visible but disabled,
- unauthorized direct API access blocked,
- fallback UI shown under degradation.
5. Prioritize state transitions
Some of the nastiest bugs happen when context changes mid-session:
- a flag flips during rollout,
- a user upgrades plan,
- permissions are revoked,
- experiment assignment changes,
- region detection differs after re-login.
Test transitions, not just static snapshots.
6. Sample the matrix based on risk, not symmetry
Do not spend equal effort on all combinations. Focus on:
- revenue paths,
- compliance-sensitive regions,
- enterprise entitlements,
- newly introduced flags,
- recently migrated auth logic,
- AI-generated or rapidly refactored areas.
This is where the highest debugging leverage is.
7. Make runtime context visible in failures
When tests fail, log:
- flag values,
- user role,
- plan,
- region,
- experiment assignment,
- backend mode,
- relevant IDs and fixtures.
A failed test without context is just noise. A failed test with context is a debugging artifact.
8. Close the loop with production signals
Testing should be informed by real failures. If support reports that only enterprise users in Germany on variant B cannot export, that should become a permanent automated scenario.
The best workflow tests are often institutional memory encoded in code.
What teams should stop doing
Some habits need to die.
Stop saying:
- "The tests passed, so the feature is fine."
- "QA checked it in staging."
- "The flag is off by default, so it is safe."
- "We can always roll it back."
- "The endpoint is protected server-side, so the UI state doesn't matter."
These are all versions of the same mistake: assuming the tested environment is representative of the shipped environment.
It often is not.
Also stop writing end-to-end tests that rely on ambient state nobody understands. If the test requires the right account, hidden seed data, and a remembered flag setup from some previous step, it is not a serious reliability tool. It is a ritual.
The new testing contract for modern delivery
If your product behavior is controlled by runtime context, your testing contract has to include runtime context.
That means:
- Every critical action has declared gating conditions.
- Tests can set those conditions explicitly.
- CI/CD validates more than the default environment.
- Flag flips and rollout changes trigger verification.
- Frontend availability and backend enforcement are tested together.
- Production failures feed back into scenario coverage.
This is not overengineering. It is the minimum standard for software whose behavior changes after deploy.
And that is most software now.
Conclusion
The dangerous fiction in modern engineering is that a green pipeline means the product is ready. Usually it means something narrower: the code worked in the environment your tests remembered to create.
But users do not operate in that environment. They operate under flags, plans, permissions, regions, experiments, degraded services, and account histories your happy-path suite rarely models.
That is why so many bugs now survive CI/CD. Your tests are looking at code paths while the failure is hiding in runtime context.
AI-generated code raises the stakes because it accelerates implementation without automatically increasing verification. You can ship more features faster, but you also ship more assumptions faster. If your testing strategy still centers on default-state execution, you are scaling confidence theater.
The fix is not exotic. Test actions, not just components. Make runtime context explicit. Treat environment matrices as a first-class part of quality. Verify the conditions under which a workflow exists, not merely the code that runs if it exists.
Because the next production incident may not come from a broken function.
It may come from a feature flag flip your tests never saw.
