Skip to content

Commit

Permalink
feat: add test provider (#971)
Browse files Browse the repository at this point in the history
Adds some testing utilities, specifically an
`<OpenFeatureTestProvider/>` react context provider.

See README for details.

---------

Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
  • Loading branch information
toddbaert authored Jul 29, 2024
1 parent a621e6d commit 1c12d4d
Show file tree
Hide file tree
Showing 6 changed files with 275 additions and 2 deletions.
62 changes: 62 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 <OpenFeatureProvider>.
```

You can resolve this by simply wrapping your component under test in the OpenFeatureTestProvider:

```tsx
// use default values for all evaluations
<OpenFeatureTestProvider>
<MyComponent />
</OpenFeatureTestProvider>
```

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'`
<OpenFeatureTestProvider flagValueMap={{ 'my-boolean-flag': true }}>
<MyComponent />
</OpenFeatureTestProvider>
```

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'`
<OpenFeatureTestProvider delayMs={1000} flagValueMap={{ 'my-boolean-flag': true }}>
<MyComponent />
</OpenFeatureTestProvider>
```

For maximum control, you can also pass your own mock provider implementation.
The type of this option is `Partial<Provider>`, so you can pass an incomplete implementation:

```tsx
class MyTestProvider implements Partial<Provider> {
// implement the relevant resolver
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
return {
value: true,
variant: 'my-variant',
reason: 'MY_REASON',
};
}
}
```

```tsx
// use your custom testing provider
<OpenFeatureTestProvider provider={new MyTestProvider()}>
<MyComponent />
</OpenFeatureTestProvider>,
```

## FAQ and troubleshooting

> I get an error that says something like: `A React component suspended while rendering, but no fallback UI was specified.`
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/provider/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './provider';
export * from './use-open-feature-client';
export * from './use-when-provider-ready';
export * from './test-provider';
111 changes: 111 additions & 0 deletions packages/react/src/provider/test-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof InMemoryProvider>[0];
type TestProviderProps = Omit<React.ComponentProps<typeof OpenFeatureProvider>, '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<Provider>;
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<void> {
await Promise.all([super.initialize(context), new Promise<void>((resolve) => setTimeout(resolve, this.delay))]);
}

async onContextChange() {
return new Promise<void>((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 (
<OpenFeatureProvider {...(testProviderOptions as NormalizedOptions)} domain={testProviderOptions.domain}>
{testProviderOptions.children}
</OpenFeatureProvider>
);
}

// mix in the no-op provider when the partial is passed
function mixInNoop(provider: Partial<Provider> = {}) {
// 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;
}
2 changes: 1 addition & 1 deletion packages/react/src/provider/use-open-feature-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <OpenFeatureProvider>',
'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',
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/react/test/provider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions packages/react/test/test-provider.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div>{thumbs ? 'πŸ‘' : 'πŸ‘Ž'}</div>
<div>reason: {`${reason}`}</div>
</>
);
}

describe('OpenFeatureTestProvider', () => {
describe('no args', () => {
it('renders default', async () => {
render(
<OpenFeatureTestProvider>
<TestComponent />
</OpenFeatureTestProvider>,
);
expect(await screen.findByText('πŸ‘Ž')).toBeInTheDocument();
});
});

describe('flagValueMap set', () => {
it('renders value from map', async () => {
render(
<OpenFeatureTestProvider flagValueMap={{ [FLAG_KEY]: true }}>
<TestComponent />
</OpenFeatureTestProvider>,
);

expect(await screen.findByText('πŸ‘')).toBeInTheDocument();
});
});

describe('delay and flagValueMap set', () => {
it('renders value after delay', async () => {
const delay = 100;
render(
<OpenFeatureTestProvider delayMs={delay} flagValueMap={{ [FLAG_KEY]: true }}>
<TestComponent />
</OpenFeatureTestProvider>,
);

// 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<Provider> {
resolveBooleanEvaluation(): ResolutionDetails<boolean> {
return {
value: true,
variant: 'test-variant',
reason,
};
}
}

render(
<OpenFeatureTestProvider provider={new MyTestProvider()}>
<TestComponent />
</OpenFeatureTestProvider>,
);

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<Provider> {
}

render(
<OpenFeatureTestProvider provider={new MyEmptyProvider()}>
<TestComponent />
</OpenFeatureTestProvider>,
);

expect(await screen.findByText('πŸ‘Ž')).toBeInTheDocument();
expect(await screen.findByText(/No-op/)).toBeInTheDocument();
});
});
});

0 comments on commit 1c12d4d

Please sign in to comment.