Skip to content

Commit 1c12d4d

Browse files
authored
feat: add test provider (#971)
Adds some testing utilities, specifically an `<OpenFeatureTestProvider/>` react context provider. See README for details. --------- Signed-off-by: Todd Baert <[email protected]>
1 parent a621e6d commit 1c12d4d

File tree

6 files changed

+275
-2
lines changed

6 files changed

+275
-2
lines changed

packages/react/README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ In addition to the feature provided by the [web sdk](https://openfeature.dev/doc
5454
- [Re-rendering with Context Changes](#re-rendering-with-context-changes)
5555
- [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes)
5656
- [Suspense Support](#suspense-support)
57+
- [Testing](#testing)
5758
- [FAQ and troubleshooting](#faq-and-troubleshooting)
5859
- [Resources](#resources)
5960

@@ -278,6 +279,67 @@ function Fallback() {
278279

279280
This can be disabled in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)).
280281

282+
### Testing
283+
284+
The React SDK includes a built-in context provider for testing.
285+
This allows you to easily test components that use evaluation hooks, such as `useFlag`.
286+
If you try to test a component (in this case, `MyComponent`) which uses an evaluation hook, you might see an error message like:
287+
288+
```
289+
No OpenFeature client available - components using OpenFeature must be wrapped with an <OpenFeatureProvider>.
290+
```
291+
292+
You can resolve this by simply wrapping your component under test in the OpenFeatureTestProvider:
293+
294+
```tsx
295+
// use default values for all evaluations
296+
<OpenFeatureTestProvider>
297+
<MyComponent />
298+
</OpenFeatureTestProvider>
299+
```
300+
301+
The basic configuration above will simply use the default value provided in code.
302+
If you'd like to control the values returned by the evaluation hooks, you can pass a map of flag keys and values:
303+
304+
```tsx
305+
// return `true` for all evaluations of `'my-boolean-flag'`
306+
<OpenFeatureTestProvider flagValueMap={{ 'my-boolean-flag': true }}>
307+
<MyComponent />
308+
</OpenFeatureTestProvider>
309+
```
310+
311+
Additionally, you can pass an artificial delay for the provider startup to test your suspense boundaries or loaders/spinners impacted by feature flags:
312+
313+
```tsx
314+
// delay the provider start by 1000ms and then return `true` for all evaluations of `'my-boolean-flag'`
315+
<OpenFeatureTestProvider delayMs={1000} flagValueMap={{ 'my-boolean-flag': true }}>
316+
<MyComponent />
317+
</OpenFeatureTestProvider>
318+
```
319+
320+
For maximum control, you can also pass your own mock provider implementation.
321+
The type of this option is `Partial<Provider>`, so you can pass an incomplete implementation:
322+
323+
```tsx
324+
class MyTestProvider implements Partial<Provider> {
325+
// implement the relevant resolver
326+
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
327+
return {
328+
value: true,
329+
variant: 'my-variant',
330+
reason: 'MY_REASON',
331+
};
332+
}
333+
}
334+
```
335+
336+
```tsx
337+
// use your custom testing provider
338+
<OpenFeatureTestProvider provider={new MyTestProvider()}>
339+
<MyComponent />
340+
</OpenFeatureTestProvider>,
341+
```
342+
281343
## FAQ and troubleshooting
282344

283345
> I get an error that says something like: `A React component suspended while rendering, but no fallback UI was specified.`
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './provider';
22
export * from './use-open-feature-client';
33
export * from './use-when-provider-ready';
4+
export * from './test-provider';
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {
2+
EvaluationContext,
3+
InMemoryProvider,
4+
JsonValue,
5+
NOOP_PROVIDER,
6+
OpenFeature,
7+
Provider,
8+
} from '@openfeature/web-sdk';
9+
import React from 'react';
10+
import { NormalizedOptions } from '../common/options';
11+
import { OpenFeatureProvider } from './provider';
12+
13+
type FlagValueMap = { [flagKey: string]: JsonValue };
14+
type FlagConfig = ConstructorParameters<typeof InMemoryProvider>[0];
15+
type TestProviderProps = Omit<React.ComponentProps<typeof OpenFeatureProvider>, 'client'> &
16+
(
17+
| {
18+
provider?: never;
19+
/**
20+
* Optional map of flagKeys to flagValues for this OpenFeatureTestProvider context.
21+
* If not supplied, all flag evaluations will default.
22+
*/
23+
flagValueMap?: FlagValueMap;
24+
/**
25+
* Optional delay for the underlying test provider's readiness and reconciliation.
26+
* Defaults to 0.
27+
*/
28+
delayMs?: number;
29+
}
30+
| {
31+
/**
32+
* An optional partial provider to pass for full control over the flag resolution for this OpenFeatureTestProvider context.
33+
* Any un-implemented methods or properties will no-op.
34+
*/
35+
provider?: Partial<Provider>;
36+
flagValueMap?: never;
37+
delayMs?: never;
38+
}
39+
);
40+
41+
const TEST_VARIANT = 'test-variant';
42+
const TEST_PROVIDER = 'test-provider';
43+
44+
// internal provider which is basically the in-memory provider with a simpler config and some optional fake delays
45+
class TestProvider extends InMemoryProvider {
46+
constructor(
47+
flagValueMap: FlagValueMap,
48+
private delay = 0,
49+
) {
50+
// convert the simple flagValueMap into an in-memory config
51+
const flagConfig = Object.entries(flagValueMap).reduce((acc: FlagConfig, flag): FlagConfig => {
52+
return {
53+
...acc,
54+
[flag[0]]: {
55+
variants: {
56+
[TEST_VARIANT]: flag[1],
57+
},
58+
defaultVariant: TEST_VARIANT,
59+
disabled: false,
60+
},
61+
};
62+
}, {});
63+
super(flagConfig);
64+
}
65+
66+
async initialize(context?: EvaluationContext | undefined): Promise<void> {
67+
await Promise.all([super.initialize(context), new Promise<void>((resolve) => setTimeout(resolve, this.delay))]);
68+
}
69+
70+
async onContextChange() {
71+
return new Promise<void>((resolve) => setTimeout(resolve, this.delay));
72+
}
73+
}
74+
75+
/**
76+
* A React Context provider based on the {@link InMemoryProvider}, specifically built for testing.
77+
* Use this for testing components that use flag evaluation hooks.
78+
* @param {TestProviderProps} testProviderOptions options for the OpenFeatureTestProvider
79+
* @returns {OpenFeatureProvider} OpenFeatureTestProvider
80+
*/
81+
export function OpenFeatureTestProvider(testProviderOptions: TestProviderProps) {
82+
const { flagValueMap, provider } = testProviderOptions;
83+
const effectiveProvider = (
84+
flagValueMap ? new TestProvider(flagValueMap, testProviderOptions.delayMs) : mixInNoop(provider) || NOOP_PROVIDER
85+
) as Provider;
86+
testProviderOptions.domain
87+
? OpenFeature.setProvider(testProviderOptions.domain, effectiveProvider)
88+
: OpenFeature.setProvider(effectiveProvider);
89+
90+
return (
91+
<OpenFeatureProvider {...(testProviderOptions as NormalizedOptions)} domain={testProviderOptions.domain}>
92+
{testProviderOptions.children}
93+
</OpenFeatureProvider>
94+
);
95+
}
96+
97+
// mix in the no-op provider when the partial is passed
98+
function mixInNoop(provider: Partial<Provider> = {}) {
99+
// fill in any missing methods with no-ops
100+
for (const prop of Object.getOwnPropertyNames(Object.getPrototypeOf(NOOP_PROVIDER)).filter(prop => prop !== 'constructor')) {
101+
const patchedProvider = provider as {[key: string]: keyof Provider};
102+
if (!Object.getPrototypeOf(patchedProvider)[prop] && !patchedProvider[prop]) {
103+
patchedProvider[prop] = Object.getPrototypeOf(NOOP_PROVIDER)[prop];
104+
}
105+
}
106+
// fill in the metadata if missing
107+
if (!provider.metadata || !provider.metadata.name) {
108+
(provider.metadata as unknown) = { name: TEST_PROVIDER };
109+
}
110+
return provider;
111+
}

packages/react/src/provider/use-open-feature-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function useOpenFeatureClient(): Client {
1212

1313
if (!client) {
1414
throw new Error(
15-
'No OpenFeature client available - components using OpenFeature must be wrapped with an <OpenFeatureProvider>',
15+
'No OpenFeature client available - components using OpenFeature must be wrapped with an <OpenFeatureProvider>. If you are seeing this in a test, see: https://openfeature.dev/docs/reference/technologies/client/web/react#testing',
1616
);
1717
}
1818

packages/react/test/provider.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as React from 'react';
55
import { OpenFeatureProvider, useOpenFeatureClient, useWhenProviderReady } from '../src';
66
import { TestingProvider } from './test.utils';
77

8-
describe('provider', () => {
8+
describe('OpenFeatureProvider', () => {
99
/**
1010
* artificial delay for various async operations for our provider,
1111
* multiples of it are used in assertions as well
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Provider, ResolutionDetails } from '@openfeature/web-sdk';
2+
import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup
3+
import { render, screen } from '@testing-library/react';
4+
import * as React from 'react';
5+
import { OpenFeatureTestProvider, useFlag } from '../src';
6+
7+
const FLAG_KEY = 'thumbs';
8+
9+
function TestComponent() {
10+
const { value: thumbs, reason } = useFlag(FLAG_KEY, false);
11+
return (
12+
<>
13+
<div>{thumbs ? '👍' : '👎'}</div>
14+
<div>reason: {`${reason}`}</div>
15+
</>
16+
);
17+
}
18+
19+
describe('OpenFeatureTestProvider', () => {
20+
describe('no args', () => {
21+
it('renders default', async () => {
22+
render(
23+
<OpenFeatureTestProvider>
24+
<TestComponent />
25+
</OpenFeatureTestProvider>,
26+
);
27+
expect(await screen.findByText('👎')).toBeInTheDocument();
28+
});
29+
});
30+
31+
describe('flagValueMap set', () => {
32+
it('renders value from map', async () => {
33+
render(
34+
<OpenFeatureTestProvider flagValueMap={{ [FLAG_KEY]: true }}>
35+
<TestComponent />
36+
</OpenFeatureTestProvider>,
37+
);
38+
39+
expect(await screen.findByText('👍')).toBeInTheDocument();
40+
});
41+
});
42+
43+
describe('delay and flagValueMap set', () => {
44+
it('renders value after delay', async () => {
45+
const delay = 100;
46+
render(
47+
<OpenFeatureTestProvider delayMs={delay} flagValueMap={{ [FLAG_KEY]: true }}>
48+
<TestComponent />
49+
</OpenFeatureTestProvider>,
50+
);
51+
52+
// should only be resolved after delay
53+
expect(await screen.findByText('👎')).toBeInTheDocument();
54+
await new Promise((resolve) => setTimeout(resolve, delay * 2));
55+
expect(await screen.findByText('👍')).toBeInTheDocument();
56+
});
57+
});
58+
59+
describe('provider set', () => {
60+
const reason = 'MY_REASON';
61+
62+
it('renders provider-returned value', async () => {
63+
64+
class MyTestProvider implements Partial<Provider> {
65+
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
66+
return {
67+
value: true,
68+
variant: 'test-variant',
69+
reason,
70+
};
71+
}
72+
}
73+
74+
render(
75+
<OpenFeatureTestProvider provider={new MyTestProvider()}>
76+
<TestComponent />
77+
</OpenFeatureTestProvider>,
78+
);
79+
80+
expect(await screen.findByText('👍')).toBeInTheDocument();
81+
expect(await screen.findByText(/reason/)).toBeInTheDocument();
82+
});
83+
84+
it('falls back to no-op for missing methods', async () => {
85+
86+
class MyEmptyProvider implements Partial<Provider> {
87+
}
88+
89+
render(
90+
<OpenFeatureTestProvider provider={new MyEmptyProvider()}>
91+
<TestComponent />
92+
</OpenFeatureTestProvider>,
93+
);
94+
95+
expect(await screen.findByText('👎')).toBeInTheDocument();
96+
expect(await screen.findByText(/No-op/)).toBeInTheDocument();
97+
});
98+
});
99+
});

0 commit comments

Comments
 (0)