Skip to content

Commit

Permalink
Add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
EmilianoSanchez committed Nov 13, 2023
1 parent 7bbe38f commit fe02e05
Show file tree
Hide file tree
Showing 11 changed files with 168 additions and 122 deletions.
16 changes: 4 additions & 12 deletions src/SplitTreatments.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { SplitContext } from './SplitContext';
import { ISplitTreatmentsProps, ISplitContextValues } from './types';
import { getControlTreatmentsWithConfig, WARN_ST_NO_CLIENT } from './constants';
import { WARN_ST_NO_CLIENT } from './constants';
import { memoizeGetTreatmentsWithConfig } from './utils';

/**
Expand All @@ -24,17 +24,9 @@ export class SplitTreatments extends React.Component<ISplitTreatmentsProps> {
return (
<SplitContext.Consumer>
{(splitContext: ISplitContextValues) => {
const { client, isReady, isReadyFromCache, isDestroyed, lastUpdate } = splitContext;
let treatments;
const isOperational = !isDestroyed && (isReady || isReadyFromCache);
if (client && isOperational) {
// Cloning `client.getAttributes` result for memoization, because it returns the same reference unless `client.clearAttributes` is called.
// Caveat: same issue happens with `names` and `attributes` props if the user follows the bad practice of mutating the object instead of providing a new one.
treatments = this.evaluateFeatureFlags(client, lastUpdate, names, attributes, { ...client.getAttributes() }, flagSets);
} else {
treatments = getControlTreatmentsWithConfig(names);
if (!client) { this.logWarning = true; }
}
const { client, lastUpdate } = splitContext;
const treatments = this.evaluateFeatureFlags(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets);
if (!client) { this.logWarning = true; }
// SplitTreatments only accepts a function as a child, not a React Element (JSX)
return children({
...splitContext, treatments,
Expand Down
139 changes: 87 additions & 52 deletions src/__tests__/SplitTreatments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,27 @@ jest.mock('@splitsoftware/splitio/client', () => {
});
import { SplitFactory as SplitSdk } from '@splitsoftware/splitio/client';
import { sdkBrowser } from './testUtils/sdkConfigs';
const logSpy = jest.spyOn(console, 'log');
import { getStatus } from '../utils';
import { newSplitFactoryLocalhostInstance } from './testUtils/utils';
import { CONTROL_WITH_CONFIG, WARN_ST_NO_CLIENT } from '../constants';

/** Test target */
import { ISplitTreatmentsChildProps, ISplitTreatmentsProps, ISplitClientProps } from '../types';
import { SplitTreatments } from '../SplitTreatments';
import { SplitClient } from '../SplitClient';
import { SplitFactory } from '../SplitFactory';
jest.mock('../constants', () => {
const actual = jest.requireActual('../constants');
return {
...actual,
getControlTreatmentsWithConfig: jest.fn(actual.getControlTreatmentsWithConfig),
};
});
import { getControlTreatmentsWithConfig, WARN_ST_NO_CLIENT } from '../constants';
import { getStatus } from '../utils';
import { newSplitFactoryLocalhostInstance } from './testUtils/utils';
import { useSplitTreatments } from '../useSplitTreatments';

const logSpy = jest.spyOn(console, 'log');

describe('SplitTreatments', () => {

const featureFlagNames = ['split1', 'split2'];
const flagSets = ['set1', 'set2'];

afterEach(() => { logSpy.mockClear() });

it('passes as treatments prop the value returned by the function "getControlTreatmentsWithConfig" if the SDK is not ready.', (done) => {
const featureFlagNames = ['split1', 'split2'];
it('passes control treatments (empty object if flagSets is provided) if the SDK is not ready.', () => {
render(
<SplitFactory config={sdkBrowser} >
{({ factory }) => {
Expand All @@ -41,9 +37,16 @@ describe('SplitTreatments', () => {
<SplitTreatments names={featureFlagNames} >
{({ treatments }: ISplitTreatmentsChildProps) => {
const clientMock: any = factory?.client('user1');
expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(0);
expect(treatments).toEqual(getControlTreatmentsWithConfig(featureFlagNames));
done();
expect(clientMock.getTreatmentsWithConfig).not.toBeCalled();
expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG });
return null;
}}
</SplitTreatments>
<SplitTreatments flagSets={flagSets} >
{({ treatments }: ISplitTreatmentsChildProps) => {
const clientMock: any = factory?.client('user1');
expect(clientMock.getTreatmentsWithConfigByFlagSets).not.toBeCalled();
expect(treatments).toEqual({});
return null;
}}
</SplitTreatments>
Expand All @@ -54,8 +57,7 @@ describe('SplitTreatments', () => {
);
});

it('passes as treatments prop the value returned by the method "client.getTreatmentsWithConfig" if the SDK is ready.', (done) => {
const featureFlagNames = ['split1', 'split2'];
it('passes as treatments prop the value returned by the method "client.getTreatmentsWithConfig(ByFlagSets)" if the SDK is ready.', () => {
const outerFactory = SplitSdk(sdkBrowser);
(outerFactory as any).client().__emitter__.emit(Event.SDK_READY);

Expand All @@ -65,46 +67,51 @@ describe('SplitTreatments', () => {
expect(getStatus(outerFactory.client()).isReady).toBe(isReady);
expect(isReady).toBe(true);
return (
<SplitTreatments names={featureFlagNames} >
{({ treatments, isReady: isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitTreatmentsChildProps) => {
const clientMock: any = factory?.client();
expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(1);
expect(treatments).toBe(clientMock.getTreatmentsWithConfig.mock.results[0].value);
expect(featureFlagNames).toBe(clientMock.getTreatmentsWithConfig.mock.calls[0][0]);
expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, false, false, false, false, 0]);
done();
return null;
}}
</SplitTreatments>
<>
<SplitTreatments names={featureFlagNames} >
{({ treatments, isReady: isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitTreatmentsChildProps) => {
const clientMock: any = factory?.client();
expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(1);
expect(treatments).toBe(clientMock.getTreatmentsWithConfig.mock.results[0].value);
expect(featureFlagNames).toBe(clientMock.getTreatmentsWithConfig.mock.calls[0][0]);
expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, false, false, false, false, 0]);
return null;
}}
</SplitTreatments>
<SplitTreatments flagSets={flagSets} >
{({ treatments }: ISplitTreatmentsChildProps) => {
const clientMock: any = factory?.client();
expect(clientMock.getTreatmentsWithConfigByFlagSets.mock.calls.length).toBe(1);
expect(treatments).toBe(clientMock.getTreatmentsWithConfigByFlagSets.mock.results[0].value);
expect(flagSets).toBe(clientMock.getTreatmentsWithConfigByFlagSets.mock.calls[0][0]);
return null;
}}
</SplitTreatments>
</>
);
}}
</SplitFactory>
);
});

it('logs error and passes control treatments ("getControlTreatmentsWithConfig") if rendered outside an SplitProvider component.', () => {
const featureFlagNames = ['split1', 'split2'];
let passedTreatments;
it('logs error and passes control treatments if rendered outside an SplitProvider component.', () => {
render(
<SplitTreatments names={featureFlagNames} >
{({ treatments }: ISplitTreatmentsChildProps) => {
passedTreatments = treatments;
expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG });
return null;
}}
</SplitTreatments>
);

expect(logSpy).toBeCalledWith(WARN_ST_NO_CLIENT);
expect(getControlTreatmentsWithConfig).toBeCalledWith(featureFlagNames);
expect(getControlTreatmentsWithConfig).toHaveReturnedWith(passedTreatments);
});

/**
* Input validation. Passing invalid feature flag names or attributes while the Sdk
* is not ready doesn't emit errors, and logs meaningful messages instead.
*/
it('Input validation: invalid "names" and "attributes" props in SplitTreatments.', (done) => {
const featureFlagNames = ['split1', 'split2'];

render(
<SplitFactory config={sdkBrowser} >
{() => {
Expand Down Expand Up @@ -142,47 +149,62 @@ describe('SplitTreatments', () => {
done();
});


test('ignores flagSets and logs a warning if both names and flagSets params are provided.', () => {
render(
<SplitTreatments names={featureFlagNames} flagSets={flagSets} >
{({ treatments }) => {
expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG });
return null;
}}
</SplitTreatments>
);

expect(logSpy).toBeCalledWith('[WARN] Both "names" and "flagSets" props were provided. "flagSets" will be ignored.');
});
});

let renderTimes = 0;

/**
* Tests for asserting that client.getTreatmentsWithConfig is not called unnecessarily when using SplitTreatments and useSplitTreatments.
* Tests for asserting that client.getTreatmentsWithConfig and client.getTreatmentsWithConfigByFlagSets are not called unnecessarily when using SplitTreatments and useSplitTreatments.
*/
describe.each([
({ names, attributes }: { names: string[], attributes: SplitIO.Attributes }) => (
<SplitTreatments names={names} attributes={attributes} flagSets={undefined} >
({ names, flagSets, attributes }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes }) => (
<SplitTreatments names={names} attributes={attributes} flagSets={flagSets} >
{() => {
renderTimes++;
return null;
}}
</SplitTreatments>
),
({ names, attributes }: { names: string[], attributes: SplitIO.Attributes }) => {
useSplitTreatments({ names, attributes, flagSets: undefined });
({ names, flagSets, attributes }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes }) => {
useSplitTreatments({ names, flagSets, attributes });
renderTimes++;
return null;
}
])('SplitTreatments & useSplitTreatments optimization', (InnerComponent) => {
let outerFactory = SplitSdk(sdkBrowser);
(outerFactory as any).client().__emitter__.emit(Event.SDK_READY);

function Component({ names, attributes, splitKey, clientAttributes }: {
names: ISplitTreatmentsProps['names']
function Component({ names, flagSets, attributes, splitKey, clientAttributes }: {
names?: ISplitTreatmentsProps['names']
flagSets?: ISplitTreatmentsProps['flagSets']
attributes: ISplitTreatmentsProps['attributes']
splitKey: ISplitClientProps['splitKey']
clientAttributes?: ISplitClientProps['attributes']
}) {
return (
<SplitFactory factory={outerFactory} >
<SplitClient splitKey={splitKey} updateOnSdkUpdate={true} attributes={clientAttributes} >
<InnerComponent names={names} attributes={attributes} />
<InnerComponent names={names} attributes={attributes} flagSets={flagSets} />
</SplitClient>
</SplitFactory>
);
}

const names = ['split1', 'split2'];
const flagSets = ['set1', 'set2'];
const attributes = { att1: 'att1' };
const splitKey = sdkBrowser.core.key;

Expand All @@ -191,32 +213,47 @@ describe.each([
beforeEach(() => {
renderTimes = 0;
(outerFactory.client().getTreatmentsWithConfig as jest.Mock).mockClear();
wrapper = render(<Component names={names} attributes={attributes} splitKey={splitKey} />);
wrapper = render(<Component names={names} flagSets={flagSets} attributes={attributes} splitKey={splitKey} />);
})

afterEach(() => {
wrapper.unmount(); // unmount to remove event listener from factory
})

it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names and attributes are the same object.', () => {
wrapper.rerender(<Component names={names} attributes={attributes} splitKey={splitKey} />);
it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names, flagSets and attributes are the same object.', () => {
wrapper.rerender(<Component names={names} flagSets={flagSets} attributes={attributes} splitKey={splitKey} />);

expect(renderTimes).toBe(2);
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1);
expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0);
});

it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names and attributes are equals (shallow comparison).', () => {
wrapper.rerender(<Component names={[...names]} attributes={{ ...attributes }} splitKey={splitKey} />);
it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names, flagSets and attributes are equals (shallow comparison).', () => {
wrapper.rerender(<Component names={[...names]} flagSets={[...flagSets]} attributes={{ ...attributes }} splitKey={splitKey} />);

expect(renderTimes).toBe(2);
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1);
expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0);
});

it('rerenders and re-evaluates feature flags if names are not equals (shallow array comparison).', () => {
wrapper.rerender(<Component names={[...names, 'split3']} attributes={{ ...attributes }} splitKey={splitKey} />);

expect(renderTimes).toBe(2);
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2);
expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0);
});

it('rerenders and re-evaluates feature flags if flag sets are not equals (shallow array comparison).', () => {
wrapper.rerender(<Component flagSets={[...flagSets]} attributes={{ ...attributes }} splitKey={splitKey} />);
wrapper.rerender(<Component flagSets={[...flagSets]} attributes={{ ...attributes }} splitKey={splitKey} />);
expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(1);

wrapper.rerender(<Component flagSets={[...flagSets, 'split3']} attributes={{ ...attributes }} splitKey={splitKey} />);

expect(renderTimes).toBe(4);
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1);
expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(2);
});

it('rerenders and re-evaluates feature flags if attributes are not equals (shallow object comparison).', () => {
Expand Down Expand Up @@ -265,8 +302,6 @@ describe.each([
it('rerenders and re-evaluate feature flags when Split context changes (in both SplitFactory and SplitClient components).', async () => {
// changes in SplitContext implies that either the factory, the client (user key), or its status changed, what might imply a change in treatments
const outerFactory = SplitSdk(sdkBrowser);
const names = ['split1', 'split2'];
const attributes = { att1: 'att1' };
let renderTimesComp1 = 0;
let renderTimesComp2 = 0;

Expand Down
7 changes: 7 additions & 0 deletions src/__tests__/testUtils/mockSplitSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ function mockClient(_key: SplitIO.SplitKey, _trafficType?: string) {
return result;
}, {});
});
const getTreatmentsWithConfigByFlagSets: jest.Mock = jest.fn((flagSets: string[]) => {
return flagSets.reduce((result: SplitIO.TreatmentsWithConfig, flagSet: string) => {
result[flagSet + '_feature_flag'] = { treatment: 'on', config: null };
return result;
}, {});
});
const setAttributes: jest.Mock = jest.fn((attributes) => {
attributesCache = Object.assign(attributesCache, attributes);
return true;
Expand Down Expand Up @@ -87,6 +93,7 @@ function mockClient(_key: SplitIO.SplitKey, _trafficType?: string) {

return Object.assign(Object.create(__emitter__), {
getTreatmentsWithConfig,
getTreatmentsWithConfigByFlagSets,
track,
ready,
destroy,
Expand Down
Loading

0 comments on commit fe02e05

Please sign in to comment.