Feature Flag Testing in Playwright
Your team ships behind feature flags, so the same build serves an A/B experiment, a gradual rollout, and a kill switch all at once. The problem is that a flag’s state is decided at runtime by a remote service, which makes tests flaky, slow, and impossible to reproduce. This guide on Playwright feature flag testing shows you how to deterministically control flag values from your tests using page.route, init scripts, cookies, and storage state, so every variant gets reliable, fast coverage in TypeScript.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why feature flags break naive end-to-end tests
A feature flag is a runtime toggle. Tools like LaunchDarkly, Unleash, Flagsmith, Split, and homegrown config services evaluate a flag against a user context (user ID, region, percentage bucket) and return a boolean or a multivariate value. Your UI then renders one branch or another. The challenge for an automated test is that the same URL can render two completely different experiences depending on what the flag service returns at that moment.
If you let your tests hit the real flag backend, you inherit three problems: tests depend on a network service you do not own, percentage rollouts mean a flag might be on for one CI run and off for the next, and you cannot assert both the “on” and “off” branches in the same suite. The fix is to take control of the flag value at the boundary, so the test – not a remote rollout – decides which variant renders.
- Determinism: a test for variant A must always see variant A.
- Isolation: no dependency on a live flag service in CI.
- Coverage: both branches of every flag get exercised.
- Speed: no extra round trips to an external evaluation API.
Strategy comparison: where to inject the flag value
There are several real Playwright mechanisms for forcing a flag state. Pick based on how your app reads the flag – from a network response, from a global on window, from a cookie, or from local storage. The table below compares them.
| Technique | Playwright API | Best when the flag is read from | Scope |
|---|---|---|---|
| Intercept the SDK request | page.route / context.route | A network call to the flag service | Per page or per context |
| Inject a global before load | page.addInitScript | window.__FLAGS__ or a JS bootstrap | Per page/context, every navigation |
| Seed a cookie | context.addCookies | A cookie read on the server or client | Whole context |
| Seed storage | context.addInitScript + localStorage / storageState | localStorage / sessionStorage cache | Whole context |
| Replay recorded traffic | page.routeFromHAR | A captured real evaluation payload | Per page or context |
Technique 1: Intercept the flag SDK with page.route
Most flag SDKs fetch evaluations over HTTP – LaunchDarkly hits app.launchdarkly.com, Unleash polls a /api/client/features endpoint. The cleanest interception point is that request. With page.route you match the URL and fulfill it with your own JSON, so the SDK believes the flag is in whatever state you choose.
import { test, expect, Page } from '@playwright/test';
// Force an Unleash-style feature endpoint to return a fixed flag state.
async function mockFlags(page: Page, flags: Record<string, boolean>) {
await page.route('**/api/client/features', async (route) => {
const features = Object.entries(flags).map(([name, enabled]) => ({
name,
enabled,
strategies: [{ name: 'default' }],
}));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ version: 2, features }),
});
});
}
test('shows the new checkout when the flag is on', async ({ page }) => {
await mockFlags(page, { 'new-checkout': true });
await page.goto('/cart');
await expect(page.getByTestId('checkout-v2')).toBeVisible();
});
test('shows the legacy checkout when the flag is off', async ({ page }) => {
await mockFlags(page, { 'new-checkout': false });
await page.goto('/cart');
await expect(page.getByTestId('checkout-v1')).toBeVisible();
});
Register the route before page.goto, otherwise the SDK may fetch flags before your handler is attached. If the SDK opens a streaming connection (Server-Sent Events) for live updates, route that URL too and fulfill it with an empty stream so the SDK falls back to its initial polled state.
Technique 2: Inject flags with addInitScript
Many front ends bootstrap flags into a global like window.__FLAGS__ that the server renders into the HTML, or read flags synchronously from a client object. page.addInitScript runs your code in every new document before any page script executes, which is exactly the window you need to plant a global. This avoids the network entirely.
import { test, expect } from '@playwright/test';
test('dark-mode flag renders the themed dashboard', async ({ page }) => {
// Runs before the app boots, so the app reads our values.
await page.addInitScript(() => {
(window as any).__FLAGS__ = {
'dark-mode': true,
'beta-banner': false,
'pricing-experiment': 'variant-b',
};
});
await page.goto('/dashboard');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByRole('banner', { name: /beta/i })).toBeHidden();
});
Note that page.addInitScript runs on every navigation and on every iframe, so the global stays set as the user moves through the app. You can pass an argument as the second parameter to keep the flag set out of the function body, which is handy when you parametrize tests over many variants.
import { test, expect } from '@playwright/test';
const variants = ['control', 'variant-a', 'variant-b'] as const;
for (const variant of variants) {
test(`pricing page renders ${variant}`, async ({ page }) => {
await page.addInitScript((v) => {
(window as any).__FLAGS__ = { 'pricing-experiment': v };
}, variant);
await page.goto('/pricing');
await expect(page.getByTestId('pricing-grid'))
.toHaveAttribute('data-variant', variant);
});
}
When the flag value is carried in a cookie (common for server-evaluated flags or edge overrides) or cached in localStorage, set it on the browser context. Cookies belong on the context via context.addCookies; storage is best seeded with an init script because localStorage is per-origin and only exists after a document is created. Wrapping this in a fixture keeps your tests clean.
import { test as base, expect } from '@playwright/test';
type FlagFixtures = {
setFlags: (flags: Record<string, unknown>) => Promise<void>;
};
export const test = base.extend<FlagFixtures>({
setFlags: async ({ context }, use) => {
await use(async (flags) => {
// Cookie override read by the server-side flag resolver.
await context.addCookies([{
name: 'ff_overrides',
value: encodeURIComponent(JSON.stringify(flags)),
domain: 'localhost',
path: '/',
}]);
// Client-side cache primed before the app reads localStorage.
await context.addInitScript((f) => {
window.localStorage.setItem('feature-flags', JSON.stringify(f));
}, flags);
});
},
});
test('referral program flag is enabled via cookie + storage', async ({ page, setFlags }) => {
await setFlags({ 'referral-program': true });
await page.goto('/account');
await expect(page.getByRole('link', { name: 'Refer a friend' })).toBeVisible();
});
Because the cookie and init script are registered on the context, they apply to every page and popup opened from that context for the rest of the test. This is the most robust option when a single flag must hold across multiple tabs or a multi-step journey.
Technique 4: Capture and replay real evaluations with routeFromHAR
Sometimes the flag payload is large or deeply nested and you would rather not hand-write it. Record a real session with the flag in the desired state, save it as a HAR file, then replay it. page.routeFromHAR serves matching requests from the recording, so the flag SDK gets a byte-accurate response without touching the network.
import { test, expect } from '@playwright/test';
test('new-search flag served from recorded HAR', async ({ page }) => {
// Replay only the flag SDK traffic; let everything else pass through.
await page.routeFromHAR('hars/new-search-on.har', {
url: '**/launchdarkly/**',
update: false, // set true once to (re)record the file
});
await page.goto('/search');
await expect(page.getByPlaceholder('Search everything')).toBeVisible();
});
The url glob scopes the HAR to the flag service so unrelated requests still hit your app server. Set update: true on a one-off run with a real flag state to generate or refresh the HAR, then commit it and switch back to update: false for deterministic replay in CI.
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Covering both branches without duplicating tests
The whole point of Playwright feature flag testing is to assert both states. A clean pattern is a small project matrix or a parametrized loop that runs the same journey under each flag state. Combine this with expect.soft when you want to collect several assertions per variant without bailing on the first failure.
import { test, expect, Page } from '@playwright/test';
async function withFlag(page: Page, enabled: boolean) {
await page.addInitScript((on) => {
(window as any).__FLAGS__ = { 'one-click-buy': on };
}, enabled);
}
for (const enabled of [true, false]) {
test(`product page, one-click-buy = ${enabled}`, async ({ page }) => {
await withFlag(page, enabled);
await page.goto('/product/sku-123');
const buyNow = page.getByRole('button', { name: 'Buy now' });
// Soft assertions keep going so one run reports every mismatch.
await expect.soft(buyNow).toBeVisible({ visible: enabled });
await expect.soft(page.getByRole('button', { name: 'Add to cart' }))
.toBeVisible();
});
}
Best practices for stable flag tests
- Set flags before navigation. Register routes and init scripts before
page.gotoso nothing reads a stale default. - Prefer context-level setup (
context.addCookies,context.route,context.addInitScript) for journeys that span multiple tabs or pages. - Kill the streaming connection for SDKs that push live updates, or a late rollout change could flip your flag mid-test.
- Keep a contract test that hits the real flag service occasionally, so your mocked payload shape does not drift from production.
- Name flags from a single source. Import flag keys from your app’s constants instead of typing string literals, so a renamed flag fails to compile rather than fails silently.
- Combine with auth storageState. If a flag targets a specific user segment, log in via a saved
storageStateand override only the flag you are testing.
With these patterns in place, Playwright feature flag testing becomes boring in the best way: every variant renders the same way on every run, both branches are covered, and CI never waits on a remote rollout service. Pick interception for SDK traffic, init scripts for client globals, cookies and storage for cached values, and HAR replay when the payload is too big to fake by hand – then loop over states so no branch ships untested.
FAQ
Should I mock the flag service or use a real test environment?
For the vast majority of UI tests, mock it. Intercepting the flag request with page.route or injecting a global with addInitScript gives you deterministic, fast, isolated runs that cover both branches. Keep one or two thin contract tests against a real flag environment to catch payload-shape drift, but do not let your whole suite depend on a live rollout service.
Why is my flag override ignored even though page.route is set?
Almost always a timing issue: the SDK fetched flags before your route handler attached. Make sure you call page.route (or context.route) and page.addInitScript before page.goto. Also check whether the SDK opens a streaming or websocket connection that pushes a later value over your initial mock – if so, route and stub that URL as well.
How do I test percentage rollouts and multivariate flags?
Do not test the bucketing math through the UI – that belongs in the SDK’s unit tests. Instead, force each concrete outcome. For a multivariate flag, return the exact variant string (variant-a, variant-b) from your mock and assert the matching UI. Parametrize a loop over every variant so each rendered experience gets its own deterministic test.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
