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-testingexperiment-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
| SDK | Offline mechanism | Arm pinning | Reference |
|---|---|---|---|
| Statsig | localMode: true | overrideGate / overrideConfig | references/statsig.md |
| Optimizely | JSON datafile fixture | set_forced_decision | references/optimizely.md (+ optimizely-recipes.md) |
| Split.io / Harness FME | authorizationKey: 'localhost' + features map / YAML | Per-key fixture entry (no override API) | references/split-io.md (+ split-io-example.md) |
| Amplitude Experiment | LocalEvaluationClient + bootstrap | Fixture edit or evaluateV2 mock | references/amplitude.md |
| VWO | Settings file + is_development_mode | Deterministic bucketing on user ID | references/vwo.md |
When to use
The shared hermetic-init pattern
Regardless of vendor, the suite has the same five steps:
Plus two integrity tests every suite should carry:
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_keyThe fixture drives the whole decision; the same shape translates to each vendor via its reference above.
Anti-patterns (all vendors)
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Live API key in tests | Production analytics polluted; rate limits; flakes | The vendor's offline switch |
| Fixture not version-controlled | Tests flake when prod config changes | Commit; refresh deliberately |
| Overrides / forced decisions leak across tests | Cross-test pollution | Per-test context + cleanup |
| Asserting on internal config / variation IDs | IDs change per environment | Assert keys and values |
| Skipping client shutdown / destroy | Event-flush timers and handles leak | Teardown in afterAll |
| Trusting one user ID to cover both arms | May bucket into one arm only | Distribution test across many IDs |
Limitations
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
Authoring
Install
pip install amplitude-experiment # Python (server-side)
npm install --save-dev @amplitude/experiment-node-serverInitialize (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 testCI 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-pattern | Why it fails | Fix |
|---|---|---|
| Tests use prod Amplitude key | Test users pollute analytics | Use test workspace + dev key |
| Exposure events enabled in CI | Spurious exposure tracking | automaticExposureTracking: false |
| Mocking variant() result without testing the fetch | Misses fetch-network bugs | Test both layers separately |
| Local-eval flag JSON not committed | Test flakes when prod changes | Commit fixture |
Skipping client.stop() / cleanup | Network handles leak | Always teardown |
| Different user-ID space between test + analytics | Amplitude correlation broken | Match the prod user-ID strategy |
Limitations
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) == 1Running
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
How to use
Authoring
Install
pip install optimizely-sdk # Python
npm install --save-dev @optimizely/optimizely-sdkDatafile-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-pattern | Why it fails | Fix |
|---|---|---|
| Tests with live SDK key | Production data polluted; rate-limited | Use datafile fixture |
| Datafile not version-controlled | Tests flake when prod config changes | Commit the fixture |
| Forced decisions leak across tests | Cross-test pollution | Per-test user context; reset before assertion |
Skipping client.shutdown / network listener cleanup | Goroutine / handle leak | Always cleanup |
| Asserting on variation IDs not keys | IDs change per environment | Use variation_key |
| Manual event tracking in tests vs notification listener | Misses platform-emitted events | Use the listener |
| Tests rely on real decide-network roundtrip | Slow; non-deterministic | Datafile + offline |
Limitations
References
End-to-end example
View source (opens in new window)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
How to use
Install
npm install --save-dev @splitsoftware/splitio # Node.js + browserLocalhost/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:
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:
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 offlineThe 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-pattern | Why it fails | Fix |
|---|---|---|
| Using a real SDK key in tests | Network calls; production impressions logged | authorizationKey: 'localhost' |
Calling getTreatment before SDK_READY | Returns 'control' silently; wrong assertion | await client.whenReady() in beforeAll |
Skipping client.destroy() | Impression-flush timers leak between test files | Always await client.destroy() in afterAll |
Hardcoding the default .split file path | Breaks on CI where $HOME differs | Pass explicit path via path.join(__dirname, ...) |
Asserting result.config is an object | config is a JSON string, not a parsed object | JSON.parse(result.config!) before asserting |
| Sharing one factory across test files | Flag-map mutations bleed between suites | One factory per test file |
Using impressionsMode: 'DEBUG' in CI | Every evaluation triggers a flush attempt | Use 'NONE' when impressions are not under test |
Limitations
References
Statsig SDK testing
View source (opens in new window)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
Authoring
Install
npm install --save-dev statsig-node # Node
pip install statsig # PythonInitialize 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 testFor 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-pattern | Why it fails | Fix |
|---|---|---|
| Tests using production Statsig API key | Production traffic polluted | Per-env keys; or localMode: true |
Skipping statsig.shutdown() | Pending event upload leaks | Always shutdown |
| Asserting on exact internal config IDs | Statsig config IDs change | Assert on returned values |
| Tests rely on real evaluation (no override) | Flaky if Statsig service changes | Override per test |
Forgetting userID in evaluation | Returns default; not the test you wrote | Always pass full user object |
| Sharing one Statsig instance across test files | Override leaks | Per-test cleanup |
Limitations
References
VWO SDK testing
View source (opens in new window)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
Authoring
Install
pip install vwo-python-sdk
npm install --save-dev vwo-node-sdkSettings-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.52The 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 successRunning
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-pattern | Why it fails | Fix |
|---|---|---|
| Live-mode tests with real account | Pollutes prod analytics | is_development_mode=True |
| Settings-file not committed | Tests flake on schema changes | Commit fixture; refresh deliberately |
| User IDs that hash into one bucket only | False sense of "covering both arms" | Verify via bucketing-uniformity test |
| Skipping conversion-tracking test | Track-event regressions silent | Test track() success |
| Different settings files in dev vs CI | Behaviour diverges | Single fixture |
| Tests not isolated per experiment | Cross-experiment bucketing leak | Per-test client teardown |
Limitations
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).