From 24e43e4fa7f7b40ab909f7275a37c10bf302bc91 Mon Sep 17 00:00:00 2001 From: Adam Simon Date: Mon, 29 Jan 2024 17:27:20 +0100 Subject: [PATCH 1/2] Suggestion on implementing tests --- tests/mocks/ConfigCatClientMocks.ts | 94 ++++++++++++++++++ tests/resources/MockFeatureWrapper.ts | 16 --- tests/resources/Types.ts | 1 - tests/unit/FeatureWrapper.spec.ts | 136 ++++++++++++++++++-------- 4 files changed, 191 insertions(+), 56 deletions(-) create mode 100644 tests/mocks/ConfigCatClientMocks.ts delete mode 100644 tests/resources/MockFeatureWrapper.ts delete mode 100644 tests/resources/Types.ts diff --git a/tests/mocks/ConfigCatClientMocks.ts b/tests/mocks/ConfigCatClientMocks.ts new file mode 100644 index 0000000..08d9344 --- /dev/null +++ b/tests/mocks/ConfigCatClientMocks.ts @@ -0,0 +1,94 @@ +import { ClientCacheState, HookEvents, IConfig, IConfigCatClient, IConfigCatClientSnapshot, IEvaluationDetails, RefreshResult, SettingKeyValue, SettingTypeOf, SettingValue, User } from "../../src"; + +export class ConfigCatClientMockBase implements IConfigCatClient { + constructor( + public isOffline = false) { + } + + getValueAsync(key: string, defaultValue: T, user?: User | undefined): Promise> { + throw new Error("Method not implemented."); + } + getValueDetailsAsync(key: string, defaultValue: T, user?: User | undefined): Promise>> { + throw new Error("Method not implemented."); + } + getAllKeysAsync(): Promise { + throw new Error("Method not implemented."); + } + getAllValuesAsync(user?: User | undefined): Promise[]> { + throw new Error("Method not implemented."); + } + getAllValueDetailsAsync(user?: User | undefined): Promise[]> { + throw new Error("Method not implemented."); + } + getKeyAndValueAsync(variationId: string): Promise | null> { + throw new Error("Method not implemented."); + } + forceRefreshAsync(): Promise { + throw new Error("Method not implemented."); + } + waitForReady(): Promise { + throw new Error("Method not implemented."); + } + snapshot(): IConfigCatClientSnapshot { + throw new Error("Method not implemented."); + } + setDefaultUser(defaultUser: User): void { + throw new Error("Method not implemented."); + } + clearDefaultUser(): void { + throw new Error("Method not implemented."); + } + setOnline(): void { + throw new Error("Method not implemented."); + } + setOffline(): void { + throw new Error("Method not implemented."); + } + dispose(): void { + throw new Error("Method not implemented."); + } + addListener(eventName: TEventName, listener: (...args: HookEvents[TEventName]) => void): this { + throw new Error("Method not implemented."); + } + on(eventName: TEventName, listener: (...args: HookEvents[TEventName]) => void): this { + throw new Error("Method not implemented."); + } + once(eventName: TEventName, listener: (...args: HookEvents[TEventName]) => void): this { + throw new Error("Method not implemented."); + } + removeListener(eventName: TEventName, listener: (...args: HookEvents[TEventName]) => void): this { + throw new Error("Method not implemented."); + } + off(eventName: TEventName, listener: (...args: HookEvents[TEventName]) => void): this { + throw new Error("Method not implemented."); + } + removeAllListeners(eventName?: keyof HookEvents | undefined): this { + throw new Error("Method not implemented."); + } + listeners(eventName: keyof HookEvents): Function[] { + throw new Error("Method not implemented."); + } + listenerCount(eventName: keyof HookEvents): number { + throw new Error("Method not implemented."); + } + eventNames(): (keyof HookEvents)[] { + throw new Error("Method not implemented."); + } +} + +export class ConfigCatClientSnapshotMockBase implements IConfigCatClientSnapshot { + constructor( + public cacheState = ClientCacheState.NoFlagData, + public fetchedConfig: IConfig | null = null) { + } + + getAllKeys(): readonly string[] { + throw new Error("Method not implemented."); + } + getValue(key: string, defaultValue: T, user?: User | undefined): SettingTypeOf { + throw new Error("Method not implemented."); + } + getValueDetails(key: string, defaultValue: T, user?: User | undefined): IEvaluationDetails> { + throw new Error("Method not implemented."); + } +} \ No newline at end of file diff --git a/tests/resources/MockFeatureWrapper.ts b/tests/resources/MockFeatureWrapper.ts deleted file mode 100644 index 5cbadc0..0000000 --- a/tests/resources/MockFeatureWrapper.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineComponent } from "vue"; -// Types -import type { TFeatureFlagValue } from "./Types"; - -export const MockFeatureWrapper = defineComponent({ - data() { - return { - isFeatureFlagEnabled: null as TFeatureFlagValue, - }; - }, - template: ` - - - - `, -}); diff --git a/tests/resources/Types.ts b/tests/resources/Types.ts deleted file mode 100644 index 13c1e6f..0000000 --- a/tests/resources/Types.ts +++ /dev/null @@ -1 +0,0 @@ -export type TFeatureFlagValue = boolean | null; diff --git a/tests/unit/FeatureWrapper.spec.ts b/tests/unit/FeatureWrapper.spec.ts index d3e6a74..0250e99 100644 --- a/tests/unit/FeatureWrapper.spec.ts +++ b/tests/unit/FeatureWrapper.spec.ts @@ -2,68 +2,126 @@ import { mount } from "@vue/test-utils"; import { test, expect } from "vitest"; // Mock Component -import { MockFeatureWrapper } from "../resources/MockFeatureWrapper"; +import { ClientCacheState, FeatureWrapper, HookEvents, OverrideBehaviour, SettingTypeOf, SettingValue, User, createFlagOverridesFromMap } from "../../src"; +import ConfigCatPlugin, { type PluginOptions } from "../../src/plugins/ConfigCatPlugin"; +import { ConfigCatClientMockBase, ConfigCatClientSnapshotMockBase } from "../mocks/ConfigCatClientMocks"; test("The default slot should render when the isFeatureFlagEnabled value is true", () => { - const wrapper = mount(MockFeatureWrapper, { - data() { - return { - isFeatureFlagEnabled: true, - }; - }, - slots: { - default: "
the new feature
", - }, - }); + const featureFlagKey = "isFeatureFlagEnabled"; - // Assert the rendered text of the component - expect(wrapper.html()).toContain("
the new feature
"); -}); + const pluginOptions: PluginOptions = { + sdkKey: "local-only", + clientOptions: { + flagOverrides: createFlagOverridesFromMap({ [featureFlagKey]: true }, OverrideBehaviour.LocalOnly) + } + }; -test("The default slot should not render when the isFeatureFlagEnabled value is false", () => { - const wrapper = mount(MockFeatureWrapper, { - data() { - return { - isFeatureFlagEnabled: false, - }; + const wrapper = mount(FeatureWrapper, { + global: { + plugins: [[ConfigCatPlugin, pluginOptions]] + }, + props: { + featureKey: featureFlagKey }, slots: { default: "
the new feature
", + else: "
feature is not enabled
", + loading: "
component is loading
", }, }); - // Assert the rendered text of the component - expect(wrapper.html()).not.toContain("
the new feature
"); + try { + // Assert the rendered text of the component + expect(wrapper.html()).toContain("
the new feature
"); + } + finally { + wrapper.unmount(); + } }); test("The else slot should render when the isFeatureFlagEnabled value is false", () => { - const wrapper = mount(MockFeatureWrapper, { - data() { - return { - isFeatureFlagEnabled: false, - }; + const featureFlagKey = "isFeatureFlagEnabled"; + + const pluginOptions: PluginOptions = { + sdkKey: "local-only", + clientOptions: { + flagOverrides: createFlagOverridesFromMap({ [featureFlagKey]: false }, OverrideBehaviour.LocalOnly) + } + }; + + const wrapper = mount(FeatureWrapper, { + global: { + plugins: [[ConfigCatPlugin, pluginOptions]] + }, + props: { + featureKey: featureFlagKey }, slots: { - else: "
feature not enabled
", + default: "
the new feature
", + else: "
feature is not enabled
", + loading: "
component is loading
", }, }); - // Assert the rendered text of the component - expect(wrapper.html()).toContain("
feature not enabled
"); + try { + // Assert the rendered text of the component + expect(wrapper.html()).toContain("
feature is not enabled
"); + } + finally { + wrapper.unmount(); + } }); -test("The loading slot should render when the isFeatureFlagEnabled value is neither true nor false", () => { - const wrapper = mount(MockFeatureWrapper, { - data() { - return { - isFeatureFlagEnabled: null, - }; +test("The loading slot should render when the client is still initializing", () => { + const featureFlagKey = "isFeatureFlagEnabled"; + + // To test this scenario, we can't use flag overrides because there's no initialization period in that case, + // feature flag values provided by flag overrides are available immediately. + // So, we have to take the hard way: we need to provide a mock implementation of `IConfigCatClient` to the component. + + const clientMock = new class extends ConfigCatClientMockBase { + snapshot() { + return new ConfigCatClientSnapshotMockBase(ClientCacheState.NoFlagData); + } + + getValueAsync(key: string, defaultValue: T, user?: User | undefined): Promise> { + if (key === featureFlagKey) { + return new Promise(_ => {}); // by returning a Promise which never fullfils, we can make the initialization period infinite + } + + return super.getValueAsync(key, defaultValue, user); + } + + off(eventName: TEventName, listener: (...args: HookEvents[TEventName]) => void): this { + if (eventName == "configChanged") { + return this; + } + + return super.off(eventName, listener); + } + }(); + + const wrapper = mount(FeatureWrapper, { + global: { + provide: { + configCatClient: clientMock + } + }, + props: { + featureKey: featureFlagKey }, slots: { - loading: "
loading...
", + default: "
the new feature
", + else: "
feature is not enabled
", + loading: "
component is loading
", }, }); - // Assert the rendered text of the component - expect(wrapper.html()).toContain("
loading...
"); + try { + // Assert the rendered text of the component + expect(wrapper.html()).toContain("
component is loading
"); + } + finally { + wrapper.unmount(); + } }); From 7cd24afda8b62996e9e67fc41fa5a6c07b701be0 Mon Sep 17 00:00:00 2001 From: Adam Simon Date: Mon, 29 Jan 2024 17:55:30 +0100 Subject: [PATCH 2/2] Add more explanation --- tests/unit/FeatureWrapper.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/FeatureWrapper.spec.ts b/tests/unit/FeatureWrapper.spec.ts index 0250e99..ba39860 100644 --- a/tests/unit/FeatureWrapper.spec.ts +++ b/tests/unit/FeatureWrapper.spec.ts @@ -81,6 +81,7 @@ test("The loading slot should render when the client is still initializing", () const clientMock = new class extends ConfigCatClientMockBase { snapshot() { + // Returning a snapshot with NoFlagData will cause the component to take the "async" way on component initialization (see onBeforeMount). return new ConfigCatClientSnapshotMockBase(ClientCacheState.NoFlagData); } @@ -94,6 +95,8 @@ test("The loading slot should render when the client is still initializing", () off(eventName: TEventName, listener: (...args: HookEvents[TEventName]) => void): this { if (eventName == "configChanged") { + // The component unsubscribes from this event on component teardown (see onUnmounted). + // This is irrelevant in the case of this test, so a no-op will be fine. return this; }