AAE-50901 Allow provideMockFeatureFlags to receive observables (#12221)

* AAE-50901 Allow provideMockFeatureFlags to receive observables

* update

* update

* cr

* cr
This commit is contained in:
Bartosz Sekula
2026-09-07 14:21:03 +02:00
committed by GitHub
parent 2824543bf0
commit 27c700165c
3 changed files with 236 additions and 12 deletions
@@ -32,11 +32,13 @@ export interface QaFeaturesHelperConfig {
helperExposeKeyOnDocument?: string;
}
export interface FlagChangesetValues {
current: any;
previous: any;
}
export interface FlagChangeset {
[key: string]: {
current: any;
previous: any;
};
[key: string]: FlagChangesetValues;
}
export interface WritableFlagChangeset {
@@ -0,0 +1,133 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs';
import { FeaturesServiceToken, IFeaturesService } from '../interfaces/features.interface';
import { MockFeatureFlags, provideMockFeatureFlags } from './features-service-mock.factory';
describe('provideMockFeatureFlags', () => {
let featureValue$: BehaviorSubject<boolean>;
const feature1 = 'feature1';
const setupFeatureService = (featureFlags: MockFeatureFlags | string | string[]): IFeaturesService => {
TestBed.configureTestingModule({
providers: [provideMockFeatureFlags(featureFlags)]
});
return TestBed.inject(FeaturesServiceToken);
};
beforeEach(() => {
featureValue$ = new BehaviorSubject(false);
});
it('should emit the updated value when an observable feature flag changes', async () => {
const featuresService = setupFeatureService({ [feature1]: featureValue$ });
expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(false);
expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(true);
featureValue$.next(true);
expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(false);
featureValue$.next(false);
expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(false);
expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(true);
});
it('should not let a consumer change the feature flag value through the observable returned by isOn$', async () => {
const originalValue$ = new BehaviorSubject(true);
const service = setupFeatureService({ [feature1]: originalValue$ });
const isOn$ = service.isOn$(feature1) as Subject<boolean>;
expect(isOn$).not.toBe(originalValue$);
expect(() => isOn$.next(false)).toThrow();
expect(await firstValueFrom(service.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(originalValue$)).toBe(true);
});
it('should mock a string feature flag as enabled', async () => {
const service = setupFeatureService(feature1);
expect(await firstValueFrom(service.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(service.isOff$(feature1))).toBe(false);
});
it('should mock every feature flag in an array as enabled', async () => {
const feature2 = 'feature2';
const service = setupFeatureService([feature1, feature2]);
expect(await firstValueFrom(service.getFlags$())).toEqual({
[feature1]: { current: true, previous: null },
[feature2]: { current: true, previous: null }
});
});
it('should emit an empty changeset when no feature flags are provided', async () => {
const service = setupFeatureService({});
expect(await firstValueFrom(service.getFlags$())).toEqual({});
expect(await firstValueFrom(service.init())).toEqual({});
});
it('should emit an empty changeset when an empty feature flag array is provided', async () => {
const service = setupFeatureService([]);
expect(await firstValueFrom(service.getFlags$())).toEqual({});
expect(await firstValueFrom(service.init())).toEqual({});
});
it('should return the expected state when the feature flag is true', async () => {
const service = setupFeatureService({ [feature1]: true });
expect(await firstValueFrom(service.init())).toEqual({
[feature1]: { current: true, previous: null }
});
expect(await firstValueFrom(service.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(service.isOff$(feature1))).toBe(false);
});
it('should return the expected state when the feature flag is false', async () => {
const service = setupFeatureService({ [feature1]: false });
expect(await firstValueFrom(service.init())).toEqual({
[feature1]: { current: false, previous: null }
});
expect(await firstValueFrom(service.isOn$(feature1))).toBe(false);
expect(await firstValueFrom(service.isOff$(feature1))).toBe(true);
});
it('should resolve observable values in the complete feature flags result', async () => {
const service = setupFeatureService({ [feature1]: of(true) });
expect(await firstValueFrom(service.getFlags$())).toEqual({
[feature1]: { current: true, previous: null }
});
});
it('should throw when a feature flag has not been mocked', () => {
const featuresService = setupFeatureService({ [feature1]: true });
expect(() => featuresService.isOn$('missing-feature')).toThrowError(/missing-feature.*not mocked/);
expect(() => featuresService.isOff$('missing-feature')).toThrowError(/missing-feature.*not mocked/);
});
});
@@ -15,11 +15,27 @@
* limitations under the License.
*/
import { of } from 'rxjs';
import { FeaturesServiceToken, FlagChangeset, IFeaturesService } from '../interfaces/features.interface';
import { of, Observable, map, combineLatest, take, defer } from 'rxjs';
import { FeaturesServiceToken, FlagChangeset, FlagChangesetValues, IFeaturesService } from '../interfaces/features.interface';
/**
* Feature flags to mock. A boolean sets a fixed value, an observable lets the test change the value over time.
*
* Observable flags must have a current value, use 'BehaviorSubject' or 'of(...)'.
* A bare 'Subject' has no value until it emits, and 'getFlags$()'/'init()' withhold the whole changeset
* until every observable flag has emitted at least once.
*/
export interface MockFeatureFlags {
[key: string]: boolean;
[key: string]: boolean | Observable<boolean>;
}
interface MockFlagChangesetValues extends FlagChangesetValues {
current: boolean | Observable<boolean>;
previous: null;
}
interface MockFlagChangeset extends FlagChangeset {
[key: string]: MockFlagChangesetValues;
}
const assertFeatureFlag = (flagChangeset: FlagChangeset, key: string): void => {
@@ -32,19 +48,72 @@ const assertFeatureFlag = (flagChangeset: FlagChangeset, key: string): void => {
}
};
const mockFeaturesService = (flagChangeset: FlagChangeset): IFeaturesService => ({
init: () => of(flagChangeset),
/**
* Calling 'pipe' on a 'Subject' returns an 'AnonymousSubject' that still writes through to the original 'Subject',
* so piping alone does not stop a consumer from pushing values into the mocked flag.
* 'defer' breaks that chain and gives back a plain, read only observable.
*
* @param value$ Observable feature flag value provided by the test
* @returns Observable that cannot be used to change the mocked value
*/
const toReadOnly = (value$: Observable<boolean>): Observable<boolean> => defer(() => value$);
const mockFeaturesService = (flagChangeset: MockFlagChangeset): IFeaturesService => ({
init: () => resolveFeatureFlagValues(flagChangeset).pipe(take(1)),
isOn$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(flagChangeset[key].current);
const featureFlagValue = flagChangeset[key].current;
// In case of an observable, we do not want to return the original observable, so a consumer cannot 'next', 'error' or 'complete' it
return typeof featureFlagValue === 'boolean' ? of(featureFlagValue) : toReadOnly(featureFlagValue).pipe(map(Boolean));
},
isOff$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(!flagChangeset[key].current);
const featureFlagValue = flagChangeset[key].current;
return typeof featureFlagValue === 'boolean' ? of(!featureFlagValue) : toReadOnly(featureFlagValue).pipe(map((value) => !value));
},
getFlags$: () => of(flagChangeset)
getFlags$: () => resolveFeatureFlagValues(flagChangeset)
});
/**
* 'provideMockFeatureFlags' can receive observables, therefore we need to resolve these values
*
* @param mockFlagChangeset Mocked flag changeset
* @returns FlagChangeset
*/
const resolveFeatureFlagValues = (mockFlagChangeset: MockFlagChangeset): Observable<FlagChangeset> => {
// No FF provided, just return empty object
if (Object.keys(mockFlagChangeset).length === 0) {
return of({});
}
const resolveFeatureFlagValues$ = Object.entries(mockFlagChangeset).map(([featureKey, values]) => {
if (typeof values.current === 'boolean') {
return of([featureKey, { ...values }] as const);
}
// Value is observable, we need to resolve it
const observableValue$ = values.current;
const resolveFlagValue$ = observableValue$.pipe(map((resolvedValue) => [featureKey, { current: resolvedValue, previous: null }] as const));
return resolveFlagValue$;
});
return combineLatest(resolveFeatureFlagValues$).pipe(
map((resolvedFlags) => {
const resolvedFeatureFlag: FlagChangeset = {};
resolvedFlags.forEach(([key, values]) => {
resolvedFeatureFlag[key] = values;
});
return resolvedFeatureFlag;
})
);
};
const arrayToFlagChangeset = (featureFlags: string[]): FlagChangeset => {
const flagChangeset: FlagChangeset = {};
featureFlags.forEach((featureFlag) => {
@@ -62,6 +131,26 @@ const mockFeatureFlagsToFlagChangeset = (mockFeatureFlags: MockFeatureFlags) =>
return flagChangeset;
};
/**
* Mock the FeaturesService with the provided feature flags.
* A string or string[] sets every listed feature to true, a MockFeatureFlags object sets each value explicitly.
* Every flag the code under test asks for has to be mocked, otherwise 'isOn$'/'isOff$' throw.
*
* Use a 'BehaviorSubject' for a flag that changes during the test. A bare 'Subject' has no current value,
* so the flag stays silent and 'getFlags$()'/'init()' withhold the whole changeset, until it emits.
*
* @example
*
* const featureA$ = new BehaviorSubject(false);
*
* providers: [provideMockFeatureFlags('featureA')]
* providers: [provideMockFeatureFlags(['featureA', 'featureB'])]
* providers: [provideMockFeatureFlags({ featureA: true, featureB: false })]
* providers: [provideMockFeatureFlags({ featureA: featureA$ })]
*
* @param featureFlag The feature flag(s) to mock. Can be a single feature flag string, an array of feature flag strings, or a MockFeatureFlags object.
* @returns A provider object for the FeaturesServiceToken with the mocked feature flags.
*/
export const provideMockFeatureFlags = (featureFlag: MockFeatureFlags | string | string[]) => {
if (typeof featureFlag === 'string') {
featureFlag = [featureFlag];