diff --git a/packages/react/README.md b/packages/react/README.md index c9d0853e1..d795428b5 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -54,6 +54,7 @@ In addition to the feature provided by the [web sdk](https://openfeature.dev/doc - [Re-rendering with Context Changes](#re-rendering-with-context-changes) - [Re-rendering with Flag Configuration Changes](#re-rendering-with-flag-configuration-changes) - [Suspense Support](#suspense-support) + - [Testing](#testing) - [FAQ and troubleshooting](#faq-and-troubleshooting) - [Resources](#resources) @@ -278,6 +279,67 @@ function Fallback() { This can be disabled in the hook options (or in the [OpenFeatureProvider](#openfeatureprovider-context-provider)). +### Testing + +The React SDK includes a built-in context provider for testing. +This allows you to easily test components that use evaluation hooks, such as `useFlag`. +If you try to test a component (in this case, `MyComponent`) which uses an evaluation hook, you might see an error message like: + +``` +No OpenFeature client available - components using OpenFeature must be wrapped with an . +``` + +You can resolve this by simply wrapping your component under test in the OpenFeatureTestProvider: + +```tsx +// use default values for all evaluations + + + +``` + +The basic configuration above will simply use the default value provided in code. +If you'd like to control the values returned by the evaluation hooks, you can pass a map of flag keys and values: + +```tsx +// return `true` for all evaluations of `'my-boolean-flag'` + + + +``` + +Additionally, you can pass an artificial delay for the provider startup to test your suspense boundaries or loaders/spinners impacted by feature flags: + +```tsx +// delay the provider start by 1000ms and then return `true` for all evaluations of `'my-boolean-flag'` + + + +``` + +For maximum control, you can also pass your own mock provider implementation. +The type of this option is `Partial`, so you can pass an incomplete implementation: + +```tsx +class MyTestProvider implements Partial { + // implement the relevant resolver + resolveBooleanEvaluation(): ResolutionDetails { + return { + value: true, + variant: 'my-variant', + reason: 'MY_REASON', + }; + } +} +``` + +```tsx +// use your custom testing provider + + +, +``` + ## FAQ and troubleshooting > I get an error that says something like: `A React component suspended while rendering, but no fallback UI was specified.` diff --git a/packages/react/src/provider/index.ts b/packages/react/src/provider/index.ts index 5c148dbe6..8e12f357b 100644 --- a/packages/react/src/provider/index.ts +++ b/packages/react/src/provider/index.ts @@ -1,3 +1,4 @@ export * from './provider'; export * from './use-open-feature-client'; export * from './use-when-provider-ready'; +export * from './test-provider'; \ No newline at end of file diff --git a/packages/react/src/provider/test-provider.tsx b/packages/react/src/provider/test-provider.tsx new file mode 100644 index 000000000..aea5cca87 --- /dev/null +++ b/packages/react/src/provider/test-provider.tsx @@ -0,0 +1,111 @@ +import { + EvaluationContext, + InMemoryProvider, + JsonValue, + NOOP_PROVIDER, + OpenFeature, + Provider, +} from '@openfeature/web-sdk'; +import React from 'react'; +import { NormalizedOptions } from '../common/options'; +import { OpenFeatureProvider } from './provider'; + +type FlagValueMap = { [flagKey: string]: JsonValue }; +type FlagConfig = ConstructorParameters[0]; +type TestProviderProps = Omit, 'client'> & + ( + | { + provider?: never; + /** + * Optional map of flagKeys to flagValues for this OpenFeatureTestProvider context. + * If not supplied, all flag evaluations will default. + */ + flagValueMap?: FlagValueMap; + /** + * Optional delay for the underlying test provider's readiness and reconciliation. + * Defaults to 0. + */ + delayMs?: number; + } + | { + /** + * An optional partial provider to pass for full control over the flag resolution for this OpenFeatureTestProvider context. + * Any un-implemented methods or properties will no-op. + */ + provider?: Partial; + flagValueMap?: never; + delayMs?: never; + } + ); + + const TEST_VARIANT = 'test-variant'; + const TEST_PROVIDER = 'test-provider'; + +// internal provider which is basically the in-memory provider with a simpler config and some optional fake delays +class TestProvider extends InMemoryProvider { + constructor( + flagValueMap: FlagValueMap, + private delay = 0, + ) { + // convert the simple flagValueMap into an in-memory config + const flagConfig = Object.entries(flagValueMap).reduce((acc: FlagConfig, flag): FlagConfig => { + return { + ...acc, + [flag[0]]: { + variants: { + [TEST_VARIANT]: flag[1], + }, + defaultVariant: TEST_VARIANT, + disabled: false, + }, + }; + }, {}); + super(flagConfig); + } + + async initialize(context?: EvaluationContext | undefined): Promise { + await Promise.all([super.initialize(context), new Promise((resolve) => setTimeout(resolve, this.delay))]); + } + + async onContextChange() { + return new Promise((resolve) => setTimeout(resolve, this.delay)); + } +} + +/** + * A React Context provider based on the {@link InMemoryProvider}, specifically built for testing. + * Use this for testing components that use flag evaluation hooks. + * @param {TestProviderProps} testProviderOptions options for the OpenFeatureTestProvider + * @returns {OpenFeatureProvider} OpenFeatureTestProvider + */ +export function OpenFeatureTestProvider(testProviderOptions: TestProviderProps) { + const { flagValueMap, provider } = testProviderOptions; + const effectiveProvider = ( + flagValueMap ? new TestProvider(flagValueMap, testProviderOptions.delayMs) : mixInNoop(provider) || NOOP_PROVIDER + ) as Provider; + testProviderOptions.domain + ? OpenFeature.setProvider(testProviderOptions.domain, effectiveProvider) + : OpenFeature.setProvider(effectiveProvider); + + return ( + + {testProviderOptions.children} + + ); +} + +// mix in the no-op provider when the partial is passed +function mixInNoop(provider: Partial = {}) { + // fill in any missing methods with no-ops + for (const prop of Object.getOwnPropertyNames(Object.getPrototypeOf(NOOP_PROVIDER)).filter(prop => prop !== 'constructor')) { + const patchedProvider = provider as {[key: string]: keyof Provider}; + if (!Object.getPrototypeOf(patchedProvider)[prop] && !patchedProvider[prop]) { + patchedProvider[prop] = Object.getPrototypeOf(NOOP_PROVIDER)[prop]; + } + } + // fill in the metadata if missing + if (!provider.metadata || !provider.metadata.name) { + (provider.metadata as unknown) = { name: TEST_PROVIDER }; + } + return provider; +} \ No newline at end of file diff --git a/packages/react/src/provider/use-open-feature-client.ts b/packages/react/src/provider/use-open-feature-client.ts index bce7bbece..39bc9a207 100644 --- a/packages/react/src/provider/use-open-feature-client.ts +++ b/packages/react/src/provider/use-open-feature-client.ts @@ -12,7 +12,7 @@ export function useOpenFeatureClient(): Client { if (!client) { throw new Error( - 'No OpenFeature client available - components using OpenFeature must be wrapped with an ', + 'No OpenFeature client available - components using OpenFeature must be wrapped with an . If you are seeing this in a test, see: https://openfeature.dev/docs/reference/technologies/client/web/react#testing', ); } diff --git a/packages/react/test/provider.spec.tsx b/packages/react/test/provider.spec.tsx index 75adf85e4..fca7f8ad5 100644 --- a/packages/react/test/provider.spec.tsx +++ b/packages/react/test/provider.spec.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { OpenFeatureProvider, useOpenFeatureClient, useWhenProviderReady } from '../src'; import { TestingProvider } from './test.utils'; -describe('provider', () => { +describe('OpenFeatureProvider', () => { /** * artificial delay for various async operations for our provider, * multiples of it are used in assertions as well diff --git a/packages/react/test/test-provider.spec.tsx b/packages/react/test/test-provider.spec.tsx new file mode 100644 index 000000000..e2fca8281 --- /dev/null +++ b/packages/react/test/test-provider.spec.tsx @@ -0,0 +1,99 @@ +import { Provider, ResolutionDetails } from '@openfeature/web-sdk'; +import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; +import { OpenFeatureTestProvider, useFlag } from '../src'; + +const FLAG_KEY = 'thumbs'; + +function TestComponent() { + const { value: thumbs, reason } = useFlag(FLAG_KEY, false); + return ( + <> +
{thumbs ? '👍' : '👎'}
+
reason: {`${reason}`}
+ + ); +} + +describe('OpenFeatureTestProvider', () => { + describe('no args', () => { + it('renders default', async () => { + render( + + + , + ); + expect(await screen.findByText('👎')).toBeInTheDocument(); + }); + }); + + describe('flagValueMap set', () => { + it('renders value from map', async () => { + render( + + + , + ); + + expect(await screen.findByText('👍')).toBeInTheDocument(); + }); + }); + + describe('delay and flagValueMap set', () => { + it('renders value after delay', async () => { + const delay = 100; + render( + + + , + ); + + // should only be resolved after delay + expect(await screen.findByText('👎')).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, delay * 2)); + expect(await screen.findByText('👍')).toBeInTheDocument(); + }); + }); + + describe('provider set', () => { + const reason = 'MY_REASON'; + + it('renders provider-returned value', async () => { + + class MyTestProvider implements Partial { + resolveBooleanEvaluation(): ResolutionDetails { + return { + value: true, + variant: 'test-variant', + reason, + }; + } + } + + render( + + + , + ); + + expect(await screen.findByText('👍')).toBeInTheDocument(); + expect(await screen.findByText(/reason/)).toBeInTheDocument(); + }); + + it('falls back to no-op for missing methods', async () => { + + class MyEmptyProvider implements Partial { + } + + render( + + + , + ); + + expect(await screen.findByText('👎')).toBeInTheDocument(); + expect(await screen.findByText(/No-op/)).toBeInTheDocument(); + }); + }); +});