Skip to Content
FeatureHow do I test a flag?

These pages document @evanion/feature 0.1.1, the version on npm.

How do I test a flag?

To verify that a flag behaves as expected, you pin the context and the instant and read the answer. Because a decision in @evanion/feature is a pure function of the configuration, the context, and the instant, you can test your flags without a server, a fake transport, or a clock mock.

What a test of a flag covers

When testing @evanion/feature, you generally verify four scenarios. The first three are ordinary unit tests against createFeatures, while the fourth involves running the fixtures published by the package.

  • Pinned Decisions: Verifying if a flag (e.g., express-pickup) is on for a specific shopper at a specific instant.
  • Rollout Boundaries: Ensuring a customer is included at 4.4% but excluded at 4.3%.
  • Variant Assignments: Determining which arm a known targetingKey (e.g., cust-0001) receives.
  • Process Agreement: Confirming that a second implementation or deployment of the library produces the same answer as the first.

What a test does not mock

Because no network call happens inside a decision, you do not need a server or fake transport for the engine. createFeatures reads a literal or a document already in memory, and functions like isEnabled, resolve, variantOf, and plan never fetch data. If your application loads the document from a control plane, that fetch sits outside the engine; your test simply passes the parsed document to createFeatures.

Similarly, the engine only reads the clock when the caller does not provide an instant, meaning the clock requires no mock.

How to pin the clock

To ensure a test is deterministic, you can pass a specific instant as part of the context, as every entry point in @evanion/feature honours context.now over new Date(). By calling features.for({ now }), you settle the instant once for the entire handle. This ensures every question asked on that handle resolves at that exact instant, preventing two questions from straddling a window boundary.

import { } from '@evanion/feature'; const = ([ { : 'new-checkout', : true, : [ { : 'after-launch', : [{ : 'now', : 'after', : '2026-10-01T00:00:00Z' }], }, ], }, { : 'express-pickup', : true, : ['new-checkout'] }, ]); const = new ('2026-09-30T23:59:59Z'); const = new ('2026-10-01T00:00:01Z'); // `context.now` pins the clock for one question. .('new-checkout', { : }); // -> false .('express-pickup', { : }); // -> true .({ : })['new-checkout']?.; // -> 'rule-match' // `for({ now })` settles the instant once, for every question on the handle. const = .({ : 'cust-0001', : }); .('new-checkout'); // -> false .('express-pickup'); // -> false ..; // -> beforeLaunch

How to test a rollout at its boundary

A customer is included in a rollout when their bucket times 100 is below the percent. Since the bucket is a pure hash of the seed and the targeting value, you can prove the edge of a rollout by asserting the answer for a named customer and picking percents on either side of their bucket.

To do this, read the bucket with bucketOf first, then test the surrounding percents. Note that a percent exactly equal to the bucket times 100 excludes the customer, as the comparison is strict.

import { , , } from '@evanion/feature'; // `cust-0101` hashes to bucket 0.0438..., which is 4.38...% of the range. ('cust-0101', 'new-checkout'); // -> 0.04385687271133065 // A customer is in when the bucket times 100 is below `percent`, so the // percent either side of 4.38... decides the answer. ('cust-0101', 4.3, 'new-checkout'); // -> false ('cust-0101', 4.4, 'new-checkout'); // -> true const = ([ { : 'new-checkout', : true, : [{ : 'early-access', : { : 4.4 } }], }, ]); .('new-checkout', { : 'cust-0101' }); // -> true .('new-checkout', { : 'cust-0007' }); // -> false

How to test a variant for a known key

A variant assignment is a pure function of the variantBy field and the seed. To test this in @evanion/feature, assert the variant for a known targetingKey, call it twice to verify the answer is stable, and inspect the assignment property on the decision to identify the bucket the answer originated from.

import { } from '@evanion/feature'; const = ([ { : 'checkout-cta', : true, : [ { : 'control', : 50 }, { : 'blue', : 50, : { : 'Get it' } }, ], }, ]); // The same key lands in the same variant on every call, in every process. .('checkout-cta', { : 'cust-0001' }); // -> 'blue' .('checkout-cta', { : 'cust-0001' }); // -> 'blue' .('checkout-cta', { : 'cust-0002' }); // -> 'control' // The decision names the bucket the assignment came from. .({ : 'cust-0001' })['checkout-cta']?.; // -> { source: 'weighted', by: 'targetingKey', bucket: 0.6045857421122491 }

How to test that two processes agree

To ensure different environments or languages produce identical results, the package provides conformance fixtures in the conformance/ directory, which conformance.spec.ts runs against the engine.

  • config-decisions.json: Contains a document, a context, and the decisions resolve returns. This covers every reason: default-on, explicitly-off, dependency-off, rule-match, and no-rule-matched.
  • plan-decisions.json: Contains a document, the context of a build plan, the published plan, a client context, and the decisions resolvePlan finishes the plan with.

A port of the engine to another language can run these same files to compare its decisions against the fixtures. In a TypeScript project, you can run these to verify that the installed version of @evanion/feature still answers as the fixture dictates.

import { } from 'node:fs'; import { } from 'node:module'; import { , } from 'node:path'; import { } from '@evanion/feature'; // The package publishes its fixtures in `conformance/`, beside `dist/`. const = ( ((import.meta.).('@evanion/feature')), '..', ); const = .( ((, 'conformance', 'config-decisions.json'), 'utf8'), ); const { , ... } = .context; const = (.config); const = .({ ..., : new () }); // Decisions are plain data, so a round trip through JSON gives what a port // would write out. .(.()); // -> fixture.decisions

Because date-time strings without offsets are read as local time—which would lead to different results across machines—the fixtures state every instant with an explicit offset.

Last updated on