Skip to content

Commit 184df2e

Browse files
jcqsawyerh
andauthored
Support mocking feature flags and add test coverage (#278)
## Ticket Resolves #263 --------- Co-authored-by: Sawyer <[email protected]>
1 parent d210a7b commit 184df2e

File tree

18 files changed

+233
-54
lines changed

18 files changed

+233
-54
lines changed

app/.env.development

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@
66
# If you deploy to a subpath, change this to the subpath so relative paths work correctly.
77
NEXT_PUBLIC_BASE_PATH=
88

9-
# AWS Evidently Feature Flag variables
9+
# =====================================================
10+
# AWS Evidently feature flagging service
11+
# =====================================================
1012
AWS_ACCESS_KEY_ID=
1113
AWS_SECRET_ACCESS_KEY=
1214
FEATURE_FLAGS_PROJECT=
13-
AWS_REGION=
15+
AWS_REGION=
16+
17+
# When FEATURE_FLAGS_PROJECT isn't set, a mock feature flagging service is used.
18+
# You can mock a feature flag's value by setting it here, using an env var name
19+
# format like NEXT_PUBLIC_FEATURE_<feature_name>=<true|false>
20+
# NEXT_PUBLIC_FEATURE_foo=true

app/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
│ │ ├── api # Custom request handlers
1616
│ │ ├── layout.tsx # Root layout, wraps every page
1717
│ │ └── page.tsx # Homepage
18+
| ├── adapters # External service adapters
1819
│ ├── components # Reusable UI components
1920
│ ├── i18n # Internationalization
20-
│ │ ├── config.ts # Supported locales, timezone, and formatters
21+
│ │ ├── config.ts # Supported locales, timezone, and formatters
2122
│ │ └── messages # Translated strings
2223
│ ├── styles # Sass & design system settings
2324
│ └── types # TypeScript type declarations
2425
├── stories # Storybook pages
25-
└── tests
26+
└── tests # Test setup and helpers
2627
```
2728

2829
## 💻 Development
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Evidently } from "@aws-sdk/client-evidently";
2+
3+
import { EvidentlyAdapter } from "./EvidentlyAdapter";
4+
5+
function getMockClient(flagValue: boolean) {
6+
return {
7+
evaluateFeature: jest.fn().mockResolvedValue({
8+
value: {
9+
boolValue: flagValue,
10+
},
11+
reason: "test-reason",
12+
}),
13+
} as unknown as Evidently;
14+
}
15+
16+
describe("EvidentlyAdapter", () => {
17+
beforeAll(() => {
18+
// Disable logging in test output
19+
jest.spyOn(console, "log").mockImplementation(() => {});
20+
jest.spyOn(console, "error").mockImplementation(() => {});
21+
});
22+
23+
describe("isFeatureEnabled", () => {
24+
it("falls back to false if the feature flag evaluation fails", async () => {
25+
const client = getMockClient(true);
26+
client.evaluateFeature = jest
27+
.fn()
28+
.mockRejectedValue(new Error("test-error"));
29+
const adapter = new EvidentlyAdapter();
30+
31+
const result = await adapter.isFeatureEnabled("test-feature");
32+
33+
expect(result).toBe(false);
34+
});
35+
36+
it.each([true, false])(
37+
"returns the boolean value of the feature flag evaluation",
38+
async (flagValue) => {
39+
const adapter = new EvidentlyAdapter(getMockClient(flagValue));
40+
41+
const result = await adapter.isFeatureEnabled(
42+
"test-feature",
43+
"test-entity-id"
44+
);
45+
46+
expect(result).toBe(flagValue);
47+
}
48+
);
49+
});
50+
});

app/src/services/feature-flags/FeatureFlagManager.ts renamed to app/src/adapters/feature-flags/EvidentlyAdapter.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
import { Evidently } from "@aws-sdk/client-evidently";
22

3+
import { FeatureFlagAdapter } from "./types";
4+
35
/**
46
* Class for managing feature flagging via AWS Evidently.
57
* Class method are available for use in next.js server side code.
68
*
79
* https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/evidently/
8-
*
910
*/
10-
export class FeatureFlagManager {
11+
export class EvidentlyAdapter implements FeatureFlagAdapter {
1112
client: Evidently;
1213
private _project = process.env.FEATURE_FLAGS_PROJECT;
1314

14-
constructor() {
15-
this.client = new Evidently();
15+
constructor(client = new Evidently()) {
16+
this.client = client;
1617
}
1718

18-
async isFeatureEnabled(featureName: string, userId?: string) {
19+
async isFeatureEnabled(featureName: string, entityId = "unknown") {
1920
const evalRequest = {
20-
entityId: userId,
21+
entityId,
2122
feature: featureName,
2223
project: this._project,
2324
};
@@ -31,7 +32,7 @@ export class FeatureFlagManager {
3132
message: "Made feature flag evaluation with AWS Evidently",
3233
data: {
3334
reason: evaluation.reason,
34-
userId: userId,
35+
entityId,
3536
featureName: featureName,
3637
featureFlagValue: featureFlagValue,
3738
},
@@ -42,8 +43,8 @@ export class FeatureFlagManager {
4243
message: "Error retrieving feature flag variation from AWS Evidently",
4344
data: {
4445
err: e,
45-
userId: userId,
46-
featureName: featureName,
46+
entityId,
47+
featureName,
4748
},
4849
});
4950
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { MockAdapter } from "./MockAdapter";
2+
3+
const mockAdapter = new MockAdapter();
4+
5+
describe("MockAdapter", () => {
6+
describe("isFeatureEnabled", () => {
7+
it("returns true when env var is set to true", async () => {
8+
process.env.NEXT_PUBLIC_FEATURE_baz = "true";
9+
10+
const isEnabled = await mockAdapter.isFeatureEnabled("baz");
11+
12+
expect(isEnabled).toBe(true);
13+
});
14+
15+
it("returns true when env var is set to TRUE", async () => {
16+
process.env.NEXT_PUBLIC_FEATURE_baz = "TRUE";
17+
18+
const isEnabled = await mockAdapter.isFeatureEnabled("baz");
19+
20+
expect(isEnabled).toBe(true);
21+
});
22+
23+
it("returns false when env var is set to false", async () => {
24+
process.env.NEXT_PUBLIC_FEATURE_baz = "false";
25+
26+
const isEnabled = await mockAdapter.isFeatureEnabled("baz");
27+
28+
expect(isEnabled).toBe(false);
29+
});
30+
31+
it("returns false when an env var is not set", async () => {
32+
delete process.env.NEXT_PUBLIC_FEATURE_baz;
33+
const isEnabled = await mockAdapter.isFeatureEnabled("baz");
34+
35+
expect(isEnabled).toBe(false);
36+
});
37+
});
38+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { FeatureFlagAdapter } from "./types";
2+
3+
/**
4+
* Mocks feature flag responses based on environment variables.
5+
* Only intended for local development or testing.
6+
*/
7+
export class MockAdapter implements FeatureFlagAdapter {
8+
async isFeatureEnabled(featureName: string, entityId?: string) {
9+
const envVarName = `NEXT_PUBLIC_FEATURE_${featureName}`;
10+
const isEnabled = process.env[envVarName]?.toLowerCase() === "true";
11+
12+
if (process.env.NODE_ENV !== "test") {
13+
console.warn("Using mock feature flag adapter", {
14+
envVarName,
15+
featureName,
16+
isEnabled,
17+
entityId,
18+
});
19+
}
20+
21+
return Promise.resolve(isEnabled);
22+
}
23+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { adapter } from "./setup";
2+
3+
/**
4+
* Check if a feature flag is enabled
5+
* @param featureName - Name of the flag
6+
* @param entityId - Optional id (e.g. user id) to use for phased rollouts
7+
*/
8+
export function isFeatureEnabled(featureName: string, entityId?: string) {
9+
return adapter.isFeatureEnabled(featureName, entityId);
10+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { EvidentlyAdapter } from "./EvidentlyAdapter";
2+
import { MockAdapter } from "./MockAdapter";
3+
import { FeatureFlagAdapter } from "./types";
4+
5+
export const adapter: FeatureFlagAdapter = process.env.FEATURE_FLAGS_PROJECT
6+
? new EvidentlyAdapter()
7+
: new MockAdapter();
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface FeatureFlagAdapter {
2+
isFeatureEnabled(featureName: string, entityId?: string): Promise<boolean>;
3+
}

app/src/app/[locale]/page.test.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
import { axe } from "jest-axe";
2-
import { LocalFeatureFlagManager } from "src/services/feature-flags/LocalFeatureFlagManager";
32
import { cleanup, render, screen } from "tests/react-utils";
3+
import { mockFeatureFlag } from "tests/server-utils";
44

55
import Controller from "./page";
66
import { View } from "./view";
77

88
describe("Index - Controller", () => {
9-
it("retrieves feature flags", async () => {
10-
const featureFlagSpy = jest
11-
.spyOn(LocalFeatureFlagManager.prototype, "isFeatureEnabled")
12-
.mockResolvedValue(true);
9+
describe("local feature flags", () => {
10+
it("renders correctly based on local feature flag is unset", async () => {
11+
const result = await Controller();
12+
render(result);
1313

14-
const result = await Controller();
15-
render(result);
14+
expect(await screen.findByText(/flag is disabled/i)).toBeInTheDocument();
15+
});
1616

17-
expect(featureFlagSpy).toHaveBeenCalledWith("foo", "anonymous");
18-
expect(screen.getByText(/Flag is enabled/)).toBeInTheDocument();
17+
it("renders correctly based on local feature flag is true", async () => {
18+
mockFeatureFlag("foo", true);
19+
20+
const result = await Controller();
21+
render(result);
22+
23+
expect(await screen.findByText(/flag is enabled/i)).toBeInTheDocument();
24+
});
25+
26+
it("renders correctly based on local feature flag is false", async () => {
27+
mockFeatureFlag("foo", false);
28+
29+
const result = await Controller();
30+
render(result);
31+
32+
expect(await screen.findByText(/flag is disabled/i)).toBeInTheDocument();
33+
});
1934
});
2035
});
2136

0 commit comments

Comments
 (0)