Skip to content

Commit 3d7278b

Browse files
authored
Merge pull request #290 from leancodepl/test/feature-flags-react-client
test(feature-flags-react-client): add unit tests
2 parents d6e3956 + 3960c22 commit 3d7278b

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

‎packages/feature-flags/feature-flags-react-client/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
"@openfeature/react-sdk": "^0.4.5",
2020
"@openfeature/web-sdk": "^1.2.3"
2121
},
22+
"devDependencies": {
23+
"@testing-library/react": "*",
24+
"react": "*",
25+
"vitest": "*"
26+
},
2227
"publishConfig": {
2328
"access": "public",
2429
"registry": "https://registry.npmjs.org/"
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { FunctionComponent, ReactNode } from "react"
2+
import { OpenFeatureProvider, useFlag } from "@openfeature/react-sdk"
3+
import { InMemoryProvider, OpenFeature } from "@openfeature/web-sdk"
4+
import { renderHook, waitFor } from "@testing-library/react"
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
6+
import { mkFeatureFlags } from "./mkFeatureFlags"
7+
8+
vi.mock("@openfeature/react-sdk", async importActual => {
9+
const actual = await importActual<typeof import("@openfeature/react-sdk")>()
10+
return {
11+
...actual,
12+
useFlag: vi.fn(actual.useFlag),
13+
}
14+
})
15+
16+
const flags = {
17+
boolFlag: { defaultValue: false },
18+
stringFlag: { defaultValue: "fallback" },
19+
numberFlag: { defaultValue: 0 },
20+
}
21+
22+
function arrange(flagConfiguration: ConstructorParameters<typeof InMemoryProvider>[0] = {}) {
23+
const provider = new InMemoryProvider(flagConfiguration)
24+
25+
const featureFlags = mkFeatureFlags(flags, provider)
26+
27+
const wrapper: FunctionComponent<{ children: ReactNode }> = ({ children }) => (
28+
<featureFlags.FeatureFlagsProvider>{children}</featureFlags.FeatureFlagsProvider>
29+
)
30+
31+
return { provider, wrapper, ...featureFlags }
32+
}
33+
34+
describe("mkFeatureFlags", () => {
35+
beforeEach(() => {
36+
vi.restoreAllMocks()
37+
})
38+
39+
afterEach(async () => {
40+
// reset global OpenFeature state so tests stay isolated
41+
await OpenFeature.clearContext()
42+
await OpenFeature.clearProviders()
43+
})
44+
45+
describe("provider registration", () => {
46+
it("registers the supplied provider with OpenFeature", () => {
47+
const setProvider = vi.spyOn(OpenFeature, "setProvider")
48+
const provider = new InMemoryProvider({})
49+
50+
mkFeatureFlags(flags, provider)
51+
52+
expect(setProvider).toHaveBeenCalledTimes(1)
53+
expect(setProvider).toHaveBeenCalledWith(provider)
54+
})
55+
})
56+
57+
describe("returned api", () => {
58+
it("exposes the OpenFeatureProvider as FeatureFlagsProvider", () => {
59+
const { FeatureFlagsProvider } = arrange()
60+
61+
expect(FeatureFlagsProvider).toBe(OpenFeatureProvider)
62+
})
63+
64+
it("exposes useFeatureFlag and setFeatureFlagsContext", () => {
65+
const { useFeatureFlag, setFeatureFlagsContext } = arrange()
66+
67+
expect(typeof useFeatureFlag).toBe("function")
68+
expect(typeof setFeatureFlagsContext).toBe("function")
69+
})
70+
})
71+
72+
describe("setFeatureFlagsContext", () => {
73+
it("delegates to OpenFeature.setContext with the correct binding", async () => {
74+
const setContext = vi.spyOn(OpenFeature, "setContext").mockResolvedValue(undefined)
75+
const { setFeatureFlagsContext } = arrange()
76+
77+
const context = { targetingKey: "user-1", country: "PL" }
78+
await setFeatureFlagsContext(context)
79+
80+
expect(setContext).toHaveBeenCalledTimes(1)
81+
expect(setContext).toHaveBeenCalledWith(context)
82+
})
83+
84+
it("stays callable after destructuring (this is bound to OpenFeature)", async () => {
85+
const { setFeatureFlagsContext } = arrange()
86+
87+
// would throw if `this` were lost during destructuring
88+
await expect(setFeatureFlagsContext({ targetingKey: "user-2" })).resolves.toBeUndefined()
89+
})
90+
})
91+
92+
describe("useFeatureFlag", () => {
93+
it("resolves the value configured in the provider", async () => {
94+
const { useFeatureFlag, wrapper } = arrange({
95+
boolFlag: { disabled: false, variants: { on: true, off: false }, defaultVariant: "on" },
96+
})
97+
98+
const { result } = renderHook(() => useFeatureFlag("boolFlag"), { wrapper })
99+
100+
await waitFor(() => {
101+
expect(result.current.value).toBe(true)
102+
})
103+
})
104+
105+
it("falls back to the per-flag defaultValue when the flag is missing from the provider", () => {
106+
const { useFeatureFlag, wrapper } = arrange({})
107+
108+
const { result } = renderHook(() => useFeatureFlag("stringFlag"), { wrapper })
109+
110+
expect(result.current.value).toBe("fallback")
111+
})
112+
113+
it("prefers an explicit defaultValue over the per-flag defaultValue", () => {
114+
const { useFeatureFlag, wrapper } = arrange({})
115+
116+
const { result } = renderHook(() => useFeatureFlag("stringFlag", "explicit"), { wrapper })
117+
118+
expect(result.current.value).toBe("explicit")
119+
})
120+
121+
it("uses an explicit falsy defaultValue rather than discarding it", () => {
122+
// guards the `defaultValue ?? flags[key]?.defaultValue` nullish-coalescing logic:
123+
// an explicit `false` must be respected, not replaced by the per-flag default.
124+
const explicitFalse = arrange({})
125+
const { result: falseResult } = renderHook(() => explicitFalse.useFeatureFlag("boolFlag", false), {
126+
wrapper: explicitFalse.wrapper,
127+
})
128+
expect(falseResult.current.value).toBe(false)
129+
130+
// and an explicit empty string is kept as-is
131+
const explicitEmpty = arrange({})
132+
const { result: emptyResult } = renderHook(() => explicitEmpty.useFeatureFlag("stringFlag", ""), {
133+
wrapper: explicitEmpty.wrapper,
134+
})
135+
expect(emptyResult.current.value).toBe("")
136+
})
137+
138+
it("passes the stringified key and resolved default through to the underlying useFlag", () => {
139+
const { useFeatureFlag, wrapper } = arrange({})
140+
vi.mocked(useFlag).mockClear()
141+
142+
renderHook(() => useFeatureFlag("numberFlag", 42), { wrapper })
143+
144+
expect(useFlag).toHaveBeenCalledWith("numberFlag", 42)
145+
})
146+
147+
it("evaluates configured number flags from the provider", async () => {
148+
const { useFeatureFlag, wrapper } = arrange({
149+
numberFlag: { disabled: false, variants: { high: 100, low: 1 }, defaultVariant: "high" },
150+
})
151+
152+
const { result } = renderHook(() => useFeatureFlag("numberFlag", 0), { wrapper })
153+
154+
await waitFor(() => {
155+
expect(result.current.value).toBe(100)
156+
})
157+
})
158+
159+
it("falls back to the default value when the flag is disabled in the provider", () => {
160+
const { useFeatureFlag, wrapper } = arrange({
161+
stringFlag: { disabled: true, variants: { a: "configured" }, defaultVariant: "a" },
162+
})
163+
164+
const { result } = renderHook(() => useFeatureFlag("stringFlag"), { wrapper })
165+
166+
// disabled flag -> client returns the default value handed to useFlag
167+
expect(result.current.value).toBe("fallback")
168+
})
169+
})
170+
})

0 commit comments

Comments
 (0)