Testland
Browse all skills & agents

experiment-sdk-testing

Umbrella for experimentation-SDK test harnesses: the shared offline-datafile / hermetic-init pattern (commit a point-in-time flag/experiment config fixture, initialize the SDK with no network, pin arms per test, assert assignment integrity), with per-vendor references for Statsig (localMode + overrideGate), Optimizely (datafile + forced decisions), Split.io / Harness FME (localhost mode + features map or YAML fixture), Amplitude Experiment (local evaluation + bootstrap), and VWO (settings file + deterministic bucketing). Use when writing tests for application code instrumented with any of these five experimentation SDKs; for experiment DESIGN gates use ab-test-validity-checklist, and to read results use experiment-results-interpreter.

Install with skills.sh (any agent)

npx skills add testland/qa --skill experiment-sdk-testing
View source

experiment-sdk-testing

Overview

Every major experimentation SDK ships the same hermetic-test mechanism under a different name: a point-in-time config fixture (datafile, settings file, flag payload, features map) that the SDK evaluates locally, so tests make zero network calls, pollute no production analytics, and stay deterministic. The vendor-specific mechanics differ only in how the fixture is loaded and how an arm is pinned.

Routing table

SDKOffline mechanismArm pinningReference
StatsiglocalMode: trueoverrideGate / overrideConfigreferences/statsig.md
OptimizelyJSON datafile fixtureset_forced_decisionreferences/optimizely.md (+ optimizely-recipes.md)
Split.io / Harness FMEauthorizationKey: 'localhost' + features map / YAMLPer-key fixture entry (no override API)references/split-io.md (+ split-io-example.md)
Amplitude ExperimentLocalEvaluationClient + bootstrapFixture edit or evaluateV2 mockreferences/amplitude.md
VWOSettings file + is_development_modeDeterministic bucketing on user IDreferences/vwo.md

When to use

  • Tests for code that reads a gate / experiment / variant from any of the five SDKs above.
  • Assignment-integrity tests per ab-test-validity-checklist Step 3.
  • CI pipelines that must run without vendor network access.

The shared hermetic-init pattern

Regardless of vendor, the suite has the same five steps:

  1. Export and commit the config fixture - the datafile / settings / flag payload the SDK would fetch, checked into tests/fixtures/ and refreshed deliberately (drift between fixture and prod config is invisible otherwise).
  2. Initialize the SDK offline - the vendor's no-network switch (localMode, datafile string, 'localhost' key, bootstrap, is_development_mode).
  3. Pin the arm where the test needs one - override API, forced decision, per-key fixture entry, or a deterministically-bucketed user ID.
  4. Assert on values and keys, never internal IDs - variation keys and returned values survive environment changes; internal config IDs don't.
  5. Tear down - shutdown / destroy the client so event-flush timers and handles don't leak across test files.

Plus two integrity tests every suite should carry:

  • Determinism - the same user ID gets the same arm on repeated evaluation.
  • Distribution - across many user IDs, more than one arm actually occurs (and, where the split is known, roughly matches it).

Worked example (Optimizely datafile)

The team ships a new_checkout_flow flag with a treatment_a variation and needs a deterministic test that a premium-plan user is routed into the treatment:

import json
from optimizely import optimizely

# Step 1-2: committed fixture, offline init - no SDK key, no network
with open("tests/fixtures/optimizely-datafile.json") as f:
    client = optimizely.Optimizely(f.read())

def test_premium_user_in_treatment():
    # Step 3: context carries the attributes targeting needs
    user = client.create_user_context("user-1", {"plan": "premium"})
    decision = user.decide("new_checkout_flow")
    # Step 4: assert on enabled + variation_key, not IDs
    assert decision.enabled is True
    assert decision.variation_key == "treatment_a"

def test_assignment_deterministic():
    user = client.create_user_context("user-1")
    d1 = user.decide("new_checkout_flow")
    d2 = user.decide("new_checkout_flow")
    assert d1.variation_key == d2.variation_key

The fixture drives the whole decision; the same shape translates to each vendor via its reference above.

Anti-patterns (all vendors)

Anti-patternWhy it failsFix
Live API key in testsProduction analytics polluted; rate limits; flakesThe vendor's offline switch
Fixture not version-controlledTests flake when prod config changesCommit; refresh deliberately
Overrides / forced decisions leak across testsCross-test pollutionPer-test context + cleanup
Asserting on internal config / variation IDsIDs change per environmentAssert keys and values
Skipping client shutdown / destroyEvent-flush timers and handles leakTeardown in afterAll
Trusting one user ID to cover both armsMay bucket into one arm onlyDistribution test across many IDs

Limitations

  • Fixtures are point-in-time. Drift against the vendor UI is invisible until refreshed.
  • Offline modes don't validate the vendor's server-side analysis. Platform statistics are the vendor's job; these tests cover your code's SDK interaction only.
  • Per-vendor gaps (Statsig localMode still pings on some init paths; Split.io has no override API; Amplitude local evaluation lacks some flag types; VWO has no forced-decision API) are documented in each reference.

References

Amplitude Experiment SDK testing

View source (opens in new window)

Amplitude Experiment SDK testing

Per amplitude.com/docs/experiment (opens in new window), the Amplitude Experiment SDKs (server-side and client-side) expose fetch + variant APIs: fetch the user's assigned variants, then read each variant on demand.

Amplitude correlates exposure + outcome events via the same user ID space as Amplitude Analytics, so exposure-event suppression in tests is important to avoid polluting analytics.

When to use

  • Tests for code that reads an Amplitude Experiment variant.
  • Suppressing exposure events in non-production test runs.
  • Assignment-integrity tests per ab-test-validity-checklist Step 3.

Authoring

Install

pip install amplitude-experiment           # Python (server-side)
npm install --save-dev @amplitude/experiment-node-server

Initialize (server-side)

import * as Experiment from '@amplitude/experiment-node-server';

const client = Experiment.Experiment.initializeRemote(API_KEY, {
  // Suppress real fetches in tests
  fetchTimeoutMillis: 1000,
});

For fully-offline tests, use the local evaluation mode and seed the flag config via the bootstrap option. Per the local-evaluation docs (opens in new window), start() takes no arguments and always performs an initial network fetch (it throws offline and would clear a bootstrapped cache), so do NOT call it for a no-network test: bootstrap populates the cache in the constructor.

import { LocalEvaluationClient } from '@amplitude/experiment-node-server';
import { readFileSync } from 'fs';

// Commit the flag config the flags endpoint would return, keyed by flag key.
const flagFixture = JSON.parse(readFileSync('fixtures/flags.json', 'utf8'));

const localClient = new LocalEvaluationClient(API_KEY, {
  bootstrap: flagFixture,   // seeds the cache; no start() / no network
});

Read variant (offline, synchronous)

evaluateV2 reads straight from the bootstrapped cache, no fetch required:

const user = { user_id: 'user-1', device_id: 'dev-1' };

test('user variant from local eval', () => {
  const variants = localClient.evaluateV2(user);
  expect(variants['checkout-experiment'].value).toBe('treatment-a');
});

Force a variant for a test

Amplitude Experiment's standard pattern is via the flag config: override the flag's default-variant for a specific user ID by modifying the local-eval fixture. Alternatively, mock the evaluate method:

import { jest } from '@jest/globals';

test('user in treatment', () => {
  jest.spyOn(localClient, 'evaluateV2').mockReturnValue({
    'checkout-experiment': { value: 'treatment-a' } as any,
  });

  const variants = localClient.evaluateV2(user);
  expect(variants['checkout-experiment'].value).toBe('treatment-a');
});

Suppress exposure events in tests

Default behavior fires an exposure event on variant() read. Suppress per amplitude.com/docs/experiment (opens in new window):

// In test setup:
const client = Experiment.Experiment.initializeRemote(API_KEY, {
  // Disable automatic exposure tracking
  automaticExposureTracking: false,
});

Assignment integrity tests

test('deterministic assignment', () => {
  const v1 = localClient.evaluateV2({ user_id: 'user-1' });
  const v2 = localClient.evaluateV2({ user_id: 'user-1' });
  expect(v1).toEqual(v2);
});

Running

npm test

CI integration

jobs:
  amplitude-experiment-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm test
        env:
          AMPLITUDE_API_KEY: ${{ secrets.AMPLITUDE_TEST_KEY }}

For fully-offline CI: skip the env var and use local-eval with checked-in flag config JSON.

Anti-patterns

Anti-patternWhy it failsFix
Tests use prod Amplitude keyTest users pollute analyticsUse test workspace + dev key
Exposure events enabled in CISpurious exposure trackingautomaticExposureTracking: false
Mocking variant() result without testing the fetchMisses fetch-network bugsTest both layers separately
Local-eval flag JSON not committedTest flakes when prod changesCommit fixture
Skipping client.stop() / cleanupNetwork handles leakAlways teardown
Different user-ID space between test + analyticsAmplitude correlation brokenMatch the prod user-ID strategy

Limitations

  • Local-evaluation mode is feature-limited. Some flag types (CMAB, multi-armed bandit) aren't supported offline.
  • Mocking variant() loses targeting-rule fidelity. Use real local-eval when targeting matters.
  • Exposure suppression is binary. Can't selectively suppress per-test.
  • Doesn't validate Amplitude's results analysis. Platform-side statistics separate.

References

Optimizely extended test recipes

View source (opens in new window)

Optimizely extended test recipes

Deeper test recipes for optimizely.md (opens in new window). Its spine covers install, datafile init, user context + decide, and forced decisions; this file holds the assignment-integrity and event-tracking tests plus the run/CI wiring.

Assignment integrity

Same user id must resolve to the same variation, and decide_all must return a stable key set across calls.

def test_assignment_deterministic():
    user_a1 = client.create_user_context("user-1")
    user_a2 = client.create_user_context("user-1")
    d1 = user_a1.decide("flag-x")
    d2 = user_a2.decide("flag-x")
    assert d1.variation_key == d2.variation_key

def test_decide_all_returns_consistent_set():
    user = client.create_user_context("user-1")
    decisions_1 = user.decide_all()
    decisions_2 = user.decide_all()
    assert decisions_1.keys() == decisions_2.keys()

Event tracking

Assert conversion events via the notification listener rather than inspecting network calls.

def test_conversion_event_emitted():
    captured_events = []
    # Optimizely supports a notification listener
    client.notification_center.add_notification_listener(
        notification_type="TRACK", notification_callback=lambda *args: captured_events.append(args)
    )

    user = client.create_user_context("user-1")
    user.track_event("checkout_completed")
    assert len(captured_events) == 1

Running

pytest tests/optimizely/

CI integration

jobs:
  optimizely-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
      - run: pip install -e ".[test]"
      - run: pytest tests/optimizely/

Datafile lives in the repo; no SDK key needed for tests.

Optimizely SDK testing

View source (opens in new window)

Optimizely SDK testing

Optimizely Feature Experimentation (Optimizely Full Stack / Optimizely X) uses a datafile - a JSON blob describing all flags, experiments, and audiences - that the SDK fetches and evaluates locally. Per docs.developers.optimizely.com/feature-experimentation/docs/python-sdk (opens in new window), the SDK supports datafile-based testing: load a fixture datafile in tests, no network call.

The current API surface is decide (single flag) / decideAll (all flags) per the v5 SDK.

When to use

  • Tests for code that reads an Optimizely flag / experiment.
  • Datafile-based snapshot tests for assignment matrices.
  • Forced-decisions for per-test arm pinning.

How to use

  1. Install the Optimizely SDK for your language (pip install optimizely-sdk, or the npm package).
  2. Export the datafile from the Optimizely UI or API and commit it as a fixture under tests/fixtures/.
  3. Initialize the client offline from that datafile - no SDK key, no network call.
  4. Create an OptimizelyUserContext per test with the attributes the audience targeting needs.
  5. Verify: assert the fixture drives a real decision - user.decide("new_checkout_flow").enabled is True - before pinning arms. If it fails, the datafile is stale or missing the flag; re-export it and re-run.
  6. Pin arms with set_forced_decision where a test needs a fixed variation, then call decide (one flag) or decide_all (all flags).
  7. Assert on variation_key / enabled (never variation IDs); add assignment-integrity and event-tracking checks via the notification listener (see optimizely-recipes.md (opens in new window)).
  8. Run under pytest in CI; re-export the datafile periodically to catch drift against prod config.

Authoring

Install

pip install optimizely-sdk           # Python
npm install --save-dev @optimizely/optimizely-sdk

Datafile-based initialization

Per Optimizely docs, the datafile path is the canonical offline approach:

import json
from optimizely import optimizely

# Load a checked-in datafile fixture
with open("tests/fixtures/optimizely-datafile.json") as f:
    datafile = json.load(f)

client = optimizely.Optimizely(json.dumps(datafile))

The datafile is downloadable from the Optimizely UI or via the Optimizely API; commit a version-specific copy to the repo for deterministic tests.

Create a user context

def test_get_decision_for_user():
    user = client.create_user_context("user-1", {"plan": "premium"})
    decision = user.decide("new_checkout_flow")
    assert decision.enabled is True
    assert decision.variation_key == "treatment_a"

Forced decisions for per-test pinning

from optimizely.optimizely_user_context import OptimizelyDecisionContext

def test_force_user_to_treatment():
    user = client.create_user_context("user-1")
    context = OptimizelyDecisionContext(flag_key="new_checkout_flow", rule_key=None)
    user.set_forced_decision(context, OptimizelyForcedDecision(variation_key="treatment_a"))

    decision = user.decide("new_checkout_flow")
    assert decision.variation_key == "treatment_a"

Extended recipes - assignment-integrity and event-tracking tests, the pytest run command, and CI integration yaml - are in optimizely-recipes.md (opens in new window).

Worked example

The team ships a new_checkout_flow flag with a treatment_a variation, and QA needs a deterministic test that a premium-plan user is routed into the treatment. Follow the How-to-use steps: export the fixture, init offline, create the {"plan": "premium"} context, assert decide("new_checkout_flow") returns enabled is True / variation_key == "treatment_a", then pin the arm with set_forced_decision for the variant-specific path.

Result: a deterministic pass/fail on the checkout-routing logic with no network round-trip and no SDK key - the fixture drives the whole decision.

Anti-patterns

Anti-patternWhy it failsFix
Tests with live SDK keyProduction data polluted; rate-limitedUse datafile fixture
Datafile not version-controlledTests flake when prod config changesCommit the fixture
Forced decisions leak across testsCross-test pollutionPer-test user context; reset before assertion
Skipping client.shutdown / network listener cleanupGoroutine / handle leakAlways cleanup
Asserting on variation IDs not keysIDs change per environmentUse variation_key
Manual event tracking in tests vs notification listenerMisses platform-emitted eventsUse the listener
Tests rely on real decide-network roundtripSlow; non-deterministicDatafile + offline

Limitations

  • Datafile is point-in-time. Drift between fixture + prod is invisible. Sync periodically.
  • Stickiness depends on bucketing UUID. If a user's bucketing ID changes (e.g., from anonymous → logged-in), the assignment may change.
  • Forced decisions are per-context. Across multiple user- contexts in the same test, you may need to re-force.
  • Doesn't test Optimizely's results analysis. Platform-side statistics are separate.

References

End-to-end example

Full Node.js test (Jest) for a checkout feature guarded by two flags, using localhost/offline mode. See split-io.md (opens in new window) for each pattern in isolation.

import path from 'path';
import { SplitFactory } from '@splitsoftware/splitio';

let factory: SplitIO.ISDK;
let client: SplitIO.IClient;

beforeAll(async () => {
  factory = SplitFactory({
    core: { authorizationKey: 'localhost' },
    features: path.join(__dirname, '__fixtures__/split-flags.yml'),
    sync: { impressionsMode: 'NONE' },
  });
  client = factory.client('user-123');
  await client.whenReady();
});

afterAll(async () => {
  await client.destroy();
});

describe('checkout page feature flags', () => {
  it('shows redesigned checkout for on-treatment users', () => {
    expect(client.getTreatment('user-123', 'checkout_redesign')).toBe('on');
  });

  it('returns config for pricing experiment arm', () => {
    const result = client.getTreatmentWithConfig('user-123', 'pricing_experiment');
    expect(result.treatment).toBe('v2');
    expect(JSON.parse(result.config!)).toMatchObject({ price: 9 });
  });

  it('returns control for an undeclared flag', () => {
    expect(client.getTreatment('user-123', 'unrelated_flag')).toBe('control');
  });
});

The __fixtures__/split-flags.yml file is committed alongside the test. No SPLIT_API_KEY secret is required in CI when using localhost mode.

Split.io (Harness FME) SDK testing

View source (opens in new window)

Split.io (Harness FME) SDK testing

The Split.io SDK (now Harness Feature Management & Experimentation, FME) keeps the surface SplitFactory, getTreatment, getTreatmentWithConfig, and the event system. Both the JavaScript (browser) and Node.js (server-side) SDKs support a localhost/offline mode that eliminates all network calls during tests, making feature-flag evaluation fully hermetic. This skill covers both; all SDK behavior cited below is drawn from the FME SDK docs (see References).

Differentiation from the sibling vendor references: statsig.md (opens in new window) uses localMode: true (gate/config primitives); optimizely.md (opens in new window) uses a JSON datafile; amplitude.md (opens in new window) uses a local-eval JSON fixture. Split.io is distinct: Split.io's offline mechanism uses authorizationKey: 'localhost' paired with an in-memory features map (JS SDK) or a YAML/text fixture file (Node.js SDK), and its evaluation API is getTreatment / getTreatmentWithConfig rather than decide, variant, or checkGate.

When to use

  • Tests for code that calls client.getTreatment() or client.getTreatmentWithConfig() against a Split.io-instrumented surface.
  • Verifying that impression listeners fire correctly per SDK evaluation.
  • Assignment-integrity tests per ab-test-validity-checklist Step 3.
  • CI pipelines where network access to Split.io / Harness is unavailable or undesirable.

How to use

Install

npm install --save-dev @splitsoftware/splitio   # Node.js + browser

Localhost/offline mode - JavaScript (browser) SDK

Set authorizationKey to 'localhost' and supply a features map:

import { SplitFactory } from '@splitsoftware/splitio';

const factory = SplitFactory({
  core: {
    authorizationKey: 'localhost',
    key: 'test-user-1',
  },
  features: {
    'checkout_redesign':  'on',
    'dark_mode':          'off',
    'pricing_experiment': { treatment: 'v2', config: '{"price":9}' },
  },
  scheduler: {
    offlineRefreshRate: 15,   // seconds between simulated polls
  },
});
const client = factory.client();

Any flag absent from the features map returns the 'control' treatment automatically - no extra setup needed for flags the test does not care about.

Localhost/offline mode - Node.js (server-side) SDK

The server-side SDK reads offline fixtures from a file path. Use a YAML fixture (supported since SDK v10.7.0) for per-key targeting:

# tests/fixtures/split-flags.yml
- checkout_redesign:
    treatment: "on"
    keys: "test-user-1"
    config: "{}"
- checkout_redesign:
    treatment: "off"
- dark_mode:
    treatment: "off"
import path from 'path';
import { SplitFactory } from '@splitsoftware/splitio';

const factory = SplitFactory({
  core: { authorizationKey: 'localhost' },
  features: path.join(__dirname, 'fixtures/split-flags.yml'),
  scheduler: { offlineRefreshRate: 15 },
});
const client = factory.client('test-user-1');

The plain-text format (two whitespace-separated columns) is also supported for simpler cases where per-key targeting is not needed.

SDK_READY event and whenReady()

The SDK emits client.Event.SDK_READY when its data is loaded. Always wait for this event before evaluating treatments to avoid receiving 'control' prematurely:

// Event-listener style
client.on(client.Event.SDK_READY, () => {
  const treatment = client.getTreatment('checkout_redesign');
  expect(treatment).toBe('on');
});

Or use the promise-based equivalent (cleaner in async test bodies):

// Promise style
beforeAll(async () => {
  await client.whenReady();
});

Additional events:

  • SDK_READY_TIMED_OUT: timeout before data loaded; SDK may still become ready later
  • SDK_UPDATE: rollout plan changed (useful in localhost mode to test dynamic flag flips)

getTreatment and getTreatmentWithConfig

getTreatment returns a treatment string; getTreatmentWithConfig returns { treatment: string, config: string | null }:

test('user in treatment arm sees new pricing', async () => {
  await client.whenReady();

  const treatment = client.getTreatment('test-user-1', 'pricing_experiment');
  expect(treatment).toBe('v2');

  const result = client.getTreatmentWithConfig(
    'test-user-1',
    'pricing_experiment'
  );
  expect(result.treatment).toBe('v2');
  expect(JSON.parse(result.config!)).toEqual({ price: 9 });
});

test('unknown flag returns control', async () => {
  await client.whenReady();
  const treatment = client.getTreatment('test-user-1', 'nonexistent_flag');
  expect(treatment).toBe('control');
});

Note: the Node.js SDK's getTreatment takes (key, flagName, attributes?, evaluationOptions?). The JavaScript client SDK's getTreatment takes (flagName, attributes?) because the key is bound at factory.client(key) construction time.

Impression listener verification

Attach an impressionListener to SplitFactory options. The logImpression callback receives an object containing impression (feature flag, key, treatment, label), attributes, ip, hostname, and sdkLanguageVersion:

const impressions: any[] = [];

const factory = SplitFactory({
  core: { authorizationKey: 'localhost' },
  features: path.join(__dirname, 'fixtures/split-flags.yml'),
  impressionListener: {
    logImpression(data) {
      impressions.push(data);
    },
  },
});
const client = factory.client('test-user-1');

test('impression fires on getTreatment', async () => {
  impressions.length = 0;
  await client.whenReady();

  client.getTreatment('test-user-1', 'checkout_redesign');

  expect(impressions).toHaveLength(1);
  expect(impressions[0].impression.treatment).toBe('on');
});

Controlling impression mode

sync.impressionsMode has three values:

  • 'OPTIMIZED' (default): only unique impressions queued; reduces traffic; suitable for production and most test scenarios.
  • 'DEBUG': all impressions queued and sent; use when validating that every evaluation generates a record.
  • 'NONE': no impressions tracked; use for flag-only (non-experiment) use cases in CI where impression noise is unwanted.
const factory = SplitFactory({
  core: { authorizationKey: 'localhost' },
  features: path.join(__dirname, 'fixtures/split-flags.yml'),
  sync: { impressionsMode: 'DEBUG' },
});

Assignment integrity tests

Per ab-test-validity-checklist Step 3, verify that the same key always receives the same treatment and that different keys can receive different treatments:

test('same key always gets same treatment (determinism)', async () => {
  await client.whenReady();
  const t1 = client.getTreatment('test-user-1', 'checkout_redesign');
  const t2 = client.getTreatment('test-user-1', 'checkout_redesign');
  expect(t1).toBe(t2);
});

test('treatments match the features map (hermetic)', async () => {
  await client.whenReady();
  expect(client.getTreatment('test-user-1', 'checkout_redesign')).toBe('on');
  // Different key receives the default treatment (no per-key override)
  expect(client.getTreatment('test-user-2', 'checkout_redesign')).toBe('off');
});

Teardown

Call client.destroy() (returns a promise) after tests to release internal resources. Skipping it leaks event-listener handles and impression flush timers:

afterAll(async () => {
  await client.destroy();
});

After destroy() is called, subsequent getTreatment calls return 'control' and factory operations require re-instantiation.

CI integration

jobs:
  split-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test
        # No SPLIT_API_KEY needed: localhost mode is fully offline

The YAML fixture is committed alongside the test code. No SPLIT_API_KEY secret is required in CI when using localhost mode.

Example

A full end-to-end Node.js/Jest test for a checkout feature guarded by two flags is in split-io-example.md (opens in new window).

Anti-patterns

Anti-patternWhy it failsFix
Using a real SDK key in testsNetwork calls; production impressions loggedauthorizationKey: 'localhost'
Calling getTreatment before SDK_READYReturns 'control' silently; wrong assertionawait client.whenReady() in beforeAll
Skipping client.destroy()Impression-flush timers leak between test filesAlways await client.destroy() in afterAll
Hardcoding the default .split file pathBreaks on CI where $HOME differsPass explicit path via path.join(__dirname, ...)
Asserting result.config is an objectconfig is a JSON string, not a parsed objectJSON.parse(result.config!) before asserting
Sharing one factory across test filesFlag-map mutations bleed between suitesOne factory per test file
Using impressionsMode: 'DEBUG' in CIEvery evaluation triggers a flush attemptUse 'NONE' when impressions are not under test

Limitations

  • No arm-pinning override API. Unlike Statsig's overrideGate or Optimizely's setForcedDecision, the Split.io SDK has no per-user override call. Pin treatments by adding a per-key YAML entry in the fixture file or by supplying the exact key that maps to the desired treatment.
  • Localhost mode is in-process only. It does not work across multiple processes (e.g., a forked worker) unless each process initializes its own factory with the same fixture.
  • YAML per-key targeting requires Node.js SDK v10.7.0+. Earlier versions only support the plain-text two-column format with uniform treatment per flag.
  • Dynamic feature-map mutations (JS SDK only). Mutating the features object at runtime triggers SDK_UPDATE and simulates a rollout change. The Node.js file-based fixture does not support runtime mutation without reloading.
  • Does not validate Split.io's server-side statistical analysis. Platform analysis is the vendor's responsibility; this skill tests your code's interaction with the SDK only.

References

Statsig SDK testing

Per docs.statsig.com (opens in new window), the Statsig SDK is available for Node.js, Java, Python, Go, Ruby, .NET, PHP, Rust, and C++ - all with the same conceptual surface: gates, experiments, dynamic configs.

When to use

  • Tests for code that reads a Statsig gate / experiment.
  • Verifying assignment integrity per ab-test-validity-checklist Step 3.
  • Local-evaluation tests when network access is restricted.

Authoring

Install

npm install --save-dev statsig-node       # Node
pip install statsig                        # Python

Initialize for testing

import statsig from 'statsig-node';

beforeAll(async () => {
  await statsig.initialize(process.env.STATSIG_SERVER_KEY!, {
    localMode: true,    // No network; all gates / configs return defaults
  });
});

afterAll(async () => {
  await statsig.shutdown();
});

localMode: true is the test-mode flag - gates fall back to the default value, configs return empty, no network calls.

Override gates / experiments per user

test('user in treatment sees new UI', async () => {
  statsig.overrideGate('new_ui_gate', true, 'user-1');

  const enabled = await statsig.checkGate({ userID: 'user-1' }, 'new_ui_gate');
  expect(enabled).toBe(true);

  const disabledForOthers = await statsig.checkGate({ userID: 'user-2' }, 'new_ui_gate');
  expect(disabledForOthers).toBe(false);
});

statsig.overrideGate(gateName, value, userID) - pin a user to a value for the lifetime of the test.

Experiment evaluation

test('user in arm B sees increased font size', async () => {
  statsig.overrideConfig('font_size_experiment', { font_size: 20 }, 'user-1');

  const exp = await statsig.getExperiment({ userID: 'user-1' }, 'font_size_experiment');
  expect(exp.value).toEqual({ font_size: 20 });
});

Assignment integrity tests

Per ab-test-validity-checklist Step 3:

test('same user always gets same arm (determinism)', async () => {
  const arm1 = await statsig.getExperiment({ userID: 'user-1' }, 'exp-x');
  const arm2 = await statsig.getExperiment({ userID: 'user-1' }, 'exp-x');
  expect(arm1.value).toEqual(arm2.value);
});

test('different users may get different arms', async () => {
  const arms = await Promise.all(
    Array.from({ length: 100 }, (_, i) =>
      statsig.getExperiment({ userID: `user-${i}` }, 'exp-x').then(e => e.value)
    )
  );
  const uniqueArms = new Set(arms.map(a => JSON.stringify(a)));
  expect(uniqueArms.size).toBeGreaterThan(1);
});

Exposure event firing

Statsig fires an exposure event per evaluation by default; verify in tests:

test('exposure logged on evaluation', async () => {
  const events: any[] = [];
  // Statsig SDK exposes a hook for testing event logging
  statsig.flush();  // Force flush any pending events
  // Inspect via test mock of the event-uploader
});

Running

npm test

For CI, use STATSIG_SERVER_KEY set to a test-tier key OR rely on localMode: true for fully-offline tests.

CI integration

jobs:
  statsig-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm test
        env:
          STATSIG_SERVER_KEY: ${{ secrets.STATSIG_TEST_KEY }}

Anti-patterns

Anti-patternWhy it failsFix
Tests using production Statsig API keyProduction traffic pollutedPer-env keys; or localMode: true
Skipping statsig.shutdown()Pending event upload leaksAlways shutdown
Asserting on exact internal config IDsStatsig config IDs changeAssert on returned values
Tests rely on real evaluation (no override)Flaky if Statsig service changesOverride per test
Forgetting userID in evaluationReturns default; not the test you wroteAlways pass full user object
Sharing one Statsig instance across test filesOverride leaksPer-test cleanup

Limitations

  • localMode is most-but-not-fully-offline. Some SDK initialization paths still ping Statsig.
  • No deterministic arm assignment without override. Test randomness via override; for hash-stability use the network-mode SDK.
  • Overrides are SDK-instance scoped. Don't help if the app spawns multiple processes.
  • Doesn't validate Statsig's server-side analysis. That's the platform's responsibility; tests verify your code's interaction with the SDK.

References

VWO SDK testing

The VWO (Visual Website Optimizer) server-side SDK uses a settings file (equivalent to Optimizely's datafile) for offline-capable testing. Per developers.vwo.com (opens in new window), the SDK supports multiple languages with a common API: activate, get_feature_variable_value, is_feature_enabled, track, push.

When to use

  • Tests for code that reads a VWO feature variable / experiment.
  • Force-bucketing for per-test assignment pinning.
  • Assignment-integrity tests per ab-test-validity-checklist Step 3.

Authoring

Install

pip install vwo-python-sdk
npm install --save-dev vwo-node-sdk

Settings-file-based init

import json
import vwo

with open("tests/fixtures/vwo-settings.json") as f:
    settings = json.load(f)

client = vwo.launch(settings, is_development_mode=True)

is_development_mode=True disables event tracking to VWO servers - fully-offline tests.

Activate an experiment / variation

def test_get_variation_for_user():
    variation_name = client.activate("checkout-experiment", "user-1")
    assert variation_name in ("Control", "Variation-1")

Feature variable

def test_feature_variable_value():
    value = client.get_feature_variable_value("checkout-experiment", "button_color", "user-1")
    assert value in ("blue", "green")

Force-bucket a user

VWO doesn't have a direct "force decision" API like Optimizely; the canonical approach is to construct user IDs that hash into specific buckets - or use the SDK's userPreSegment callback where supported.

Per VWO docs, the bucketing is deterministic on the user ID. Tests rely on this:

def test_specific_user_id_in_treatment():
    # User IDs are bucketed deterministically; pre-compute and pin
    KNOWN_TREATMENT_USER = "test-user-treatment-12345"
    variation = client.activate("checkout-experiment", KNOWN_TREATMENT_USER)
    assert variation == "Variation-1"

A pre-test step generates user IDs and records their bucket assignments in a fixture; tests reference the fixture.

Assignment integrity tests

def test_same_user_always_same_variation():
    v1 = client.activate("expt", "user-1")
    v2 = client.activate("expt", "user-1")
    assert v1 == v2

def test_bucketing_is_uniform():
    counts = {"Control": 0, "Variation-1": 0}
    for i in range(10000):
        v = client.activate("expt", f"user-{i}")
        if v: counts[v] += 1
    # 50/50 split → within a few percent
    ratio = counts["Variation-1"] / sum(counts.values())
    assert 0.48 < ratio < 0.52

The bucketing-uniformity test is also a unit-level SRM check per ab-test-validity-checklist Step 2.

Event tracking

def test_conversion_tracked():
    client.activate("expt", "user-1")
    success = client.track("expt", "user-1", "checkout_completed")
    assert success

Running

pytest tests/vwo/

CI integration

jobs:
  vwo-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
      - run: pip install vwo-python-sdk
      - run: pytest tests/vwo/

Anti-patterns

Anti-patternWhy it failsFix
Live-mode tests with real accountPollutes prod analyticsis_development_mode=True
Settings-file not committedTests flake on schema changesCommit fixture; refresh deliberately
User IDs that hash into one bucket onlyFalse sense of "covering both arms"Verify via bucketing-uniformity test
Skipping conversion-tracking testTrack-event regressions silentTest track() success
Different settings files in dev vs CIBehaviour divergesSingle fixture
Tests not isolated per experimentCross-experiment bucketing leakPer-test client teardown

Limitations

  • No direct force-decision API. Must rely on deterministic bucketing or modify the user pre-segmentation.
  • Settings file is point-in-time. Drift between fixture + prod invisible.
  • Bucketing-uniformity tests need many user IDs. Below 1000 iterations the variance dominates.
  • Event tracking is fire-and-forget in offline mode. Can't assert delivery; only the local-call success.

References

Related skills

ab-test-validity-checklist

Workflow skill that builds an A/B-test validity checklist from an experiment proposal, walking the canonical design-correctness gates - pre-registered OEC/power/guardrails, randomization unit + SRM check, assignment integrity, telemetry, peeking discipline, novelty/primacy, post-experiment SRM re-check - into a per-experiment checklist + sign-off form. Use when launching, auditing, or governing an experiment. For pitfall mechanics (guardrails, peeking) see experiment-results-interpreter's references; to read an already-valid result use experiment-results-interpreter; for per-SDK harness tests use experiment-sdk-testing - this gates DESIGN, not SDK code.

experiment-results-interpreter

Interprets the results of a valid online controlled experiment, one whose harness, SRM, and telemetry have already been confirmed. Covers the distinction between practical and statistical significance, reading confidence intervals instead of binary p-values, novelty and primacy week-over-week decay that causes post-ship reversion, interaction effects from concurrent experiments, Simpson's paradox in segmented results, and the ordered guardrail-check sequence required before a ship decision - with the deep methodology in references/: the peeking problem and its corrections (fixed-horizon, alpha-spending, always-valid mSPRT) in references/peeking.md, and guardrail-metric methodology (taxonomy, OEC relationship, pre-commitment, thresholds) in references/guardrails.md. Use when a data scientist or PM is ready to draw conclusions from an experiment, when designing a stop-early policy, or when declaring an experiment's guardrail set. Distinct from ab-test-validity-checklist (harness setup and SRM detection).

feature-flag-test-matrix-reference

Feature-flag test matrix design: the flag-state combinatorics problem (N flags × M variants × K user-segments = N×M×K test cases), the canonical coverage strategies (pairwise interaction coverage; default-only smoke; full matrix; risk-driven matrix), the workflow for building the coverage suite from a flag inventory (grep-based inventory, per-flag classification, PICT pairwise generation, per-cell test skeletons), the dedicated kill-switch test categories (references/killswitch.md: graceful degradation, fail-static default, kill latency, mid-flight consistency), and the flags-vs-experiments distinction. Use when designing the flag-test surface for a new project, building or auditing flag-test coverage, or authoring kill-switch tests.

launchdarkly-testing

Wraps LaunchDarkly server-side SDK testing patterns: TestData data source for hermetic tests (no network), file-based data source for fixture-driven tests, flag override patterns (TestData.update for per-test flag values), and assignment-integrity tests. Use when writing tests for code that uses LaunchDarkly flags; to decide which flag combinations those tests should cover in the first place, see feature-flag-test-matrix-reference.

openfeature-sdk-testing

Wraps OpenFeature (CNCF vendor-neutral SDK abstraction) testing patterns: the InMemoryProvider for hermetic tests without network calls, provider registration via OpenFeature.setProvider, the getBooleanValue/getBooleanDetails evaluation API with EvaluationDetails (value, variant, reason, errorCode), hooks for evaluation side-effects, and evaluation context for targeting-rule tests. Covers TypeScript, Java, and Python SDKs, plus per-vendor hermetic-bootstrap references for Unleash (bootstrap toggles), Flagsmith (offline LocalFileHandler), and GrowthBook (initSync payload). Use when writing tests for code that resolves feature flags through the OpenFeature SDK or the Unleash / Flagsmith / GrowthBook native SDKs; LaunchDarkly has its own skill (launchdarkly-testing).