Testing Feature-Flagged UI Branches in Playwright Without Doubling the Suite
By David Frei · September 2, 2026
A practical Playwright guide for covering flag-on and flag-off UI states with deterministic flag setup, reusable helpers, and maintainable branching strategy.
Feature flags change the testing problem more than they change the app. The code path is still one release, but the UI can behave like two products for a while: old branch and new branch, flag off and flag on. If you test those branches by copy-pasting every spec, you will pay for it in slow CI, duplicated assertions, and brittle maintenance.
The better pattern is to test feature flagged UI branches in Playwright by making flag state deterministic, centralizing evaluation behind helpers, and deciding case by case whether a spec should be parameterized or split. That keeps branch coverage explicit without turning the suite into a matrix you dread touching.
The core problem: branch coverage without branch explosion
A feature flag changes visible UI, behavior, or both. In Playwright, that usually means one of three things:
- The page renders different elements based on the flag.
- The same element exists, but the flow changes after interaction.
- The app reads the flag during startup, so the initial state must be controlled before navigation.
The mistake is treating all three the same way. If you branch inside every assertion, your test becomes a maze of if flagOn logic. If you duplicate entire specs, you get two nearly identical suites that drift apart.
The goal is not to test every implementation detail of the flag system. The goal is to make the UI contract explicit for each meaningful flag state.
Start with deterministic flag context
The test should not depend on a random remote flag rollout. For stable automation, seed the flag state in a way the app can read consistently.
Common patterns are:
- API setup before the test starts, if your app can read flags from a backend service or test-only seed endpoint.
- Storage or cookie state, if your app caches flag decisions in session storage, local storage, or a cookie.
- URL-level test harness, if your app supports a deterministic test mode or query parameter in non-production environments.
- Network mocking, when the feature flag decision is fetched from an API and your test needs to control the response.
Playwright supports storage state and network interception, which makes this manageable. See the Playwright docs for the framework basics and the official API pages for storageState and route when you need to seed or override state.
Prefer one flag source per test
If the application reads flags from multiple places, choose one source of truth for automation. For example, if production uses LaunchDarkly but your test environment can seed a backend flag snapshot, use the backend snapshot in tests. That is easier to reason about than mixing local storage overrides, query parameters, and network mocks in the same suite.
A clean rule is:
- App startup flags: control before
page.goto(). - Interaction-time toggles: control through UI or API after the page is ready.
- Remote flag service: mock at the network boundary only if you cannot seed the state more directly.
Hide flag lookup behind a helper, not inside every assertion
Flag branching gets painful when selectors change. Instead of checking the flag everywhere, create a small helper that returns the current branch once, then use that helper to drive the minimal branching needed in the test.
import { expect, test, type Page } from '@playwright/test';
async function getFeatureVariant(page: Page) {
const value = await page.evaluate(() => window.localStorage.getItem('newCheckoutFlag'));
return value === 'on' ? 'on' : 'off';
}
test('checkout entry point is visible in both flag states', async ({ page }) => {
await page.goto('/cart');
const variant = await getFeatureVariant(page);
if (variant === 'on') {
await expect(page.getByRole('button', { name: 'Try new checkout' })).toBeVisible();
} else {
await expect(page.getByRole('button', { name: 'Continue to payment' })).toBeVisible();
}
});
This is intentionally small. The point is not to make a universal abstraction for every feature flag. The point is to keep the flag decision at the edge of the test, then assert against the branch that matters.
Better still, assert outcomes, not implementation labels
If the only difference between branches is the label on a button, branch-specific assertions are fine. But if the user outcome is the same, prefer an assertion on the outcome.
For example, if both branches eventually open the same checkout modal, assert that the modal appears and the next step works. That keeps the test resilient if the label changes again.
Seed flag state through fixtures when the whole spec needs it
If several tests in one file require the same flag state, use a fixture so the setup lives in one place. Playwright fixtures are a good fit when the branch affects a whole flow.
import { test as base, expect } from '@playwright/test';
type Fixtures = { flagState: ‘on’ | ‘off’; };
const test = base.extend<Fixtures>({
flagState: ['on', { option: true }],
page: async ({ page, flagState }, use) => {
await page.addInitScript((state) => {
window.localStorage.setItem('newCheckoutFlag', state);
}, flagState);
await use(page);
}
});
test('checkout CTA matches the enabled branch', async ({ page, flagState }) => {
await page.goto('/cart');
if (flagState === 'on') {
await expect(page.getByText('Try new checkout')).toBeVisible();
}
});
This pattern works best when the app reads state early in the lifecycle. If the feature flag is fetched after load, addInitScript() alone will not control it. In that case, intercept the request or seed the backend state instead.
Avoid brittle locator branching
The fastest way to make these tests unpleasant is to branch on raw DOM structure too early. For example:
if (page.locator('.new-banner').count()) ...if (await page.textContent('body')) ...if (await page.isVisible('button:nth-child(3)')) ...
Those checks are fragile because they encode layout details, not user intent.
Prefer one of these instead:
- roles and accessible names, such as
getByRole() - explicit data attributes for test hooks, if the UI is highly dynamic
- a helper that returns a semantic branch identifier from app state
If the flag produces entirely different pages, you can still test both branches without branching on internals. The test can confirm the page type, then make assertions specific to that page type.
When to parameterize and when to split spec files
Not every flag belongs in a parameterized test. The right structure depends on how much the branch changes the flow.
| Situation | Better structure | Why |
|---|---|---|
| Same page, one or two selectors change | Parameterized test | Low duplication, simple maintenance |
| Same flow, different copy or CTA | Parameterized test with branch-specific assertions | Keeps shared path in one place |
| Different page, different steps, same feature area | Separate spec files | Improves readability and failure isolation |
| Flag touches many unrelated areas | Separate specs plus shared helpers | Avoids one test becoming a conditional monolith |
| Flag is temporary and will be removed soon | Short-lived parameterized coverage | Minimizes code you will delete later |
A useful rule of thumb is this: if the flag changes the path, split it; if it changes the surface, parameterize it.
Example of a clean split
import { test, expect } from '@playwright/test';
test.describe('new checkout flag on', () => {
test('renders the new flow', async ({ page }) => {
await page.goto('/cart?feature=new-checkout:on');
await expect(page.getByRole('heading', { name: 'New checkout' })).toBeVisible();
});
});
test.describe('new checkout flag off', () => {
test('renders the existing flow', async ({ page }) => {
await page.goto('/cart?feature=new-checkout:off');
await expect(page.getByRole('heading', { name: 'Checkout' })).toBeVisible();
});
});
This reads better than stuffing both branches into one test when the user journeys are materially different.
Testing LaunchDarkly flags and similar remote systems
If you are testing LaunchDarkly flags or another remote flag platform, the key question is not the vendor name, it is where the decision is made. If the app evaluates flags server-side, your Playwright test may need a seeded server session, not a browser-side override. If the browser fetches the flag payload, network mocking can work.
The practical risk is assuming the flag is “just a UI concern.” It often is not. A flag can influence HTML from the server, client-side rendering, permissions, or routing. Test at the boundary where the decision becomes visible.
A strong pattern is to expose a test-only endpoint or seed hook in non-production environments that returns a known flag set. That keeps the UI test focused on the rendering and behavior, not on reverse-engineering the flag service protocol.
If you cannot explain exactly when the flag is read, your test setup is probably too late.
Keep the suite honest with one coverage decision per flag
Do not assume every flag needs both states in every spec. Some flags deserve only one state in a given test file because the other state is already covered elsewhere.
A good coverage model is:
- Smoke path: the most important user journey in the active branch.
- Regression path: the behavior that changes when the flag is off.
- Targeted branch checks: a few focused assertions that the flag actually changes the UI or behavior.
This gives you UI variant coverage without turning every feature into a matrix of on/off x desktop/mobile x locale x browser.
If a flag is only gating an unfinished UI that users cannot reach yet, a small number of branch assertions is enough. If the flag alters a critical purchase, signup, or data entry path, you probably want explicit coverage in both states until the rollout completes.
Maintenance checklist for flaky flag tests
Feature-flagged tests fail for boring reasons more often than exotic ones. I would check these first:
- The test depends on the default flag state instead of setting it explicitly.
- The flag is read before the override is applied.
- The app caches the flag across navigations or sessions.
- The test branches on visible text that changes with copy updates.
- A shared helper hides too much, so failures do not say which branch was exercised.
A few maintenance habits help a lot:
- Name helpers after the business feature, not the flag key.
- Keep the flag key in one place so renames are local.
- Assert the branch at least once in the spec so the test failure is informative.
- Remove the flag coverage when the flag is deleted, do not let dead branches linger.
A simple decision framework
When you decide how to test a feature-flagged branch, ask four questions:
- When is the flag evaluated? Before load, during render, or after user action.
- What changes? One component, a full page, or the entire journey.
- Where is the source of truth? Backend seed, browser storage, or remote service.
- How long will the flag live? A short rollout, or a long-term product mode.
My default recommendation is:
- Use a helper or fixture to seed deterministic state.
- Parameterize only when the same flow covers both states cleanly.
- Split files when the flow diverges or when the branch logic starts to obscure the test.
- Assert outcomes first, selectors second, branch labels only when they matter.
Bottom line
You do not need two full Playwright suites for every feature flag. You need one maintainable suite with explicit control over flag state, a clean helper boundary, and a judgment call about where the branch belongs.
If you can seed the flag context deterministically and keep the branching near the top of the test, you get stable coverage without duplicating everything. If the flag changes the journey enough that the test becomes hard to read, split it. That is usually the point where the extra file is cheaper than the extra conditionals.
FAQ
Should I mock the feature flag service in Playwright tests?
Only if it is the cleanest way to create deterministic state in your environment. If you can seed the app more directly, that is usually easier to maintain.
Can I test both flag states in one spec file?
Yes. That works well when the flow is the same and only the UI surface changes. Use parameterization or a fixture, not scattered if statements.
What if the flag changes server-rendered HTML?
Set the flag before navigation or seed the backend state. Browser-side overrides after page.goto() are too late if the server already rendered the branch.
How do I keep locators from becoming brittle across branches?
Use roles, accessible names, or stable test hooks. Avoid branching on layout position or CSS class names unless you have no better semantic hook.
When should I split the suite into separate files?
Split it when the branch changes the user journey, not just the copy. Separate files are easier to read, easier to debug, and easier to delete when the flag is removed.