-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds some testing utilities, specifically an `<OpenFeatureTestProvider/>` react context provider. See README for details. --------- Signed-off-by: Todd Baert <[email protected]>
- Loading branch information
Showing
6 changed files
with
275 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); |