Skip to content

Commit 7743129

Browse files
committed
integration tests
1 parent 5be8618 commit 7743129

File tree

7 files changed

+691
-6
lines changed

7 files changed

+691
-6
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ logs/
66
temp
77
.env
88
yarn-error.log
9+
10+
test/assignmentTestData

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ module.exports = {
99
transform: {
1010
'^.+\\.(t|j)s$': 'ts-jest',
1111
},
12+
globalSetup: './test/globalSetup.ts',
1213
collectCoverageFrom: ['**/*.(t|j)s'],
1314
coverageDirectory: 'coverage/',
1415
testEnvironment: 'node',

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
},
3030
"homepage": "https://github.com/Eppo-exp/js-client-sdk#readme",
3131
"devDependencies": {
32+
"@google-cloud/storage": "^6.1.0",
3233
"@microsoft/api-documenter": "^7.17.17",
3334
"@microsoft/api-extractor": "^7.25.0",
3435
"@types/jest": "^28.1.1",
@@ -44,8 +45,10 @@
4445
"jest": "^28.1.1",
4546
"jest-environment-jsdom": "^28.1.1",
4647
"prettier": "^2.7.1",
48+
"testdouble": "^3.16.6",
4749
"ts-jest": "^28.0.5",
48-
"typescript": "^4.7.3"
50+
"typescript": "^4.7.3",
51+
"xhr-mock": "^2.5.1"
4952
},
5053
"dependencies": {
5154
"axios": "^0.27.2"

src/eppo-client.spec.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import * as td from 'testdouble';
5+
import mock from 'xhr-mock';
6+
7+
import { IAssignmentTestCase, readAssignmentTestData } from '../test/testHelpers';
8+
9+
import EppoClient from './eppo-client';
10+
import { IExperimentConfiguration } from './experiment/experiment-configuration';
11+
import ExperimentConfigurationRequestor from './experiment/experiment-configuration-requestor';
12+
import { IVariation } from './experiment/variation';
13+
import { OperatorType } from './rule';
14+
15+
import { init } from '.';
16+
17+
describe('EppoClient E2E test', () => {
18+
beforeAll(() => {
19+
window.sessionStorage.clear();
20+
mock.setup();
21+
mock.get(/randomized_assignment\/config*/, (_req, res) => {
22+
const testCases: IAssignmentTestCase[] = readAssignmentTestData();
23+
const assignmentConfig: Record<string, IExperimentConfiguration> = {};
24+
testCases.forEach(({ experiment, percentExposure, variations }) => {
25+
assignmentConfig[experiment] = {
26+
name: experiment,
27+
percentExposure,
28+
enabled: true,
29+
subjectShards: 10000,
30+
variations,
31+
overrides: {},
32+
rules: [],
33+
};
34+
});
35+
return res.status(200).body(JSON.stringify({ experiments: assignmentConfig }));
36+
});
37+
});
38+
39+
afterAll(() => {
40+
mock.teardown();
41+
});
42+
43+
describe('getAssignment', () => {
44+
it.each(readAssignmentTestData())(
45+
'test variation assignment splits',
46+
async ({
47+
variations,
48+
experiment,
49+
percentExposure,
50+
subjects,
51+
expectedAssignments,
52+
}: IAssignmentTestCase) => {
53+
console.log(`---- Test Case for ${experiment} Experiment ----`);
54+
const assignments = await getAssignments(subjects, experiment);
55+
// verify the assingments don't change across test runs (deterministic)
56+
expect(assignments).toEqual(expectedAssignments);
57+
const expectedVariationSplitPercentage = percentExposure / variations.length;
58+
const unassignedCount = assignments.filter((assignment) => assignment == null).length;
59+
expectToBeCloseToPercentage(unassignedCount / assignments.length, 1 - percentExposure);
60+
variations.forEach((variation) => {
61+
validateAssignmentCounts(assignments, expectedVariationSplitPercentage, variation);
62+
});
63+
},
64+
);
65+
});
66+
67+
it('returns subject from overrides', () => {
68+
const mockConfigRequestor = td.object<ExperimentConfigurationRequestor>();
69+
const experiment = 'experiment_5';
70+
td.when(mockConfigRequestor.getConfiguration(experiment)).thenReturn({
71+
name: experiment,
72+
percentExposure: 1,
73+
enabled: true,
74+
subjectShards: 100,
75+
variations: [
76+
{
77+
name: 'control',
78+
shardRange: {
79+
start: 0,
80+
end: 33,
81+
},
82+
},
83+
{
84+
name: 'variant-1',
85+
shardRange: {
86+
start: 34,
87+
end: 66,
88+
},
89+
},
90+
{
91+
name: 'variant-2',
92+
shardRange: {
93+
start: 67,
94+
end: 100,
95+
},
96+
},
97+
],
98+
overrides: {
99+
a90ea45116d251a43da56e03d3dd7275: 'variant-2',
100+
},
101+
});
102+
const client = new EppoClient('subject-1', mockConfigRequestor);
103+
const assignment = client.getAssignment(experiment);
104+
expect(assignment).toEqual('variant-2');
105+
});
106+
107+
it('only returns variation if subject matches rules', () => {
108+
const mockConfigRequestor = td.object<ExperimentConfigurationRequestor>();
109+
const experiment = 'experiment_5';
110+
td.when(mockConfigRequestor.getConfiguration(experiment)).thenReturn({
111+
name: experiment,
112+
percentExposure: 1,
113+
enabled: true,
114+
subjectShards: 100,
115+
variations: [
116+
{
117+
name: 'control',
118+
shardRange: {
119+
start: 0,
120+
end: 50,
121+
},
122+
},
123+
{
124+
name: 'treatment',
125+
shardRange: {
126+
start: 50,
127+
end: 100,
128+
},
129+
},
130+
],
131+
overrides: {},
132+
rules: [
133+
{
134+
conditions: [
135+
{
136+
operator: OperatorType.GT,
137+
attribute: 'appVersion',
138+
value: 10,
139+
},
140+
],
141+
},
142+
],
143+
});
144+
let client = new EppoClient('subject-1', mockConfigRequestor, { appVersion: 9 });
145+
let assignment = client.getAssignment(experiment);
146+
expect(assignment).toEqual(null);
147+
client = new EppoClient('subject-1', mockConfigRequestor);
148+
assignment = client.getAssignment(experiment);
149+
expect(assignment).toEqual(null);
150+
client = new EppoClient('subject-1', mockConfigRequestor, { appVersion: 11 });
151+
assignment = client.getAssignment(experiment);
152+
expect(assignment).toEqual('control');
153+
});
154+
155+
function validateAssignmentCounts(
156+
assignments: string[],
157+
expectedPercentage: number,
158+
variation: IVariation,
159+
) {
160+
const assignedCount = assignments.filter((assignment) => assignment === variation.name).length;
161+
console.log(
162+
`Expect variation ${variation.name} percentage of ${
163+
assignedCount / assignments.length
164+
} to be close to ${expectedPercentage}`,
165+
);
166+
expectToBeCloseToPercentage(assignedCount / assignments.length, expectedPercentage);
167+
}
168+
169+
// expect assignment count to be within 5 percentage points of the expected count (because the hash output is random)
170+
function expectToBeCloseToPercentage(percentage: number, expectedPercentage: number) {
171+
expect(percentage).toBeGreaterThanOrEqual(expectedPercentage - 0.05);
172+
expect(percentage).toBeLessThanOrEqual(expectedPercentage + 0.05);
173+
}
174+
175+
async function getAssignments(subjects: string[], experiment: string): Promise<string[]> {
176+
const assignments: string[] = [];
177+
for (const subjectKey of subjects) {
178+
const client = await init({
179+
apiKey: 'dummy',
180+
baseUrl: 'http://127.0.0.1:4000',
181+
subjectKey,
182+
});
183+
const assignment = client.getAssignment(experiment);
184+
assignments.push(assignment);
185+
}
186+
return assignments;
187+
}
188+
});

test/globalSetup.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as fs from 'fs';
2+
3+
import { Storage } from '@google-cloud/storage';
4+
5+
import { TEST_DATA_DIR } from './testHelpers';
6+
7+
const storage = new Storage();
8+
9+
async function downloadTestDataFiles() {
10+
const [files] = await storage.bucket('sdk-test-data').getFiles({
11+
prefix: 'assignment/test-case',
12+
});
13+
return Promise.all(
14+
files.map((file, index) => {
15+
return file.download({ destination: `${TEST_DATA_DIR}test-case-${index}.json` });
16+
}),
17+
);
18+
}
19+
20+
export default async () => {
21+
if (!fs.existsSync(TEST_DATA_DIR)) {
22+
fs.mkdirSync(TEST_DATA_DIR);
23+
await downloadTestDataFiles();
24+
}
25+
};

test/testHelpers.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as fs from 'fs';
2+
3+
import { IVariation } from '../src/experiment/variation';
4+
5+
export const TEST_DATA_DIR = './test/assignmentTestData/';
6+
7+
export interface IAssignmentTestCase {
8+
experiment: string;
9+
percentExposure: number;
10+
variations: IVariation[];
11+
subjects: string[];
12+
expectedAssignments: string[];
13+
}
14+
15+
export function readAssignmentTestData(): IAssignmentTestCase[] {
16+
const testDataDir = './test/assignmentTestData/';
17+
const testCaseData: IAssignmentTestCase[] = [];
18+
const testCaseFiles = fs.readdirSync(testDataDir);
19+
testCaseFiles.forEach((file) => {
20+
const testCase = JSON.parse(fs.readFileSync(testDataDir + file, 'utf8'));
21+
testCaseData.push(testCase);
22+
});
23+
return testCaseData;
24+
}

0 commit comments

Comments
 (0)