Skip to content

Commit cbc98d3

Browse files
authored
feat(core): Mock React Native (#2)
1 parent 53f26f4 commit cbc98d3

34 files changed

+1485
-576
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ node_modules/
1010
# Generated
1111
build/
1212
dist/
13+
*.tgz
1314

1415
# VSCode
1516
.vscode/

package.json

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
"engines": {
1717
"node": ">=18"
1818
},
19+
"files": [
20+
"dist/",
21+
"src/",
22+
"register.js"
23+
],
1924
"scripts": {
2025
"build": "tsc -p tsconfig.prod.json",
2126
"check": "yarn compile && yarn lint && yarn test",
@@ -25,28 +30,41 @@
2530
"test": "NODE_ENV=test mocha"
2631
},
2732
"packageManager": "yarn@4.0.2",
33+
"dependencies": {
34+
"@babel/core": "^7.23.9",
35+
"@babel/preset-react": "^7.23.3",
36+
"@babel/register": "^7.23.7",
37+
"@react-native/babel-preset": "^0.73.20",
38+
"babel-plugin-module-resolver": "^5.0.0",
39+
"dot-prop-immutable": "^2.1.1"
40+
},
2841
"devDependencies": {
2942
"@assertive-ts/core": "^2.0.0",
30-
"@types/eslint": "^8",
43+
"@testing-library/react-native": "^12.4.3",
44+
"@types/babel__core": "^7.20.5",
45+
"@types/babel__register": "^7.17.3",
46+
"@types/eslint": "^8.56.2",
3147
"@types/mocha": "^10.0.6",
32-
"@types/node": "^20.10.6",
33-
"@types/react": "^18",
34-
"@types/sinon": "^17.0.2",
35-
"@typescript-eslint/eslint-plugin": "^6.17.0",
36-
"@typescript-eslint/parser": "^6.17.0",
48+
"@types/node": "^20.11.7",
49+
"@types/react": "^18.2.48",
50+
"@types/react-test-renderer": "^18.0.7",
51+
"@types/sinon": "^17.0.3",
52+
"@typescript-eslint/eslint-plugin": "^6.19.1",
53+
"@typescript-eslint/parser": "^6.19.1",
3754
"eslint": "^8.56.0",
3855
"eslint-import-resolver-typescript": "^3.6.1",
3956
"eslint-plugin-etc": "^2.0.3",
4057
"eslint-plugin-import": "^2.29.1",
41-
"eslint-plugin-jsdoc": "^47.0.2",
58+
"eslint-plugin-jsdoc": "^48.0.4",
4259
"eslint-plugin-sonarjs": "^0.23.0",
4360
"mocha": "^10.2.0",
4461
"react": "18.2.0",
45-
"react-native": "^0.73.1",
62+
"react-native": "^0.73.2",
63+
"react-test-renderer": "^18.2.0",
4664
"sinon": "^17.0.1",
4765
"ts-node": "^10.9.2",
4866
"tslib": "^2.6.2",
49-
"typedoc": "^0.25.6",
67+
"typedoc": "^0.25.7",
5068
"typedoc-plugin-markdown": "^3.17.1",
5169
"typedoc-plugin-merge-modules": "^5.1.0",
5270
"typescript": "^5.3.3"

register.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const start = Date.now();
2+
require("./dist/main");
3+
4+
const end = Date.now();
5+
const diff = (end - start) / 1000;
6+
7+
// eslint-disable-next-line no-console
8+
console.info(`React Native testing mocks registered! (${diff}s)`);

src/helpers/commons.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/* eslint-disable @typescript-eslint/no-var-requires */
2+
import path from "path";
3+
4+
/**
5+
* A simple no-operation function
6+
*/
7+
export function noop(): void {
8+
// do nothing...
9+
}
10+
11+
/**
12+
* Replaces a module with a given `exports` value or another module path.
13+
*
14+
* @param modulePath the path to the module
15+
* @param other the exports to replace or another module path
16+
*/
17+
export function replace<T>(modulePath: string, other: T | string): void {
18+
const exports = typeof other === "string"
19+
? require(other) as T
20+
: other;
21+
const id = resolveId(modulePath);
22+
23+
require.cache[id] = {
24+
children: [],
25+
exports,
26+
filename: id,
27+
id,
28+
isPreloading: false,
29+
loaded: true,
30+
parent: require.main,
31+
path: path.dirname(id),
32+
paths: [],
33+
require,
34+
};
35+
}
36+
37+
function resolveId(modulePath: string): string {
38+
try {
39+
return require.resolve(modulePath);
40+
} catch (error) {
41+
const hastePath = require.resolve(`${modulePath}.ios`);
42+
return hastePath.slice(0, -4);
43+
}
44+
}

src/helpers/mockComponent.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { get } from "dot-prop-immutable";
2+
import {
3+
Component,
4+
ComponentClass,
5+
PropsWithChildren,
6+
ReactNode,
7+
createElement,
8+
} from "react";
9+
10+
export function mockComponent<P, C extends ComponentClass<PropsWithChildren<P>>>(
11+
RealComponent: C,
12+
instanceMethods?: object | null,
13+
): C {
14+
const SuperClass: ComponentClass<PropsWithChildren<P>> = typeof RealComponent === "function"
15+
? RealComponent
16+
: Component;
17+
const name = RealComponent.displayName
18+
|| RealComponent.name
19+
|| get(RealComponent, "render.displayName", "")
20+
|| get(RealComponent, "render.name", "")
21+
|| "Unknown";
22+
const nameWithoutPrefix = name.replace(/^(RCT|RK)/, "");
23+
24+
class Mock extends SuperClass {
25+
26+
public static displayName = "Component";
27+
28+
public render(): ReactNode {
29+
const props = { ...RealComponent.defaultProps };
30+
31+
if (this.props) {
32+
Object.keys(this.props).forEach(key => {
33+
// We can't just assign props on top of defaultProps
34+
// because React treats undefined as special and different from null.
35+
// If a prop is specified but set to undefined it is ignored and the
36+
// default prop is used instead. If it is set to null, then the
37+
// null value overwrites the default value.
38+
39+
const prop: unknown = get(this.props, key);
40+
41+
if (prop !== undefined) {
42+
Object.assign(props, { [key]: prop });
43+
}
44+
});
45+
}
46+
47+
return createElement(nameWithoutPrefix, props, this.props.children);
48+
}
49+
}
50+
51+
Mock.displayName = nameWithoutPrefix;
52+
53+
Object.keys(RealComponent).forEach(key => {
54+
const staticProp: unknown = get(RealComponent, key);
55+
56+
if (staticProp !== undefined) {
57+
Object.assign(Mock, { [key]: staticProp });
58+
}
59+
});
60+
61+
if (instanceMethods) {
62+
Object.assign(Component.prototype, instanceMethods);
63+
}
64+
65+
return Mock as unknown as C;
66+
}

src/helpers/mockModal.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { get } from "dot-prop-immutable";
2+
import { Component, ComponentClass, PropsWithChildren, ReactNode } from "react";
3+
4+
export function mockModal<P, C extends typeof Component<P>>(Base: C): C {
5+
const BaseComponent = Base as ComponentClass<PropsWithChildren<P>>;
6+
7+
class Mock extends BaseComponent {
8+
9+
public render(): ReactNode {
10+
if (get(this.props, "visible") === false) {
11+
return null;
12+
}
13+
14+
return (
15+
<BaseComponent {...this.props}>
16+
{this.props.children}
17+
</BaseComponent>
18+
);
19+
}
20+
}
21+
22+
return Mock as C;
23+
}

src/helpers/mockNativeComponent.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Component, ComponentClass, PropsWithChildren, ReactNode, createElement } from "react";
2+
3+
const native = { tag: 1 };
4+
5+
export function mockNativeComponent(viewName: string): ComponentClass {
6+
return class extends Component<PropsWithChildren<unknown>> {
7+
8+
public static displayName = viewName === "RCTView" ? "View" : viewName;
9+
10+
protected _nativeTag = native.tag++;
11+
12+
public constructor(props: PropsWithChildren<unknown>) {
13+
super(props);
14+
}
15+
16+
public render(): ReactNode {
17+
return createElement(viewName, this.props, this.props.children);
18+
}
19+
20+
public blur(): void { /* noop */ }
21+
public focus(): void { /* noop */ }
22+
public measure(): void { /* noop */ }
23+
public measureInWindow(): void { /* noop */ }
24+
public measureLayout(): void { /* noop */ }
25+
public setNativeProps(): void { /* noop */ }
26+
};
27+
}

src/helpers/mockNativeMethods.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { noop } from "./commons";
2+
3+
export const MockNativeMethods = {
4+
blur: noop,
5+
focus: noop,
6+
measure: noop,
7+
measureInWindow: noop,
8+
measureLayout: noop,
9+
setNativeProps: noop,
10+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/* eslint-disable sort-keys */
2+
import { noop } from "../../helpers/commons";
3+
4+
export const AccessibilityInfoMock = {
5+
addEventListener: noop,
6+
announceForAccessibility: noop,
7+
isAccessibilityServiceEnabled: noop,
8+
isBoldTextEnabled: noop,
9+
isGrayscaleEnabled: noop,
10+
isInvertColorsEnabled: noop,
11+
isReduceMotionEnabled: noop,
12+
prefersCrossFadeTransitions: noop,
13+
isReduceTransparencyEnabled: noop,
14+
isScreenReaderEnabled: (): Promise<boolean> => Promise.resolve(false),
15+
setAccessibilityFocus: noop,
16+
sendAccessibilityEvent: noop,
17+
getRecommendedTimeoutMillis: noop,
18+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ActivityIndicator } from "react-native";
2+
3+
import { mockComponent } from "../../helpers/mockComponent";
4+
5+
export const ActivityIndicatorMock = mockComponent(ActivityIndicator, null);

src/lib/Components/AppState.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NativeEventSubscription } from "react-native";
2+
3+
import { noop } from "../../helpers/commons";
4+
5+
export const AppStateMock = {
6+
addEventListener: (): NativeEventSubscription => ({ remove: noop }),
7+
currentState: noop,
8+
removeEventListener: noop,
9+
};

src/lib/Components/Clipboard.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { noop } from "../../helpers/commons";
2+
3+
export const ClipboardMock = {
4+
getString: (): string => "",
5+
setString: noop,
6+
};

src/lib/Components/Image.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ComponentClass } from "react";
2+
import { Image } from "react-native";
3+
4+
import { noop } from "../../helpers/commons";
5+
import { mockComponent } from "../../helpers/mockComponent";
6+
7+
const Mock = mockComponent(Image as ComponentClass);
8+
9+
export const ImageMock = Object.assign(Mock, {
10+
getSize: noop,
11+
getSizeWithHeaders: noop,
12+
prefetch: noop,
13+
prefetchWithMetadata: noop,
14+
queryCache: noop,
15+
resolveAssetSource: noop,
16+
});

src/lib/Components/Linking.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { noop } from "../../helpers/commons";
2+
3+
export const LinkingMock = {
4+
addEventListener: noop,
5+
canOpenURL: (): Promise<boolean> => Promise.resolve(true),
6+
getInitialURL: (): Promise<void> => Promise.resolve(),
7+
openSettings: noop,
8+
openURL: noop,
9+
sendIntent: noop,
10+
};

src/lib/Components/Modal.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Modal } from "react-native";
2+
3+
import { mockComponent } from "../../helpers/mockComponent";
4+
import { mockModal } from "../../helpers/mockModal";
5+
6+
const BaseMock = mockComponent(Modal);
7+
8+
export const ModalMock = mockModal(BaseMock);

src/lib/Components/RefreshControl.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Component, ReactNode, createElement } from "react";
2+
import { requireNativeComponent } from "react-native";
3+
4+
const RCTRefreshControl = requireNativeComponent("RCTRefreshControl");
5+
6+
export class RefreshControlMock extends Component {
7+
8+
public static latestRef?: RefreshControlMock;
9+
10+
public componentDidMount(): void {
11+
RefreshControlMock.latestRef = this;
12+
}
13+
14+
public render(): ReactNode {
15+
return createElement(RCTRefreshControl);
16+
}
17+
}

src/lib/Components/ScrollView.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/* eslint-disable sort-keys */
2+
import { PropsWithChildren, ReactNode, createElement } from "react";
3+
import { ScrollView, View, requireNativeComponent } from "react-native";
4+
5+
import { noop } from "../../helpers/commons";
6+
import { mockComponent } from "../../helpers/mockComponent";
7+
import { MockNativeMethods } from "../../helpers/mockNativeMethods";
8+
9+
const RCTScrollView = requireNativeComponent("RCTScrollView");
10+
const BaseMock = mockComponent(ScrollView, {
11+
...MockNativeMethods,
12+
getScrollResponder: noop,
13+
getScrollableNode: noop,
14+
getInnerViewNode: noop,
15+
getInnerViewRef: noop,
16+
getNativeScrollRef: noop,
17+
scrollTo: noop,
18+
scrollToEnd: noop,
19+
flashScrollIndicators: noop,
20+
scrollResponderZoomTo: noop,
21+
scrollResponderScrollNativeHandleToKeyboard: noop,
22+
});
23+
24+
export class ScrollViewMock<P> extends BaseMock {
25+
26+
public constructor(props: PropsWithChildren<P>) {
27+
super(props);
28+
}
29+
30+
public render(): ReactNode {
31+
return createElement(
32+
RCTScrollView,
33+
this.props as object,
34+
this.props.refreshControl,
35+
createElement(mockComponent(View), {}, this.props.children),
36+
);
37+
}
38+
}

src/lib/Components/Text.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Text } from "react-native";
2+
3+
import { mockComponent } from "../../helpers/mockComponent";
4+
import { MockNativeMethods } from "../../helpers/mockNativeMethods";
5+
6+
export const TextMock = mockComponent(Text, MockNativeMethods);

0 commit comments

Comments
 (0)