Unleash Feature Toggle Testing: A Complete Guide

Unleash Feature Toggle Testing: A Complete Guide

Unleash is an open-source feature management platform used by thousands of teams to control feature rollouts without code deploys. But the flexibility that makes Unleash powerful — custom activation strategies, gradual rollouts, user segmentation — also makes testing more complex. This guide covers every layer of Unleash testing from SDK mocks to production monitoring.

Understanding Unleash's Testing Surface

Before writing tests, understand what can go wrong with feature toggles:

  1. Wrong default state — a toggle that should be off is on, or vice versa
  2. Broken activation strategy — your custom strategy returns incorrect results for edge-case contexts
  3. SDK integration bugs — the Unleash client isn't called where you expect
  4. Context propagation errors — user ID, session ID, or environment context isn't passed correctly
  5. Variant misconfiguration — multivariate toggles return wrong variant payloads

Testing must address all five failure modes.

Unit Testing with the Unleash Fake Client

The Unleash SDK ecosystem provides official fake/mock implementations for testing.

Node.js: FakeUnleashClient

const { FakeUnleashClient } = require('unleash-client/lib/unleash');

describe('Feature toggle behavior', () => {
  let client;

  beforeEach(() => {
    client = new FakeUnleashClient();
  });

  it('shows legacy UI when toggle is off', () => {
    client.setVariant('new-ui', { enabled: false, name: 'disabled' });

    const feature = new FeatureService(client);
    expect(feature.getUIVersion()).toBe('legacy');
  });

  it('shows new UI when toggle is on', () => {
    client.setVariant('new-ui', { enabled: true, name: 'enabled' });

    const feature = new FeatureService(client);
    expect(feature.getUIVersion()).toBe('v2');
  });
});

Java: Unleash Mock with UnleashMock

import io.getunleash.FakeUnleash;

@Test
void shouldShowNewDashboard_whenToggleEnabled() {
    FakeUnleash fakeUnleash = new FakeUnleash();
    fakeUnleash.enable("new-dashboard");

    DashboardService service = new DashboardService(fakeUnleash);
    assertEquals("new-dashboard-v2", service.getDashboardVersion());
}

@Test
void shouldShowLegacyDashboard_whenToggleDisabled() {
    FakeUnleash fakeUnleash = new FakeUnleash();
    fakeUnleash.disableAll();

    DashboardService service = new DashboardService(fakeUnleash);
    assertEquals("dashboard-v1", service.getDashboardVersion());
}

The FakeUnleash class is provided by the official unleash-client-java SDK. No network connection needed.

Testing Custom Activation Strategies

Unleash's power comes from custom activation strategies. These need dedicated unit tests.

Defining a Custom Strategy

class GradualRolloutStrategy {
  isEnabled(parameters, context) {
    const percentage = parseInt(parameters.percentage, 10);
    const userId = context.userId;
    const normalizedId = hashUserId(userId) % 100;
    return normalizedId < percentage;
  }
}

Testing the Strategy in Isolation

describe('GradualRolloutStrategy', () => {
  const strategy = new GradualRolloutStrategy();

  it('enables for users within rollout percentage', () => {
    // User whose hash % 100 = 15
    const result = strategy.isEnabled(
      { percentage: '20' },
      { userId: 'deterministic-user-15' }
    );
    expect(result).toBe(true);
  });

  it('disables for users outside rollout percentage', () => {
    // User whose hash % 100 = 85
    const result = strategy.isEnabled(
      { percentage: '20' },
      { userId: 'deterministic-user-85' }
    );
    expect(result).toBe(false);
  });

  it('handles 0% rollout', () => {
    const result = strategy.isEnabled(
      { percentage: '0' },
      { userId: 'any-user' }
    );
    expect(result).toBe(false);
  });

  it('handles 100% rollout', () => {
    const result = strategy.isEnabled(
      { percentage: '100' },
      { userId: 'any-user' }
    );
    expect(result).toBe(true);
  });
});

Testing at 0%, 100%, and boundary values catches the most common strategy bugs.

Integration Testing Against a Local Unleash Instance

For integration tests, run Unleash locally using Docker:

# docker-compose.test.yml
services:
  unleash:
    image: unleashorg/unleash-server:latest
    ports:
      - "4242:4242"
    environment:
      DATABASE_URL: postgres://unleash:password@db/unleash
      INIT_CLIENT_API_TOKENS: test-client-token
    depends_on:
      - db

  db:
    image: postgres:15
    environment:
      POSTGRES_DB: unleash
      POSTGRES_USER: unleash
      POSTGRES_PASSWORD: password

Pre-seeding Toggle State

Use the Unleash Admin API to set toggle state before tests run:

# Create toggle
curl -X POST http://localhost:4242/api/admin/projects/default/features \
  -H <span class="hljs-string">"Authorization: Bearer admin-token" \
  -H <span class="hljs-string">"Content-Type: application/json" \
  -d <span class="hljs-string">'{"name": "new-checkout", "type": "release", "enabled": true}'

<span class="hljs-comment"># Enable in development environment
curl -X POST \
  <span class="hljs-string">"http://localhost:4242/api/admin/projects/default/features/new-checkout/environments/development/on" \
  -H <span class="hljs-string">"Authorization: Bearer admin-token"

Wrap this in a test setup script that runs before your integration test suite.

Testing Context Propagation

Context bugs — where user ID, session ID, or custom properties aren't passed to Unleash — are common and hard to spot. Test them explicitly.

describe('Unleash context propagation', () => {
  it('passes user ID to toggle evaluation', async () => {
    const calls = [];
    const mockClient = {
      isEnabled: (name, context) => {
        calls.push({ name, context });
        return true;
      }
    };

    const service = new CheckoutService(mockClient);
    await service.getCheckoutFlow({ userId: 'user-123', sessionId: 'sess-456' });

    expect(calls[0].context).toMatchObject({
      userId: 'user-123',
      sessionId: 'sess-456',
    });
  });
});

If your service doesn't pass context, Unleash can't evaluate user-specific rules — and the test will fail, surfacing the bug.

Testing Variant Payloads

Unleash variants carry JSON payloads for A/B experiments. Test that your code handles them:

describe('A/B test variant handling', () => {
  it('renders button text from variant payload', () => {
    client.setVariant('cta-button', {
      enabled: true,
      name: 'variant-b',
      payload: { type: 'string', value: 'Get Started Free' }
    });

    const component = renderCTA(client);
    expect(component.buttonText).toBe('Get Started Free');
  });

  it('uses default button text when variant payload is missing', () => {
    client.setVariant('cta-button', {
      enabled: false,
      name: 'disabled',
    });

    const component = renderCTA(client);
    expect(component.buttonText).toBe('Sign Up'); // default
  });
});

End-to-End Testing with Unleash

E2E tests must handle dynamic toggle state. Use these patterns:

Pattern 1: Dedicated E2E Toggle State

Create toggles specifically for E2E test scenarios and lock their state in the test environment:

Toggle: e2e-new-checkout-enabled
Environment: e2e
State: always on

Configure your E2E test user to hit the E2E environment.

Pattern 2: Toggle State Reset Between Tests

Before each E2E test, reset relevant toggles via the Admin API:

beforeEach(async () => {
  await resetToggle('new-checkout', { enabled: false });
});

it('verifies checkout flow with legacy UI', async () => {
  await browser.url('/checkout');
  await expect($('.legacy-checkout')).toExist();
});

Pattern 3: HelpMeTest Monitoring

Set up HelpMeTest to run your critical user journeys every few minutes. If a toggle change breaks a flow, you'll know within 5 minutes — not when a customer complains.

Health check: checkout-flow-with-new-toggle
Interval: 5 minutes
Test: complete purchase as authenticated user
Alert: Slack + email on failure

CI/CD Pipeline Integration

Matrix Testing for Toggle States

# .github/workflows/test.yml
strategy:
  matrix:
    toggle_state: [enabled, disabled]

steps:
  - name: Set toggle state
    run: |
      curl -X POST "$UNLEASH_URL/api/admin/features/new-checkout/environments/ci/${{ matrix.toggle_state }}" \
        -H "Authorization: Bearer $UNLEASH_ADMIN_TOKEN"

  - name: Run tests
    run: npm test
    env:
      UNLEASH_URL: ${{ secrets.UNLEASH_CI_URL }}
      UNLEASH_TOKEN: ${{ secrets.UNLEASH_CI_TOKEN }}

Toggle Drift Detection

Compare your code's toggle references against what's defined in Unleash:

// scripts/check-toggle-drift.js
const usedToggles = extractToggleRefs('./src'); // grep for isEnabled calls
const definedToggles = await fetchUnleashToggles();

const undefined_toggles = usedToggles.filter(t => !definedToggles.includes(t));
if (undefined_toggles.length > 0) {
  console.error('Toggles used in code but not defined in Unleash:', undefined_toggles);
  process.exit(1);
}

Run this check in CI to catch missing toggle definitions before deploy.

Testing Toggle Cleanup

Technical debt accumulates around stale toggles. Build cleanup testing into your process:

  1. Mark toggles as permanent vs. temporary using Unleash's toggle types (release, experiment, operational, kill-switch, permission)
  2. Set expiry dates on release toggles — Unleash shows warnings for expired toggles
  3. Test the "cleaned up" state before removing toggle references from code: deploy the code as if the toggle doesn't exist, verify behavior
// Cleanup test: verify code works without toggle evaluation
it('works without feature toggle dependency', () => {
  // Remove toggle check — simulates post-cleanup state
  const feature = new FeatureService(null); // no client
  expect(feature.getUIVersion()).toBe('v2'); // new UI should be default after cleanup
});

Summary

Testing Unleash feature toggles requires: SDK-level unit tests using FakeUnleash or FakeUnleashClient, strategy unit tests covering edge cases, integration tests against a local Unleash instance with pre-seeded state, explicit context propagation tests, and E2E tests that lock toggle state per test. Add toggle drift detection to CI and monitoring in production, and you'll catch toggle-related regressions before they reach users.

Read more