Skip to content

Commit

Permalink
refactor: Add unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
gorandalum committed Oct 25, 2024
1 parent 4408d59 commit 840368e
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 45 deletions.
44 changes: 5 additions & 39 deletions src/feature-toggles/FeatureTogglesContext.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,8 @@
import React, {createContext, useContext, useEffect, useState} from 'react';
import React, {createContext, useContext} from 'react';
import {storage} from '@atb/storage';
import {useRemoteConfig} from '@atb/RemoteConfigContext';
import {
DebugOverride,
FeatureToggles,
OverridesMap,
SetDebugOverride,
} from './types';
import {
getDebugOverridesFromSpecs,
getFeatureTogglesFromSpecs,
getStoredOverrides,
toStorageKey,
} from './utils.ts';

type FeatureTogglesContextState = FeatureToggles & {
debug: {
overrides: DebugOverride[];
setOverride: SetDebugOverride;
};
};
import {FeatureTogglesContextState} from './types';
import {useFeatureTogglesContextState} from './use-feature-toggle-context-state';

/**
* A contexts for retrieving feature toggle values.
Expand All @@ -32,28 +15,11 @@ const FeatureTogglesContext = createContext<

export const FeatureTogglesProvider: React.FC = ({children}) => {
const remoteConfig = useRemoteConfig();
const [overridesMap, setOverridesMap] = useState<OverridesMap>({});

useEffect(() => {
getStoredOverrides().then(setOverridesMap);
}, []);

const featureToggles = getFeatureTogglesFromSpecs(remoteConfig, overridesMap);

const value: FeatureTogglesContextState = {
...featureToggles,
debug: {
overrides: getDebugOverridesFromSpecs(overridesMap),
setOverride: (name, val) => {
const key = toStorageKey(name);
storage.set(key, JSON.stringify(val === undefined ? null : val));
setOverridesMap({...overridesMap, ...{[key]: val}});
},
},
};
const state = useFeatureTogglesContextState(remoteConfig, storage);

return (
<FeatureTogglesContext.Provider value={value}>
<FeatureTogglesContext.Provider value={state}>
{children}
</FeatureTogglesContext.Provider>
);
Expand Down
162 changes: 162 additions & 0 deletions src/feature-toggles/__tests__/FeatureTogglesContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {act, renderHook} from '@testing-library/react-hooks';
import {useFeatureTogglesContextState} from '../use-feature-toggle-context-state';
import type {StorageService} from '@atb/storage';
import type {RemoteConfig} from '@atb/remote-config';
import {toggleSpecifications} from '../toggle-specifications.ts';
import {toStorageKey} from '../utils.ts';
import {FeatureToggleNames} from '../types.ts';

const getStorageMock = (
getMultiResponse: [FeatureToggleNames, string | undefined][],
): StorageService =>
({
getMulti: async () =>
getMultiResponse.map(([n, v]) => [toStorageKey(n), v || null]),
set: async () => {},
} as any as StorageService);

const getRCMock = (conf: {[K in string]: boolean}) =>
conf as any as RemoteConfig;

describe('useFeatureTogglesContextState', () => {
it('Should react to remote config changes of no override', async () => {
const toggle = toggleSpecifications[0];
const rc = getRCMock({[toggle.remoteConfigKey]: false});
const storage = getStorageMock([]);

const hook = renderHook(
(rc: RemoteConfig) => useFeatureTogglesContextState(rc, storage),
{initialProps: rc},
);

await hook.waitForNextUpdate();
expect(hook.result.current[toggle.name]).toBe(false);

hook.rerender(getRCMock({[toggle.remoteConfigKey]: true}));
expect(hook.result.current[toggle.name]).toBe(true);

hook.rerender(getRCMock({[toggle.remoteConfigKey]: false}));
expect(hook.result.current[toggle.name]).toBe(false);
});

it('Should not react to remote config changes of override is set to false', async () => {
const toggle = toggleSpecifications[0];
const storage = getStorageMock([[toggle.name, 'false']]);

const hook = renderHook(
({rc}) => useFeatureTogglesContextState(rc, storage),
{
initialProps: {
rc: getRCMock({[toggle.remoteConfigKey]: true}),
},
},
);

await hook.waitForNextUpdate();
expect(hook.result.current[toggle.name]).toBe(false);

hook.rerender({rc: getRCMock({[toggle.remoteConfigKey]: false})});
expect(hook.result.current[toggle.name]).toBe(false);

hook.rerender({rc: getRCMock({[toggle.remoteConfigKey]: true})});
expect(hook.result.current[toggle.name]).toBe(false);
});

it('Should not react to remote config changes of override is set to true', async () => {
const toggle = toggleSpecifications[0];
const storage = getStorageMock([[toggle.name, 'true']]);

const hook = renderHook(
({rc}) => useFeatureTogglesContextState(rc, storage),
{
initialProps: {
rc: getRCMock({[toggle.remoteConfigKey]: false}),
},
},
);

await hook.waitForNextUpdate();
expect(hook.result.current[toggle.name]).toBe(true);

hook.rerender({rc: getRCMock({[toggle.remoteConfigKey]: true})});
expect(hook.result.current[toggle.name]).toBe(true);

hook.rerender({rc: getRCMock({[toggle.remoteConfigKey]: false})});
expect(hook.result.current[toggle.name]).toBe(true);
});

it('Should override value when setting debug override', async () => {
const toggle = toggleSpecifications[0];
const rc = getRCMock({[toggle.remoteConfigKey]: true});
const storage = getStorageMock([]);

const hook = renderHook(() => useFeatureTogglesContextState(rc, storage));

await hook.waitForNextUpdate();
expect(hook.result.current[toggle.name]).toBe(true);

act(() => hook.result.current.debug.setOverride(toggle.name, false));
expect(hook.result.current[toggle.name]).toBe(false);

act(() => hook.result.current.debug.setOverride(toggle.name, true));
expect(hook.result.current[toggle.name]).toBe(true);

act(() => hook.result.current.debug.setOverride(toggle.name, false));
expect(hook.result.current[toggle.name]).toBe(false);

act(() => hook.result.current.debug.setOverride(toggle.name, undefined));
expect(hook.result.current[toggle.name]).toBe(true);
});

it('Set debug overrides should be returned', async () => {
const toggle1 = toggleSpecifications[0];
const toggle2 = toggleSpecifications[1];
const rc = getRCMock({
[toggle1.remoteConfigKey]: false,
[toggle2.remoteConfigKey]: true,
});
const storage = getStorageMock([]);

const hook = renderHook(() => useFeatureTogglesContextState(rc, storage));

await hook.waitForNextUpdate();
expect(hook.result.current.debug.overrides).toContainEqual({
name: toggle1.name,
value: undefined,
});
expect(hook.result.current.debug.overrides).toContainEqual({
name: toggle2.name,
value: undefined,
});

act(() => hook.result.current.debug.setOverride(toggle1.name, false));
expect(hook.result.current.debug.overrides).toContainEqual({
name: toggle1.name,
value: false,
});
expect(hook.result.current.debug.overrides).toContainEqual({
name: toggle2.name,
value: undefined,
});

act(() => hook.result.current.debug.setOverride(toggle2.name, true));
expect(hook.result.current.debug.overrides).toContainEqual({
name: toggle1.name,
value: false,
});
expect(hook.result.current.debug.overrides).toContainEqual({
name: toggle2.name,
value: true,
});

act(() => hook.result.current.debug.setOverride(toggle1.name, undefined));
expect(hook.result.current.debug.overrides).toContainEqual({
name: toggle1.name,
value: undefined,
});
expect(hook.result.current.debug.overrides).toContainEqual({
name: toggle2.name,
value: true,
});
});
});
7 changes: 7 additions & 0 deletions src/feature-toggles/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import {toggleSpecifications} from './toggle-specifications';

export type FeatureTogglesContextState = FeatureToggles & {
debug: {
overrides: DebugOverride[];
setOverride: SetDebugOverride;
};
};

export type FeatureToggleNames = (typeof toggleSpecifications)[number]['name'];
export type FeatureToggles = {
[K in (typeof toggleSpecifications)[number]['name']]: boolean;
Expand Down
38 changes: 38 additions & 0 deletions src/feature-toggles/use-feature-toggle-context-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {RemoteConfig} from '@atb/remote-config';
import type {StorageService} from '@atb/storage';
import type {FeatureTogglesContextState, OverridesMap} from './types';
import {useEffect, useState} from 'react';
import {
getDebugOverridesFromSpecs,
getFeatureTogglesFromSpecs,
getStoredOverrides,
toStorageKey,
} from './utils';

/**
* This is an extracted hook for the provider body, to improve testability and
* dependency injection.
*/
export const useFeatureTogglesContextState = (
remoteConfig: RemoteConfig,
storageService: StorageService,
): FeatureTogglesContextState => {
const [overridesMap, setOverridesMap] = useState<OverridesMap>({});
useEffect(() => {
getStoredOverrides(storageService).then(setOverridesMap);
}, [storageService]);

const featureToggles = getFeatureTogglesFromSpecs(remoteConfig, overridesMap);

return {
...featureToggles,
debug: {
overrides: getDebugOverridesFromSpecs(overridesMap),
setOverride: (name, val) => {
const key = toStorageKey(name);
storageService.set(key, JSON.stringify(val === undefined ? null : val));
setOverridesMap({...overridesMap, ...{[key]: val}});
},
},
};
};
14 changes: 8 additions & 6 deletions src/feature-toggles/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@ import {
FeatureToggleNames,
FeatureToggles,
OverridesMap,
} from '@atb/feature-toggles/types';
import {storage} from '@atb/storage';
} from './types';
import {parseBoolean} from '@atb/utils/parse-boolean';
import {RemoteConfig} from '@atb/remote-config';
import type {RemoteConfig} from '@atb/remote-config';
import {toggleSpecifications} from './toggle-specifications';
import {isDefined} from '@atb/utils/presence.ts';
import {isDefined} from '@atb/utils/presence';
import {StorageService} from '@atb/storage';

/**
* Get stored overrides from local storage for all entries in OverrideKeysEnum,
* and map it to an object. Will return empty object if something fails.
*/
export const getStoredOverrides = async (): Promise<OverridesMap> => {
export const getStoredOverrides = async (
storageService: StorageService,
): Promise<OverridesMap> => {
const keys = toggleSpecifications.map((s) => toStorageKey(s.name));
const storedPairs = await storage.getMulti(keys);
const storedPairs = await storageService.getMulti(keys);
const overridesMap = storedPairs?.reduce<OverridesMap>((all, [k, v]) => {
all[k] = parseBoolean(v);
return all;
Expand Down
2 changes: 2 additions & 0 deletions src/storage/StorageModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,5 @@ export const storage = {
.catch(errorHandler);
},
};

export type StorageService = typeof storage;
1 change: 1 addition & 0 deletions src/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export type {
StorageModel,
StorageModelTypes,
KeyValuePair,
StorageService,
} from './StorageModel';
export {StorageModelKeysEnum, storage} from './StorageModel';

0 comments on commit 840368e

Please sign in to comment.