diff --git a/jest.config.js b/jest.config.js
index c9b229c3d7..58df77ebc6 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,7 +1,7 @@
module.exports = {
transform: { '^.+\\.ts?$': 'ts-jest' },
testMatch: ['**/__tests__/**/*test.ts?(x)'],
- testEnvironment: 'node',
+ testEnvironment: 'jsdom',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
collectCoverageFrom: [
'packages/sdk/server-node/src/**/*.ts',
diff --git a/package.json b/package.json
index 2778c9e9d2..eb764fee94 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"packages/store/node-server-sdk-dynamodb",
"packages/telemetry/node-server-sdk-otel",
"packages/tooling/jest",
+ "packages/tooling/jest/example/react-native-example",
"packages/sdk/browser",
"packages/sdk/browser/contract-tests/entity",
"packages/sdk/browser/contract-tests/adapter"
diff --git a/packages/tooling/jest/example/react-native-example/.gitignore b/packages/tooling/jest/example/react-native-example/.gitignore
new file mode 100644
index 0000000000..78c70b32ea
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/.gitignore
@@ -0,0 +1,38 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+
+# Native
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+# vscode
+.vscode
\ No newline at end of file
diff --git a/packages/tooling/jest/example/react-native-example/App.tsx b/packages/tooling/jest/example/react-native-example/App.tsx
new file mode 100644
index 0000000000..8521ffd2c6
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/App.tsx
@@ -0,0 +1,36 @@
+import { StyleSheet } from 'react-native';
+import {
+ AutoEnvAttributes,
+ LDProvider,
+ ReactNativeLDClient,
+ LDOptions,
+} from '@launchdarkly/react-native-client-sdk';
+import Welcome from './src/welcome';
+
+const options: LDOptions = {
+ debug: true,
+}
+//TODO Set MOBILE_KEY in .env file to a mobile key in your project/environment.
+const MOBILE_KEY = 'YOUR_MOBILE_KEY';
+const featureClient = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled, options);
+
+const userContext = { kind: 'user', key: '', anonymous: true };
+
+export default function App() {
+ featureClient.identify(userContext).catch((e: any) => console.log(e));
+
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#fff',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/packages/tooling/jest/example/react-native-example/app.json b/packages/tooling/jest/example/react-native-example/app.json
new file mode 100644
index 0000000000..c33b70a741
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/app.json
@@ -0,0 +1,29 @@
+{
+ "expo": {
+ "name": "react-native-jest-example",
+ "slug": "react-native-jest-example",
+ "version": "0.0.1",
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "light",
+ "splash": {
+ "image": "./assets/splash.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff"
+ },
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "com.anonymous.reactnativejestexample"
+ },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/adaptive-icon.png",
+ "backgroundColor": "#ffffff"
+ },
+ "package": "com.anonymous.reactnativejestexample"
+ },
+ "web": {
+ "favicon": "./assets/favicon.png"
+ }
+ }
+}
diff --git a/packages/tooling/jest/example/react-native-example/assets/adaptive-icon.png b/packages/tooling/jest/example/react-native-example/assets/adaptive-icon.png
new file mode 100644
index 0000000000..03d6f6b6c6
Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/adaptive-icon.png differ
diff --git a/packages/tooling/jest/example/react-native-example/assets/favicon.png b/packages/tooling/jest/example/react-native-example/assets/favicon.png
new file mode 100644
index 0000000000..e75f697b18
Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/favicon.png differ
diff --git a/packages/tooling/jest/example/react-native-example/assets/icon.png b/packages/tooling/jest/example/react-native-example/assets/icon.png
new file mode 100644
index 0000000000..a0b1526fc7
Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/icon.png differ
diff --git a/packages/tooling/jest/example/react-native-example/assets/splash.png b/packages/tooling/jest/example/react-native-example/assets/splash.png
new file mode 100644
index 0000000000..0e89705a94
Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/splash.png differ
diff --git a/packages/tooling/jest/example/react-native-example/babel.config.js b/packages/tooling/jest/example/react-native-example/babel.config.js
new file mode 100644
index 0000000000..28dcb83baa
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/babel.config.js
@@ -0,0 +1,7 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo', '@babel/preset-typescript'],
+
+ };
+};
diff --git a/packages/tooling/jest/example/react-native-example/index.js b/packages/tooling/jest/example/react-native-example/index.js
new file mode 100644
index 0000000000..202e3f47d8
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/index.js
@@ -0,0 +1,10 @@
+// We have to use a custom entrypoint for monorepo workspaces to work.
+// https://docs.expo.dev/guides/monorepos/#change-default-entrypoint
+import { registerRootComponent } from 'expo';
+
+import App from './App';
+
+// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
+// It also ensures that whether you load the app in Expo Go or in a native build,
+// the environment is set up appropriately
+registerRootComponent(App);
diff --git a/packages/tooling/jest/example/react-native-example/jest.config.js b/packages/tooling/jest/example/react-native-example/jest.config.js
new file mode 100644
index 0000000000..5fcf170e8f
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/jest.config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ preset: 'jest-expo',
+ setupFiles: ['@launchdarkly/jest/react-native'],
+};
diff --git a/packages/tooling/jest/example/react-native-example/metro.config.js b/packages/tooling/jest/example/react-native-example/metro.config.js
new file mode 100644
index 0000000000..8dd286e022
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/metro.config.js
@@ -0,0 +1,28 @@
+// We need to use a custom metro config for monorepo workspaces to work.
+// https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
+/**
+ * @type {import('expo/metro-config')}
+ */
+const { getDefaultConfig } = require('expo/metro-config');
+const path = require('path');
+
+// Find the project and workspace directories
+const projectRoot = __dirname;
+
+const findWorkspaceRoot = require('find-yarn-workspace-root');
+
+const workspaceRoot = findWorkspaceRoot(__dirname); // Absolute path or null
+
+const config = getDefaultConfig(projectRoot);
+
+// 1. Watch all files within the monorepo
+config.watchFolders = [workspaceRoot];
+// 2. Let Metro know where to resolve packages and in what order
+config.resolver.nodeModulesPaths = [
+ path.resolve(projectRoot, 'node_modules'),
+ path.resolve(workspaceRoot, 'node_modules'),
+];
+// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
+config.resolver.disableHierarchicalLookup = true;
+
+module.exports = config;
diff --git a/packages/tooling/jest/example/react-native-example/package.json b/packages/tooling/jest/example/react-native-example/package.json
new file mode 100644
index 0000000000..a2898a7d11
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "react-native-jest-example",
+ "version": "0.0.1",
+ "main": "index.js",
+ "scripts": {
+ "start": "expo start",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
+ "test": "jest",
+ "clean": "rm -rf node_modules && rm -rf package-lock.json && rm -rf yarn.lock"
+ },
+ "dependencies": {
+ "@launchdarkly/react-native-client-sdk": "^10.9.0",
+ "expo": "^51.0.26",
+ "expo-status-bar": "~1.12.1",
+ "find-yarn-workspace-root": "^2.0.0",
+ "react": "^18.2.0",
+ "react-native": "0.74.5"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.20.0",
+ "@babel/preset-env": "^7.25.4",
+ "@babel/preset-react": "^7.24.7",
+ "@babel/runtime": "^7.25.4",
+ "@launchdarkly/jest": "workspace:^",
+ "@react-native/babel-preset": "^0.75.2",
+ "@testing-library/react-native": "^12.6.1",
+ "@types/jest": "^29.5.12",
+ "@types/react": "~18.2.45",
+ "jest": "^29.7.0",
+ "jest-expo": "^51.0.4",
+ "react-test-renderer": "^18.2.0",
+ "typescript": "^5.1.3"
+ },
+ "packageManager": "yarn@3.4.1",
+ "installConfig": {
+ "hoistingLimits": "workspaces"
+ },
+ "private": true
+}
diff --git a/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx b/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx
new file mode 100644
index 0000000000..8bd9f4e21c
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx
@@ -0,0 +1,29 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { mockFlags, resetLDMocks } from '@launchdarkly/jest/react-native';
+import { screen, render } from '@testing-library/react-native';
+import { useLDClient } from '@launchdarkly/react-native-client-sdk';
+import Welcome from './welcome';
+
+describe('Welcome component test', () => {
+
+ afterEach(() => {
+ resetLDMocks();
+ });
+
+ test('mock boolean flag correctly', () => {
+ mockFlags({ 'my-boolean-flag': true });
+ render();
+ expect(screen.getByText('Flag value is true')).toBeTruthy();
+ });
+
+ test('mock ldClient correctly', () => {
+ const current = useLDClient();
+
+ current?.track('event');
+ expect(current.track).toHaveBeenCalledTimes(1);
+ });
+
+});
diff --git a/packages/tooling/jest/example/react-native-example/src/welcome.tsx b/packages/tooling/jest/example/react-native-example/src/welcome.tsx
new file mode 100644
index 0000000000..f167b11fcd
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/src/welcome.tsx
@@ -0,0 +1,25 @@
+import { StyleSheet, Text, View } from 'react-native';
+import { useLDClient } from '@launchdarkly/react-native-client-sdk';
+
+export default function Welcome() {
+
+ const ldClient = useLDClient();
+
+ const flagValue = ldClient.boolVariation('my-boolean-flag', false);
+
+ return (
+
+ Welcome to LaunchDarkly
+ Flag value is {`${flagValue}`}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#fff',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json b/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json
new file mode 100644
index 0000000000..9101efe40f
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "include": [
+ "/**/*.ts",
+ "/**/*.tsx",
+ "/*.js",
+ "/*.tsx"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/tooling/jest/example/react-native-example/tsconfig.json b/packages/tooling/jest/example/react-native-example/tsconfig.json
new file mode 100644
index 0000000000..bb0ef71b76
--- /dev/null
+++ b/packages/tooling/jest/example/react-native-example/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx"
+ }
+}
diff --git a/packages/tooling/jest/jest.config.json b/packages/tooling/jest/jest.config.json
index 6174807746..ddf54bb471 100644
--- a/packages/tooling/jest/jest.config.json
+++ b/packages/tooling/jest/jest.config.json
@@ -3,7 +3,7 @@
"testMatch": ["**/*.test.ts?(x)"],
"testPathIgnorePatterns": ["node_modules", "example", "dist"],
"modulePathIgnorePatterns": ["dist"],
- "testEnvironment": "node",
+ "testEnvironment": "jsdom",
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"],
"collectCoverageFrom": ["src/**/*.ts"]
}
diff --git a/packages/tooling/jest/package.json b/packages/tooling/jest/package.json
index 0885269428..1cb51dd224 100644
--- a/packages/tooling/jest/package.json
+++ b/packages/tooling/jest/package.json
@@ -55,9 +55,18 @@
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
+ "react-test-renderer": "^18.3.1",
"rimraf": "^5.0.1",
"ts-jest": "^29.1.0",
"typedoc": "0.25.0",
"typescript": "5.1.6"
+ },
+ "dependencies": {
+ "@launchdarkly/react-native-client-sdk": "~10.9.0",
+ "@testing-library/react-hooks": "^8.0.1",
+ "@testing-library/react-native": "^12.7.2",
+ "@types/lodash": "^4.17.7",
+ "launchdarkly-react-client-sdk": "^3.4.0",
+ "react": "^18.3.1"
}
}
diff --git a/packages/tooling/jest/src/react-native/index.test.ts b/packages/tooling/jest/src/react-native/index.test.ts
index 66cbbd8323..bb37ecd1ca 100644
--- a/packages/tooling/jest/src/react-native/index.test.ts
+++ b/packages/tooling/jest/src/react-native/index.test.ts
@@ -1,3 +1,72 @@
+import {
+ ldClientMock,
+ mockFlags,
+ mockLDProvider,
+ mockReactNativeLDClient,
+ mockUseLDClient,
+ resetLDMocks,
+} from '.';
+
describe('react-native', () => {
- test.todo('Add react-native tests');
+ afterEach(() => {
+ resetLDMocks();
+ });
+
+ test('reset LD Mocks', () => {
+ const current = mockUseLDClient();
+
+ current?.track('event');
+ expect(ldClientMock.track).toHaveBeenCalledTimes(1);
+
+ resetLDMocks();
+ expect(ldClientMock.track).toHaveBeenCalledTimes(0);
+ });
+
+ test('mock boolean flag correctly', () => {
+ mockFlags({ 'bool-flag': true });
+ expect(ldClientMock.boolVariation).toBeDefined();
+ });
+
+ test('mock number flag correctly', () => {
+ mockFlags({ 'number-flag': 42 });
+ expect(ldClientMock.numberVariation).toBeDefined();
+ });
+
+ test('mock string flag correctly', () => {
+ mockFlags({ 'string-flag': 'hello' });
+ expect(ldClientMock.stringVariation).toBeDefined();
+ });
+
+ test('mock json flag correctly', () => {
+ mockFlags({ 'json-flag': { key: 'value' } });
+ expect(ldClientMock.jsonVariation).toBeDefined();
+ });
+
+ test('mock LDProvider correctly', () => {
+ expect(mockLDProvider).toBeDefined();
+ });
+
+ test('mock ReactNativeLDClient correctly', () => {
+ expect(mockReactNativeLDClient).toBeDefined();
+ });
+
+ test('mock ldClient correctly', () => {
+ const current = mockUseLDClient();
+
+ current?.track('event');
+ expect(ldClientMock.track).toHaveBeenCalledTimes(1);
+ });
+
+ test('mock ldClient complete set of methods correctly', () => {
+ expect(ldClientMock.identify).toBeDefined();
+ expect(ldClientMock.allFlags.mock).toBeDefined();
+ expect(ldClientMock.close.mock).toBeDefined();
+ expect(ldClientMock.flush).toBeDefined();
+ expect(ldClientMock.getContext.mock).toBeDefined();
+ expect(ldClientMock.off.mock).toBeDefined();
+ expect(ldClientMock.on.mock).toBeDefined();
+ expect(ldClientMock.track.mock).toBeDefined();
+ expect(ldClientMock.variation.mock).toBeDefined();
+ expect(ldClientMock.variationDetail.mock).toBeDefined();
+ });
});
diff --git a/packages/tooling/jest/src/react-native/index.ts b/packages/tooling/jest/src/react-native/index.ts
index 07dd57aa0c..f735550808 100644
--- a/packages/tooling/jest/src/react-native/index.ts
+++ b/packages/tooling/jest/src/react-native/index.ts
@@ -1,3 +1,109 @@
-jest.mock('@launchdarkly/react-client-sdk', () => {
- // TODO:
-});
+import {
+ LDClient,
+ LDFlagSet,
+ LDProvider,
+ ReactNativeLDClient,
+ useBoolVariation,
+ useJsonVariation,
+ useLDClient,
+ useNumberVariation,
+ useStringVariation,
+} from '@launchdarkly/react-native-client-sdk';
+
+jest.mock('@launchdarkly/react-native-client-sdk', () => ({
+ LDFlagSet: jest.fn(() => ({})),
+ LDProvider: jest.fn().mockImplementation(({ children }) => children),
+ ReactNativeLDClient: jest.fn().mockImplementation(),
+ useLDClient: jest.fn().mockImplementation(),
+ useBoolVariation: jest.fn(),
+ useBoolVariationDetail: jest.fn(),
+ useNumberVariation: jest.fn(),
+ useNumberVariationDetail: jest.fn(),
+ useStringVariation: jest.fn(),
+ useStringVariationDetail: jest.fn(),
+ useJsonVariation: jest.fn(),
+ useJsonVariationDetail: jest.fn(),
+ useTypedVariation: jest.fn(),
+ useTypedVariationDetail: jest.fn(),
+}));
+
+export const ldClientMock: jest.Mocked = {
+ allFlags: jest.fn(),
+ boolVariation: jest.fn(),
+ boolVariationDetail: jest.fn(),
+ close: jest.fn(),
+ flush: jest.fn(() => Promise.resolve({ result: true })),
+ // getConnectionMode: jest.fn(),
+ getContext: jest.fn(),
+ identify: jest.fn().mockResolvedValue(undefined),
+ jsonVariation: jest.fn(),
+ jsonVariationDetail: jest.fn(),
+ logger: {
+ error: jest.fn(),
+ warn: jest.fn(),
+ info: jest.fn(),
+ debug: jest.fn(),
+ },
+ numberVariation: jest.fn(),
+ numberVariationDetail: jest.fn(),
+ off: jest.fn(),
+ on: jest.fn(),
+ // setConnectionMode: jest.fn(),
+ stringVariation: jest.fn(),
+ stringVariationDetail: jest.fn(),
+ track: jest.fn(),
+ variation: jest.fn(),
+ variationDetail: jest.fn(),
+ addHook: jest.fn(),
+};
+
+export const mockLDProvider = LDProvider as jest.Mock;
+export const mockReactNativeLDClient = ReactNativeLDClient as jest.Mock;
+export const mockUseLDClient = useLDClient as jest.Mock;
+
+const mockUseBoolVariation = useBoolVariation as jest.Mock;
+const mockUseNumberVariation = useNumberVariation as jest.Mock;
+const mockUseStringVariation = useStringVariation as jest.Mock;
+const mockUseJsonVariation = useJsonVariation as jest.Mock;
+
+mockLDProvider.mockImplementation(({ children }) => children);
+mockReactNativeLDClient.mockImplementation(() => ldClientMock);
+mockUseLDClient.mockImplementation(() => ldClientMock);
+
+export const mockFlags = (flags: LDFlagSet): any => {
+ Object.keys(flags).forEach((key) => {
+ const defaultValue = flags[key];
+ switch (typeof defaultValue) {
+ case 'boolean':
+ mockUseBoolVariation.mockImplementation((flagKey: string) => flags[flagKey] as boolean);
+ ldClientMock.boolVariation.mockImplementation(
+ (flagKey: string) => flags[flagKey] as boolean,
+ );
+ break;
+ case 'number':
+ mockUseNumberVariation.mockImplementation((flagKey: string) => flags[flagKey] as number);
+ ldClientMock.numberVariation.mockImplementation(
+ (flagKey: string) => flags[flagKey] as number,
+ );
+ break;
+ case 'string':
+ mockUseStringVariation.mockImplementation((flagKey: string) => flags[flagKey] as string);
+ ldClientMock.stringVariation.mockImplementation(
+ (flagKey: string) => flags[flagKey] as string,
+ );
+ break;
+ case 'object':
+ mockUseJsonVariation.mockImplementation((flagKey: string) => flags[flagKey] as object);
+ ldClientMock.jsonVariation.mockImplementation(
+ (flagKey: string) => flags[flagKey] as object,
+ );
+ break;
+ default:
+ break;
+ }
+ });
+};
+
+export const resetLDMocks = () => {
+ jest.clearAllMocks();
+};
diff --git a/packages/tooling/jest/src/react/index.ts b/packages/tooling/jest/src/react/index.ts
deleted file mode 100644
index 728a12ae45..0000000000
--- a/packages/tooling/jest/src/react/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-jest.mock('@launchdarkly/react-native-client-sdk', () => {
- // TODO:
-});
diff --git a/packages/tooling/jest/tsconfig.eslint.json b/packages/tooling/jest/tsconfig.eslint.json
index 56c9b38305..c46cca0b54 100644
--- a/packages/tooling/jest/tsconfig.eslint.json
+++ b/packages/tooling/jest/tsconfig.eslint.json
@@ -1,5 +1,5 @@
{
"extends": "./tsconfig.json",
- "include": ["/**/*.ts"],
+ "include": ["/**/*.ts", "/**/*.tsx", "/**/*.js"],
"exclude": ["node_modules"]
}