-
Notifications
You must be signed in to change notification settings - Fork 1
Update automated tests to also check evaluation details #272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
570984c
19de7c3
411b818
780c73b
d8ed352
e8e983e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ import * as td from 'testdouble'; | |
|
||
import { | ||
ASSIGNMENT_TEST_DATA_DIR, | ||
AssignmentVariationValue, | ||
getTestAssignments, | ||
IAssignmentTestCase, | ||
MOCK_UFC_RESPONSE_FILE, | ||
|
@@ -28,7 +29,11 @@ import { Flag, ObfuscatedFlag, VariationType, FormatEnum, Variation } from '../i | |
import { getMD5Hash } from '../obfuscation'; | ||
import { AttributeType } from '../types'; | ||
|
||
import EppoClient, { checkTypeMatch, FlagConfigurationRequestParameters } from './eppo-client'; | ||
import EppoClient, { | ||
checkTypeMatch, | ||
FlagConfigurationRequestParameters, | ||
IAssignmentDetails, | ||
} from './eppo-client'; | ||
import { initConfiguration } from './test-utils'; | ||
|
||
// Use a known salt to produce deterministic hashes | ||
|
@@ -317,50 +322,66 @@ describe('EppoClient E2E test', () => { | |
}); | ||
}); | ||
|
||
describe('UFC Shared Test Cases', () => { | ||
describe.each(['Not Obfuscated', 'Obfuscated'])('UFC Shared Test Cases %s', (obfuscationType) => { | ||
const testCases = testCasesByFileName<IAssignmentTestCase>(ASSIGNMENT_TEST_DATA_DIR); | ||
const isObfuscated = obfuscationType === 'Obfuscated'; | ||
|
||
describe('Not obfuscated', () => { | ||
beforeAll(async () => { | ||
global.fetch = jest.fn(() => { | ||
return Promise.resolve({ | ||
ok: true, | ||
status: 200, | ||
json: () => Promise.resolve(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)), | ||
}); | ||
}) as jest.Mock; | ||
beforeAll(async () => { | ||
global.fetch = jest.fn(() => { | ||
return Promise.resolve({ | ||
ok: true, | ||
status: 200, | ||
json: () => | ||
Promise.resolve( | ||
readMockUFCResponse( | ||
isObfuscated ? OBFUSCATED_MOCK_UFC_RESPONSE_FILE : MOCK_UFC_RESPONSE_FILE, | ||
), | ||
), | ||
}); | ||
}) as jest.Mock; | ||
|
||
await initConfiguration(storage); | ||
}); | ||
await initConfiguration(storage); | ||
}); | ||
|
||
afterAll(() => { | ||
jest.restoreAllMocks(); | ||
}); | ||
afterAll(() => { | ||
jest.restoreAllMocks(); | ||
}); | ||
|
||
describe.each(['Scalar', 'With Details'])('%s', (assignmentType) => { | ||
const assignmentWithDetails = assignmentType === 'With Details'; | ||
|
||
it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { | ||
const { flag, variationType, defaultValue, subjects } = testCases[fileName]; | ||
const client = new EppoClient({ flagConfigurationStore: storage }); | ||
const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated }); | ||
client.setIsGracefulFailureMode(false); | ||
|
||
let assignments: { | ||
subject: SubjectTestCase; | ||
assignment: string | boolean | number | null | object; | ||
assignment: AssignmentVariationValue; | ||
}[] = []; | ||
|
||
const typeAssignmentFunctions = { | ||
[VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), | ||
[VariationType.NUMERIC]: client.getNumericAssignment.bind(client), | ||
[VariationType.INTEGER]: client.getIntegerAssignment.bind(client), | ||
[VariationType.STRING]: client.getStringAssignment.bind(client), | ||
[VariationType.JSON]: client.getJSONAssignment.bind(client), | ||
}; | ||
const typeAssignmentFunctions = assignmentWithDetails | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: I've found that having some code repetition in your tests is easier to work with than using parameterized tests with conditionals. It makes tests easier to grok and debug and it reduces the chances of having a bug in your test itself. I would've done this: describe.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => {
it('should validate for Scalar assignment types', () => {
const { flag, variationType, defaultValue, subjects } = testCases[fileName];
const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated });
client.setIsGracefulFailureMode(false);
let assignments: {
subject: SubjectTestCase;
assignment: string | boolean | number | null | object;
}[] = [];
const scalarTypeAssignmentFunctions = {
[VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client),
[VariationType.NUMERIC]: client.getNumericAssignment.bind(client),
[VariationType.INTEGER]: client.getIntegerAssignment.bind(client),
[VariationType.STRING]: client.getStringAssignment.bind(client),
[VariationType.JSON]: client.getJSONAssignment.bind(client),
};
const assignmentFn = scalarTypeAssignmentFunctions[variationType] as (
flagKey: string,
subjectKey: string,
subjectAttributes: Record<string, AttributeType>,
defaultValue: boolean | string | number | object,
) => never;
if (!assignmentFn) {
throw new Error(`Unknown variation type: ${variationType}`);
}
assignments = getTestAssignments(
{ flag, variationType, defaultValue, subjects },
assignmentFn,
);
validateTestAssignments(assignments, flag, false, isObfuscated);
});
it('should validate assignment details', () => {
// ...
});
}); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I actually started out with this and then decided there was so much repeat code I should pull it out into a private helper method. And then, while parameterizing things to do that, I decided it was easiest to just have the 2x2 grid of obfuscated/not and scalar/details. I generally am fine with repeat code to make tests easier to understand what is happening, but I think the I also thought having two different So basically I had the same instincts as you but after coding up I ended up deciding this was the cleanest. Totally subjective I know. And I do hate how Webstorm chokes on |
||
? { | ||
[VariationType.BOOLEAN]: client.getBooleanAssignmentDetails.bind(client), | ||
[VariationType.NUMERIC]: client.getNumericAssignmentDetails.bind(client), | ||
[VariationType.INTEGER]: client.getIntegerAssignmentDetails.bind(client), | ||
[VariationType.STRING]: client.getStringAssignmentDetails.bind(client), | ||
[VariationType.JSON]: client.getJSONAssignmentDetails.bind(client), | ||
} | ||
Comment on lines
+363
to
+370
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We now also test the |
||
: { | ||
[VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), | ||
[VariationType.NUMERIC]: client.getNumericAssignment.bind(client), | ||
[VariationType.INTEGER]: client.getIntegerAssignment.bind(client), | ||
[VariationType.STRING]: client.getStringAssignment.bind(client), | ||
[VariationType.JSON]: client.getJSONAssignment.bind(client), | ||
}; | ||
|
||
const assignmentFn = typeAssignmentFunctions[variationType] as ( | ||
flagKey: string, | ||
subjectKey: string, | ||
subjectAttributes: Record<string, AttributeType>, | ||
defaultValue: boolean | string | number | object, | ||
) => never; | ||
defaultValue: AssignmentVariationValue, | ||
) => AssignmentVariationValue | IAssignmentDetails<AssignmentVariationValue>; | ||
if (!assignmentFn) { | ||
throw new Error(`Unknown variation type: ${variationType}`); | ||
} | ||
|
@@ -370,56 +391,7 @@ describe('EppoClient E2E test', () => { | |
assignmentFn, | ||
); | ||
|
||
validateTestAssignments(assignments, flag); | ||
}); | ||
}); | ||
|
||
describe('Obfuscated', () => { | ||
beforeAll(async () => { | ||
global.fetch = jest.fn(() => { | ||
return Promise.resolve({ | ||
ok: true, | ||
status: 200, | ||
json: () => Promise.resolve(readMockUFCResponse(OBFUSCATED_MOCK_UFC_RESPONSE_FILE)), | ||
}); | ||
}) as jest.Mock; | ||
|
||
await initConfiguration(storage); | ||
}); | ||
|
||
afterAll(() => { | ||
jest.restoreAllMocks(); | ||
}); | ||
|
||
it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { | ||
const { flag, variationType, defaultValue, subjects } = testCases[fileName]; | ||
const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated: true }); | ||
client.setIsGracefulFailureMode(false); | ||
|
||
const typeAssignmentFunctions = { | ||
[VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), | ||
[VariationType.NUMERIC]: client.getNumericAssignment.bind(client), | ||
[VariationType.INTEGER]: client.getIntegerAssignment.bind(client), | ||
[VariationType.STRING]: client.getStringAssignment.bind(client), | ||
[VariationType.JSON]: client.getJSONAssignment.bind(client), | ||
}; | ||
|
||
const assignmentFn = typeAssignmentFunctions[variationType] as ( | ||
flagKey: string, | ||
subjectKey: string, | ||
subjectAttributes: Record<string, AttributeType>, | ||
defaultValue: boolean | string | number | object, | ||
) => never; | ||
if (!assignmentFn) { | ||
throw new Error(`Unknown variation type: ${variationType}`); | ||
} | ||
|
||
const assignments = getTestAssignments( | ||
{ flag, variationType, defaultValue, subjects }, | ||
assignmentFn, | ||
); | ||
|
||
validateTestAssignments(assignments, flag); | ||
validateTestAssignments(assignments, flag, assignmentWithDetails, isObfuscated); | ||
}); | ||
}); | ||
}); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -144,7 +144,6 @@ export class Evaluator { | |
configDetails.configFormat, | ||
); | ||
} catch (err: any) { | ||
console.error('>>>>', err); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. whoops past Aaron left this in 😅 |
||
const flagEvaluationDetails = flagEvaluationDetailsBuilder.gracefulBuild( | ||
'ASSIGNMENT_ERROR', | ||
`Assignment Error: ${err.message}`, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -43,6 +43,7 @@ export interface IUniversalFlagConfigResponse { | |
} | ||
|
||
export interface IBanditParametersResponse { | ||
updatedAt: string; // ISO formatted string | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bandits response also hase |
||
bandits: Record<string, BanditParameters>; | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,13 @@ import * as fs from 'fs'; | |
|
||
import { isEqual } from 'lodash'; | ||
|
||
import { AttributeType, ContextAttributes, IAssignmentDetails, VariationType } from '../src'; | ||
import { | ||
AttributeType, | ||
ContextAttributes, | ||
IAssignmentDetails, | ||
Variation, | ||
VariationType, | ||
} from '../src'; | ||
import { IFlagEvaluationDetails } from '../src/flag-evaluation-details-builder'; | ||
import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../src/http-client'; | ||
|
||
|
@@ -20,17 +26,19 @@ const MOCK_PRECOMPUTED_FILENAME = 'precomputed-v1'; | |
export const MOCK_PRECOMPUTED_WIRE_FILE = `${MOCK_PRECOMPUTED_FILENAME}.json`; | ||
export const MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE = `${MOCK_PRECOMPUTED_FILENAME}-deobfuscated.json`; | ||
|
||
export type AssignmentVariationValue = Variation['value'] | object; | ||
|
||
export interface SubjectTestCase { | ||
subjectKey: string; | ||
subjectAttributes: Record<string, AttributeType>; | ||
assignment: string | number | boolean | object; | ||
assignment: AssignmentVariationValue; | ||
evaluationDetails: IFlagEvaluationDetails; | ||
} | ||
|
||
export interface IAssignmentTestCase { | ||
flag: string; | ||
variationType: VariationType; | ||
defaultValue: string | number | boolean | object; | ||
defaultValue: AssignmentVariationValue; | ||
subjects: SubjectTestCase[]; | ||
} | ||
|
||
|
@@ -85,12 +93,15 @@ export function getTestAssignments( | |
flagKey: string, | ||
subjectKey: string, | ||
subjectAttributes: Record<string, AttributeType>, | ||
defaultValue: string | number | boolean | object, | ||
) => never, | ||
): { subject: SubjectTestCase; assignment: string | boolean | number | null | object }[] { | ||
defaultValue: AssignmentVariationValue, | ||
) => AssignmentVariationValue | IAssignmentDetails<AssignmentVariationValue>, | ||
): { | ||
subject: SubjectTestCase; | ||
assignment: AssignmentVariationValue | IAssignmentDetails<AssignmentVariationValue>; | ||
}[] { | ||
const assignments: { | ||
subject: SubjectTestCase; | ||
assignment: string | boolean | number | null | object; | ||
assignment: AssignmentVariationValue; | ||
}[] = []; | ||
for (const subject of testCase.subjects) { | ||
const assignment = assignmentFn( | ||
|
@@ -104,48 +115,88 @@ export function getTestAssignments( | |
return assignments; | ||
} | ||
|
||
export function getTestAssignmentDetails( | ||
testCase: IAssignmentTestCase, | ||
assignmentDetailsFn: ( | ||
flagKey: string, | ||
subjectKey: string, | ||
subjectAttributes: Record<string, AttributeType>, | ||
defaultValue: string | number | boolean | object, | ||
) => never, | ||
): { | ||
subject: SubjectTestCase; | ||
assignmentDetails: IAssignmentDetails<string | boolean | number | object>; | ||
}[] { | ||
return testCase.subjects.map((subject) => ({ | ||
subject, | ||
assignmentDetails: assignmentDetailsFn( | ||
testCase.flag, | ||
subject.subjectKey, | ||
subject.subjectAttributes, | ||
testCase.defaultValue, | ||
), | ||
})); | ||
} | ||
const configCreatedAt = ( | ||
readMockUFCResponse(MOCK_UFC_RESPONSE_FILE) as IUniversalFlagConfigResponse | ||
).createdAt; | ||
const testHelperInstantiationDate = new Date(); | ||
|
||
export function validateTestAssignments( | ||
assignments: { | ||
subject: SubjectTestCase; | ||
assignment: string | boolean | number | object | null; | ||
assignment: AssignmentVariationValue | IAssignmentDetails<AssignmentVariationValue>; | ||
}[], | ||
flag: string, | ||
withDetails: boolean, | ||
isObfuscated: boolean, | ||
) { | ||
for (const { subject, assignment } of assignments) { | ||
if (!isEqual(assignment, subject.assignment)) { | ||
let assignedVariation = assignment; | ||
let assignmentDetails: IFlagEvaluationDetails | null = null; | ||
if ( | ||
withDetails === true && | ||
typeof assignment === 'object' && | ||
assignment !== null && | ||
'variation' in assignment | ||
) { | ||
assignedVariation = assignment.variation; | ||
assignmentDetails = assignment.evaluationDetails; | ||
} | ||
|
||
if (!isEqual(assignedVariation, subject.assignment)) { | ||
// More friendly error message | ||
console.error( | ||
`subject ${subject.subjectKey} was assigned ${JSON.stringify( | ||
assignment, | ||
assignedVariation, | ||
undefined, | ||
2, | ||
)} when expected ${JSON.stringify(subject.assignment, undefined, 2)} for flag ${flag}`, | ||
); | ||
} | ||
|
||
expect(assignment).toEqual(subject.assignment); | ||
expect(assignedVariation).toEqual(subject.assignment); | ||
|
||
if (withDetails) { | ||
if (!assignmentDetails) { | ||
throw new Error('Expected assignmentDetails to be populated'); | ||
} | ||
expect(assignmentDetails.environmentName).toBe(subject.evaluationDetails.environmentName); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here is where we now check the evaluation details |
||
expect(assignmentDetails.flagEvaluationCode).toBe( | ||
subject.evaluationDetails.flagEvaluationCode, | ||
); | ||
expect(assignmentDetails.flagEvaluationDescription).toBe( | ||
subject.evaluationDetails.flagEvaluationDescription, | ||
); | ||
expect(assignmentDetails.variationKey).toBe(subject.evaluationDetails.variationKey); | ||
// Use toString() to handle comparing JSON | ||
expect(assignmentDetails.variationValue?.toString()).toBe( | ||
subject.evaluationDetails.variationValue?.toString(), | ||
); | ||
expect(assignmentDetails.configPublishedAt).toBe(configCreatedAt); | ||
// cannot do an exact match for configFetchedAt because it will change based on fetch | ||
expect(new Date(assignmentDetails.configFetchedAt).getTime()).toBeGreaterThan( | ||
testHelperInstantiationDate.getTime(), | ||
); | ||
|
||
if (!isObfuscated) { | ||
expect(assignmentDetails.matchedRule).toEqual(subject.evaluationDetails.matchedRule); | ||
} else { | ||
// When obfuscated, rules may be one-way hashed (e.g., for ONE_OF checks) so cannot be unobfuscated | ||
// Thus we'll just check that the number of conditions is equal and relay on the unobfuscated | ||
// tests for correctness | ||
expect(assignmentDetails.matchedRule?.conditions || []).toHaveLength( | ||
subject.evaluationDetails.matchedRule?.conditions.length || 0, | ||
); | ||
} | ||
|
||
expect(assignmentDetails.matchedAllocation).toEqual( | ||
subject.evaluationDetails.matchedAllocation, | ||
); | ||
expect(assignmentDetails.unmatchedAllocations).toEqual( | ||
subject.evaluationDetails.unmatchedAllocations, | ||
); | ||
expect(assignmentDetails.unevaluatedAllocations).toEqual( | ||
subject.evaluationDetails.unevaluatedAllocations, | ||
); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using dataproviders allows reducing our setup and test case running code duplication