Skip to content

Commit

Permalink
feat(core): Mock React Native (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoseLion committed Jan 27, 2024
1 parent 53f26f4 commit cbc98d3
Show file tree
Hide file tree
Showing 34 changed files with 1,485 additions and 576 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ node_modules/
# Generated
build/
dist/
*.tgz

# VSCode
.vscode/
Expand Down
36 changes: 27 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
"engines": {
"node": ">=18"
},
"files": [
"dist/",
"src/",
"register.js"
],
"scripts": {
"build": "tsc -p tsconfig.prod.json",
"check": "yarn compile && yarn lint && yarn test",
Expand All @@ -25,28 +30,41 @@
"test": "NODE_ENV=test mocha"
},
"packageManager": "yarn@4.0.2",
"dependencies": {
"@babel/core": "^7.23.9",
"@babel/preset-react": "^7.23.3",
"@babel/register": "^7.23.7",
"@react-native/babel-preset": "^0.73.20",
"babel-plugin-module-resolver": "^5.0.0",
"dot-prop-immutable": "^2.1.1"
},
"devDependencies": {
"@assertive-ts/core": "^2.0.0",
"@types/eslint": "^8",
"@testing-library/react-native": "^12.4.3",
"@types/babel__core": "^7.20.5",
"@types/babel__register": "^7.17.3",
"@types/eslint": "^8.56.2",
"@types/mocha": "^10.0.6",
"@types/node": "^20.10.6",
"@types/react": "^18",
"@types/sinon": "^17.0.2",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@types/node": "^20.11.7",
"@types/react": "^18.2.48",
"@types/react-test-renderer": "^18.0.7",
"@types/sinon": "^17.0.3",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"eslint": "^8.56.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-etc": "^2.0.3",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsdoc": "^47.0.2",
"eslint-plugin-jsdoc": "^48.0.4",
"eslint-plugin-sonarjs": "^0.23.0",
"mocha": "^10.2.0",
"react": "18.2.0",
"react-native": "^0.73.1",
"react-native": "^0.73.2",
"react-test-renderer": "^18.2.0",
"sinon": "^17.0.1",
"ts-node": "^10.9.2",
"tslib": "^2.6.2",
"typedoc": "^0.25.6",
"typedoc": "^0.25.7",
"typedoc-plugin-markdown": "^3.17.1",
"typedoc-plugin-merge-modules": "^5.1.0",
"typescript": "^5.3.3"
Expand Down
8 changes: 8 additions & 0 deletions register.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const start = Date.now();
require("./dist/main");

const end = Date.now();
const diff = (end - start) / 1000;

// eslint-disable-next-line no-console
console.info(`React Native testing mocks registered! (${diff}s)`);
44 changes: 44 additions & 0 deletions src/helpers/commons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import path from "path";

/**
* A simple no-operation function
*/
export function noop(): void {
// do nothing...
}

/**
* Replaces a module with a given `exports` value or another module path.
*
* @param modulePath the path to the module
* @param other the exports to replace or another module path
*/
export function replace<T>(modulePath: string, other: T | string): void {
const exports = typeof other === "string"
? require(other) as T
: other;
const id = resolveId(modulePath);

require.cache[id] = {
children: [],
exports,
filename: id,
id,
isPreloading: false,
loaded: true,
parent: require.main,
path: path.dirname(id),
paths: [],
require,
};
}

function resolveId(modulePath: string): string {
try {
return require.resolve(modulePath);
} catch (error) {
const hastePath = require.resolve(`${modulePath}.ios`);
return hastePath.slice(0, -4);
}
}
66 changes: 66 additions & 0 deletions src/helpers/mockComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { get } from "dot-prop-immutable";
import {
Component,
ComponentClass,
PropsWithChildren,
ReactNode,
createElement,
} from "react";

export function mockComponent<P, C extends ComponentClass<PropsWithChildren<P>>>(
RealComponent: C,
instanceMethods?: object | null,
): C {
const SuperClass: ComponentClass<PropsWithChildren<P>> = typeof RealComponent === "function"
? RealComponent
: Component;
const name = RealComponent.displayName
|| RealComponent.name
|| get(RealComponent, "render.displayName", "")
|| get(RealComponent, "render.name", "")
|| "Unknown";
const nameWithoutPrefix = name.replace(/^(RCT|RK)/, "");

class Mock extends SuperClass {

public static displayName = "Component";

public render(): ReactNode {
const props = { ...RealComponent.defaultProps };

if (this.props) {
Object.keys(this.props).forEach(key => {
// We can't just assign props on top of defaultProps
// because React treats undefined as special and different from null.
// If a prop is specified but set to undefined it is ignored and the
// default prop is used instead. If it is set to null, then the
// null value overwrites the default value.

const prop: unknown = get(this.props, key);

if (prop !== undefined) {
Object.assign(props, { [key]: prop });
}
});
}

return createElement(nameWithoutPrefix, props, this.props.children);
}
}

Mock.displayName = nameWithoutPrefix;

Object.keys(RealComponent).forEach(key => {
const staticProp: unknown = get(RealComponent, key);

if (staticProp !== undefined) {
Object.assign(Mock, { [key]: staticProp });
}
});

if (instanceMethods) {
Object.assign(Component.prototype, instanceMethods);
}

return Mock as unknown as C;
}
23 changes: 23 additions & 0 deletions src/helpers/mockModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { get } from "dot-prop-immutable";
import { Component, ComponentClass, PropsWithChildren, ReactNode } from "react";

export function mockModal<P, C extends typeof Component<P>>(Base: C): C {
const BaseComponent = Base as ComponentClass<PropsWithChildren<P>>;

class Mock extends BaseComponent {

public render(): ReactNode {
if (get(this.props, "visible") === false) {
return null;
}

return (
<BaseComponent {...this.props}>
{this.props.children}
</BaseComponent>
);
}
}

return Mock as C;
}
27 changes: 27 additions & 0 deletions src/helpers/mockNativeComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Component, ComponentClass, PropsWithChildren, ReactNode, createElement } from "react";

const native = { tag: 1 };

export function mockNativeComponent(viewName: string): ComponentClass {
return class extends Component<PropsWithChildren<unknown>> {

public static displayName = viewName === "RCTView" ? "View" : viewName;

protected _nativeTag = native.tag++;

public constructor(props: PropsWithChildren<unknown>) {
super(props);
}

public render(): ReactNode {
return createElement(viewName, this.props, this.props.children);
}

public blur(): void { /* noop */ }
public focus(): void { /* noop */ }
public measure(): void { /* noop */ }
public measureInWindow(): void { /* noop */ }
public measureLayout(): void { /* noop */ }
public setNativeProps(): void { /* noop */ }
};
}
10 changes: 10 additions & 0 deletions src/helpers/mockNativeMethods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { noop } from "./commons";

export const MockNativeMethods = {
blur: noop,
focus: noop,
measure: noop,
measureInWindow: noop,
measureLayout: noop,
setNativeProps: noop,
};
18 changes: 18 additions & 0 deletions src/lib/Components/AccessibilityInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* eslint-disable sort-keys */
import { noop } from "../../helpers/commons";

export const AccessibilityInfoMock = {
addEventListener: noop,
announceForAccessibility: noop,
isAccessibilityServiceEnabled: noop,
isBoldTextEnabled: noop,
isGrayscaleEnabled: noop,
isInvertColorsEnabled: noop,
isReduceMotionEnabled: noop,
prefersCrossFadeTransitions: noop,
isReduceTransparencyEnabled: noop,
isScreenReaderEnabled: (): Promise<boolean> => Promise.resolve(false),
setAccessibilityFocus: noop,
sendAccessibilityEvent: noop,
getRecommendedTimeoutMillis: noop,
};
5 changes: 5 additions & 0 deletions src/lib/Components/ActivityIndicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ActivityIndicator } from "react-native";

import { mockComponent } from "../../helpers/mockComponent";

export const ActivityIndicatorMock = mockComponent(ActivityIndicator, null);
9 changes: 9 additions & 0 deletions src/lib/Components/AppState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NativeEventSubscription } from "react-native";

import { noop } from "../../helpers/commons";

export const AppStateMock = {
addEventListener: (): NativeEventSubscription => ({ remove: noop }),
currentState: noop,
removeEventListener: noop,
};
6 changes: 6 additions & 0 deletions src/lib/Components/Clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { noop } from "../../helpers/commons";

export const ClipboardMock = {
getString: (): string => "",
setString: noop,
};
16 changes: 16 additions & 0 deletions src/lib/Components/Image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ComponentClass } from "react";
import { Image } from "react-native";

import { noop } from "../../helpers/commons";
import { mockComponent } from "../../helpers/mockComponent";

const Mock = mockComponent(Image as ComponentClass);

export const ImageMock = Object.assign(Mock, {
getSize: noop,
getSizeWithHeaders: noop,
prefetch: noop,
prefetchWithMetadata: noop,
queryCache: noop,
resolveAssetSource: noop,
});
10 changes: 10 additions & 0 deletions src/lib/Components/Linking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { noop } from "../../helpers/commons";

export const LinkingMock = {
addEventListener: noop,
canOpenURL: (): Promise<boolean> => Promise.resolve(true),
getInitialURL: (): Promise<void> => Promise.resolve(),
openSettings: noop,
openURL: noop,
sendIntent: noop,
};
8 changes: 8 additions & 0 deletions src/lib/Components/Modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Modal } from "react-native";

import { mockComponent } from "../../helpers/mockComponent";
import { mockModal } from "../../helpers/mockModal";

const BaseMock = mockComponent(Modal);

export const ModalMock = mockModal(BaseMock);
17 changes: 17 additions & 0 deletions src/lib/Components/RefreshControl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Component, ReactNode, createElement } from "react";
import { requireNativeComponent } from "react-native";

const RCTRefreshControl = requireNativeComponent("RCTRefreshControl");

export class RefreshControlMock extends Component {

public static latestRef?: RefreshControlMock;

public componentDidMount(): void {
RefreshControlMock.latestRef = this;
}

public render(): ReactNode {
return createElement(RCTRefreshControl);
}
}
38 changes: 38 additions & 0 deletions src/lib/Components/ScrollView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/* eslint-disable sort-keys */
import { PropsWithChildren, ReactNode, createElement } from "react";
import { ScrollView, View, requireNativeComponent } from "react-native";

import { noop } from "../../helpers/commons";
import { mockComponent } from "../../helpers/mockComponent";
import { MockNativeMethods } from "../../helpers/mockNativeMethods";

const RCTScrollView = requireNativeComponent("RCTScrollView");
const BaseMock = mockComponent(ScrollView, {
...MockNativeMethods,
getScrollResponder: noop,
getScrollableNode: noop,
getInnerViewNode: noop,
getInnerViewRef: noop,
getNativeScrollRef: noop,
scrollTo: noop,
scrollToEnd: noop,
flashScrollIndicators: noop,
scrollResponderZoomTo: noop,
scrollResponderScrollNativeHandleToKeyboard: noop,
});

export class ScrollViewMock<P> extends BaseMock {

public constructor(props: PropsWithChildren<P>) {
super(props);
}

public render(): ReactNode {
return createElement(
RCTScrollView,
this.props as object,
this.props.refreshControl,
createElement(mockComponent(View), {}, this.props.children),
);
}
}
6 changes: 6 additions & 0 deletions src/lib/Components/Text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Text } from "react-native";

import { mockComponent } from "../../helpers/mockComponent";
import { MockNativeMethods } from "../../helpers/mockNativeMethods";

export const TextMock = mockComponent(Text, MockNativeMethods);
Loading

0 comments on commit cbc98d3

Please sign in to comment.