From 45b68218b2eaff17a2e58831939fc1d387eb16fe Mon Sep 17 00:00:00 2001 From: Jose Luis Leon Date: Sat, 6 Jul 2024 17:42:07 -0500 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat(support):=20Vitest=20plugin=20(#2?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(support): Vitest plugin * Document usage with Vitest * Fix local tests env and plugin * Fix native mocks on Vitest * Add build to CI workflow --- .github/workflows/ci.yml | 1 + README.md | 27 +++++++++-- package.json | 14 ++++++ register.d.ts | 1 + src/helpers/commons.ts | 3 ++ src/helpers/mockComponent.ts | 3 +- src/helpers/nativeMethodsMock.ts | 79 +++++++++++++++++++++++++++++++- src/lib/Components/Image.ts | 18 +------- src/lib/Components/ScrollView.ts | 57 ++--------------------- src/lib/Components/TextInput.ts | 19 +------- src/lib/babelRegister.ts | 2 +- src/lib/mockNative.ts | 19 +++++--- src/main.ts | 1 + src/register.ts | 4 +- src/vitest/env.ts | 15 ++++++ src/vitest/plugin.ts | 54 ++++++++++++++++++++++ test/env.ts | 2 + test/plugin.ts | 24 ++++++++++ vite.config.ts | 3 ++ vitest.config.ts | 13 ++---- vitest.d.ts | 1 + 21 files changed, 247 insertions(+), 113 deletions(-) create mode 100644 register.d.ts create mode 100644 src/vitest/env.ts create mode 100644 src/vitest/plugin.ts create mode 100644 test/plugin.ts create mode 100644 vitest.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38f9153..2c1b997 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: node-version: ${{ matrix.nodejs }} cache: yarn - run: yarn install --immutable + - run: yarn build - run: yarn compile - run: yarn lint - run: yarn test diff --git a/README.md b/README.md index 58f1649..1478938 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,28 @@ import "react-native-testing-mocks/register"; // ...rest of your setup code ``` -### Mocha.js Example +### With Vitest -Some frameworks also provide mechanisms to load setup modules. In Mocha.js, you can use the `--require` CLI option: +Ideally, Vitest should be able to replace Babel when it comes to resolving and transforming the React Native code. However, so much more goes on in [@react-native/babel-preset](https://www.npmjs.com/package/@react-native/babel-preset) that replacing this module with Vite is not only a hard task but also increases the risk of something going wrong. So, until React Native delivers their code in a more conventional format (CommonJS/ESM), is my opinion that it's safer to keep using Babel to transform React Native's code with Vitest. + +That being said, this package also provides a Vite Plugin you can add to your `vitest.config.ts` configuration file: + +```ts +import { reactNativePlugin } from "react-native-testing-mocks/vitest"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [reactNativePlugin()], + test: { + include: ["test/**/*.test.ts?(x)"], + setupFiles: "./test/setup.ts", + }, +}); +``` + +### With Mocha + +Some frameworks like Mocha provide a mechanism to require modules during the Node.js execution. For instance, you can use the `--require` CLI option: ```bash mocha --require react-native-testing-mocks/register @@ -91,9 +110,9 @@ Or you can add it to the `.mocharc.json` file: "require": [ "ts-node/register", "react-native-testing-mocks/register", - "test/hooks.ts" + "./test/setup.ts" ], - "spec": ["test/**/*.test.*"] + "spec": ["test/**/*.test.ts?(x)"] } ``` diff --git a/package.json b/package.json index f5cedb4..e789df9 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,25 @@ "types": "./dist/register.d.ts", "default": "./dist/register.js" }, + "./vitest": { + "import": "./dist/vitest.js", + "require": "./dist/vitest.cjs", + "types": "./dist/vitest/plugin.d.ts", + "default": "./dist/vitest.js" + }, + "./vitest/env": { + "import": "./dist/vitestEnv.js", + "require": "./dist/vitestEnv.cjs", + "types": "./dist/vitest/env.d.ts", + "default": "./dist/vitestEnv.js" + }, "./package.json": "./package.json" }, "files": [ "./dist", "./src/", + "./register.d.ts", + "./vitest.d.ts", "./package.json" ], "engines": { diff --git a/register.d.ts b/register.d.ts new file mode 100644 index 0000000..842f17f --- /dev/null +++ b/register.d.ts @@ -0,0 +1 @@ +export * from "./dist/register"; diff --git a/src/helpers/commons.ts b/src/helpers/commons.ts index 5c210ef..bbbb657 100644 --- a/src/helpers/commons.ts +++ b/src/helpers/commons.ts @@ -1,7 +1,10 @@ +import { createRequire } from "module"; import path from "path"; type ExportsLike = object | { default?: unknown; }; +const require = createRequire(import.meta.url); + /** * A simple no-operation function */ diff --git a/src/helpers/mockComponent.ts b/src/helpers/mockComponent.ts index cf2a15f..91ce7ee 100644 --- a/src/helpers/mockComponent.ts +++ b/src/helpers/mockComponent.ts @@ -7,8 +7,7 @@ import { createElement, } from "react"; -import type { ScrollViewMethods } from "../lib/Components/ScrollView"; -import type { TextInputMethods } from "../lib/Components/TextInput"; +import type { ScrollViewMethods, TextInputMethods } from "./nativeMethodsMock"; import type { NativeMethods } from "react-native"; export type AllNativeMethods = NativeMethods | ScrollViewMethods | TextInputMethods; diff --git a/src/helpers/nativeMethodsMock.ts b/src/helpers/nativeMethodsMock.ts index 815903c..4d89fd2 100644 --- a/src/helpers/nativeMethodsMock.ts +++ b/src/helpers/nativeMethodsMock.ts @@ -1,6 +1,18 @@ import { noop } from "./commons"; -import type { NativeMethods } from "react-native"; +import type { ElementRef } from "react"; +import type { HostComponent, Image, NativeMethods, ScrollView, TextInput, View } from "react-native"; + +export type ImageMethods = Partial; + +export type ScrollViewMethods = NativeMethods | ScrollView & { + getInnerViewRef: () => ElementRef | null; + getNativeScrollRef: () => ElementRef> | null; +}; + +export type TextInputMethods = NativeMethods | TextInput & { + getNativeRef: () => ElementRef> | undefined; +}; export const nativeMethodsMock: NativeMethods = { blur: noop, @@ -11,3 +23,68 @@ export const nativeMethodsMock: NativeMethods = { refs: { }, setNativeProps: noop, }; + +export const imageMethodsMock: ImageMethods = { + getSize: noop, + getSizeWithHeaders: noop, + prefetch: () => Promise.resolve(false), + prefetchWithMetadata: () => Promise.resolve(false), + queryCache: () => Promise.resolve({ }), + resolveAssetSource: () => ({ + height: 0, + scale: 0, + uri: "", + width: 0, + }), +}; + +export const scrollViewMethodsMock: ScrollViewMethods = { + ...nativeMethodsMock, + flashScrollIndicators: noop, + getInnerViewNode: noop, + getInnerViewRef: () => null, + getNativeScrollRef: () => null, + getScrollResponder: () => ({ + addListenerOn: noop, + componentWillMount: noop, + scrollResponderGetScrollableNode: noop, + scrollResponderHandleMomentumScrollBegin: noop, + scrollResponderHandleMomentumScrollEnd: noop, + scrollResponderHandleResponderGrant: noop, + scrollResponderHandleResponderReject: noop, + scrollResponderHandleResponderRelease: noop, + scrollResponderHandleScroll: noop, + scrollResponderHandleScrollBeginDrag: noop, + scrollResponderHandleScrollEndDrag: noop, + scrollResponderHandleScrollShouldSetResponder: () => false, + scrollResponderHandleStartShouldSetResponder: () => false, + scrollResponderHandleStartShouldSetResponderCapture: () => false, + scrollResponderHandleTerminationRequest: () => false, + scrollResponderHandleTouchEnd: noop, + scrollResponderHandleTouchMove: noop, + scrollResponderHandleTouchStart: noop, + scrollResponderInputMeasureAndScrollToKeyboard: noop, + scrollResponderIsAnimating: () => false, + scrollResponderKeyboardDidHide: noop, + scrollResponderKeyboardDidShow: noop, + scrollResponderKeyboardWillHide: noop, + scrollResponderKeyboardWillShow: noop, + scrollResponderScrollNativeHandleToKeyboard: noop, + scrollResponderScrollTo: noop, + scrollResponderTextInputFocusError: noop, + scrollResponderZoomTo: noop, + }), + getScrollableNode: noop, + scrollResponderScrollNativeHandleToKeyboard: noop, + scrollResponderZoomTo: noop, + scrollTo: noop, + scrollToEnd: noop, +}; + +export const textInputMethodsMock: TextInputMethods = { + ...nativeMethodsMock, + clear: noop, + getNativeRef: () => undefined, + isFocused: () => false, + setSelection: noop, +}; diff --git a/src/lib/Components/Image.ts b/src/lib/Components/Image.ts index b9fd3a5..23e52ac 100644 --- a/src/lib/Components/Image.ts +++ b/src/lib/Components/Image.ts @@ -1,26 +1,10 @@ import { Image } from "react-native"; -import { noop } from "../../helpers/commons"; import { mockComponent } from "../../helpers/mockComponent"; +import { imageMethodsMock } from "../../helpers/nativeMethodsMock"; import type { ComponentClass } from "react"; -export type ImageMethods = Partial; - -export const imageMethodsMock: ImageMethods = { - getSize: noop, - getSizeWithHeaders: noop, - prefetch: () => Promise.resolve(false), - prefetchWithMetadata: () => Promise.resolve(false), - queryCache: () => Promise.resolve({ }), - resolveAssetSource: () => ({ - height: 0, - scale: 0, - uri: "", - width: 0, - }), -}; - const Mock = mockComponent(Image as ComponentClass); export const ImageMock = Object.assign(Mock, imageMethodsMock); diff --git a/src/lib/Components/ScrollView.ts b/src/lib/Components/ScrollView.ts index ed67871..9edc966 100644 --- a/src/lib/Components/ScrollView.ts +++ b/src/lib/Components/ScrollView.ts @@ -1,59 +1,8 @@ -/* eslint-disable sort-keys */ -import { type ElementRef, type PropsWithChildren, type ReactNode, createElement } from "react"; -import { type HostComponent, type NativeMethods, ScrollView, View, requireNativeComponent } from "react-native"; +import { type PropsWithChildren, type ReactNode, createElement } from "react"; +import { ScrollView, View, requireNativeComponent } from "react-native"; -import { noop } from "../../helpers/commons"; import { mockComponent } from "../../helpers/mockComponent"; -import { nativeMethodsMock } from "../../helpers/nativeMethodsMock"; - -export type ScrollViewMethods = NativeMethods | ScrollView & { - getInnerViewRef: () => ElementRef | null; - getNativeScrollRef: () => ElementRef> | null; -}; - -export const scrollViewMethodsMock: ScrollViewMethods = { - ...nativeMethodsMock, - getScrollResponder: () => ({ - addListenerOn: noop, - componentWillMount: noop, - scrollResponderGetScrollableNode: noop, - scrollResponderHandleMomentumScrollBegin: noop, - scrollResponderHandleMomentumScrollEnd: noop, - scrollResponderHandleResponderGrant: noop, - scrollResponderHandleResponderReject: noop, - scrollResponderHandleResponderRelease: noop, - scrollResponderHandleScroll: noop, - scrollResponderHandleScrollBeginDrag: noop, - scrollResponderHandleScrollEndDrag: noop, - scrollResponderHandleScrollShouldSetResponder: () => false, - scrollResponderHandleStartShouldSetResponder: () => false, - scrollResponderHandleStartShouldSetResponderCapture: () => false, - scrollResponderHandleTerminationRequest: () => false, - scrollResponderHandleTouchEnd: noop, - scrollResponderHandleTouchMove: noop, - scrollResponderHandleTouchStart: noop, - scrollResponderInputMeasureAndScrollToKeyboard: noop, - scrollResponderIsAnimating: () => false, - scrollResponderKeyboardDidHide: noop, - scrollResponderKeyboardDidShow: noop, - scrollResponderKeyboardWillHide: noop, - scrollResponderKeyboardWillShow: noop, - scrollResponderScrollNativeHandleToKeyboard: noop, - scrollResponderScrollTo: noop, - scrollResponderTextInputFocusError: noop, - scrollResponderZoomTo: noop, - }), - getScrollableNode: noop, - getInnerViewNode: noop, - getInnerViewRef: () => null, - getNativeScrollRef: () => null, - scrollTo: noop, - scrollToEnd: noop, - flashScrollIndicators: noop, - scrollResponderZoomTo: noop, - scrollResponderScrollNativeHandleToKeyboard: noop, - -}; +import { scrollViewMethodsMock } from "../../helpers/nativeMethodsMock"; const RCTScrollView = requireNativeComponent("RCTScrollView"); const BaseMock = mockComponent(ScrollView, scrollViewMethodsMock); diff --git a/src/lib/Components/TextInput.ts b/src/lib/Components/TextInput.ts index 57c5591..fe36d2f 100644 --- a/src/lib/Components/TextInput.ts +++ b/src/lib/Components/TextInput.ts @@ -1,21 +1,6 @@ -import { type HostComponent, type NativeMethods, TextInput } from "react-native"; +import { TextInput } from "react-native"; -import { noop } from "../../helpers/commons"; import { mockComponent } from "../../helpers/mockComponent"; -import { nativeMethodsMock } from "../../helpers/nativeMethodsMock"; - -import type { ElementRef } from "react"; - -export type TextInputMethods = NativeMethods | TextInput & { - getNativeRef: () => ElementRef> | undefined; -}; - -export const textInputMethodsMock: TextInputMethods = { - ...nativeMethodsMock, - clear: noop, - getNativeRef: () => undefined, - isFocused: () => false, - setSelection: noop, -}; +import { textInputMethodsMock } from "../../helpers/nativeMethodsMock"; export const TextInputMock = mockComponent(TextInput, textInputMethodsMock); diff --git a/src/lib/babelRegister.ts b/src/lib/babelRegister.ts index 3ce4e7e..e5b0e8e 100644 --- a/src/lib/babelRegister.ts +++ b/src/lib/babelRegister.ts @@ -2,7 +2,7 @@ import register from "@babel/register"; register({ cache: true, - only: [/node_modules[/\\](react-native|@react-native)[/\\]/], + only: [/[/\\]node_modules[/\\](react-native|@react-native)[/\\]/], plugins: [ ["extension-resolver", { extensions: [ diff --git a/src/lib/mockNative.ts b/src/lib/mockNative.ts index eee7d67..546abe5 100644 --- a/src/lib/mockNative.ts +++ b/src/lib/mockNative.ts @@ -1,13 +1,18 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ +import { createRequire } from "module"; + import { match } from "ts-pattern"; import { replace } from "../helpers/commons"; import { type AllNativeMethods, mockComponent } from "../helpers/mockComponent"; -import { nativeMethodsMock } from "../helpers/nativeMethodsMock"; - -import { type ImageMethods, imageMethodsMock } from "./Components/Image"; -import { type ScrollViewMethods, scrollViewMethodsMock } from "./Components/ScrollView"; -import { type TextInputMethods, textInputMethodsMock } from "./Components/TextInput"; +import { + type ImageMethods, + type ScrollViewMethods, + type TextInputMethods, + imageMethodsMock, + nativeMethodsMock, + scrollViewMethodsMock, + textInputMethodsMock, +} from "../helpers/nativeMethodsMock"; import type { ComponentClass, PropsWithChildren } from "react"; import type { NativeMethods } from "react-native"; @@ -23,6 +28,8 @@ export type NativeKey = NativeBase | "ScrollView" | "TextInput"; +const require = createRequire(import.meta.url); + const MOCKS: Set = new Set(); const PATHS: Record = { diff --git a/src/main.ts b/src/main.ts index d1110ee..07d0409 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,2 +1,3 @@ export { mockNative, restoreNativeMocks } from "./lib/mockNative"; + export type { NativeBase, NativeKey } from "./lib/mockNative"; diff --git a/src/register.ts b/src/register.ts index f2f6947..6122701 100644 --- a/src/register.ts +++ b/src/register.ts @@ -3,8 +3,10 @@ import { createRequire } from "module"; import pino from "pino"; import pinoPretty from "pino-pretty"; +import { name } from "../package.json"; + const start = Date.now(); -const logger = pino(pinoPretty({ colorize: true })); +const logger = pino({ name }, pinoPretty({ colorize: true })); const require = createRequire(import.meta.url); require("./load.cjs"); diff --git a/src/vitest/env.ts b/src/vitest/env.ts new file mode 100644 index 0000000..ca40bc4 --- /dev/null +++ b/src/vitest/env.ts @@ -0,0 +1,15 @@ +import { name } from "../../package.json"; + +import type { Environment } from "vitest"; + +const reactNativeEnv: Environment = { + name: "react-native", + setup: async () => { + await import(`${name}/register`); + + return { teardown: () => undefined }; + }, + transformMode: "ssr", +}; + +export default reactNativeEnv; diff --git a/src/vitest/plugin.ts b/src/vitest/plugin.ts new file mode 100644 index 0000000..448ca42 --- /dev/null +++ b/src/vitest/plugin.ts @@ -0,0 +1,54 @@ +import { createRequire } from "module"; +import path from "path"; + +import { name } from "../../package.json"; + +import type { Plugin } from "vite"; + +interface ReactNativeVitestPluginOptions { + /** + * External dependencies that should not be processed by Vite in addition to + * the following: + * + * ``` + * ["react-native", "@react-native"] + * ``` + * + * @default [] + */ + external?: Array; +} + +/** + * Creates a plugin that allows testing React Native code on Vitest. + * + * @param options Plugin configuration object. + * @returns A Vitest plugin for React Native. + */ +export function reactNativeVitestPlugin(options: ReactNativeVitestPluginOptions = { }): Plugin { + const { external = [] } = options; + const require = createRequire(import.meta.url); + const envPath = require.resolve(`${name}/vitest/env`); + + return { + config: { + handler: () => ({ + test: { + environment: `./${path.relative("./", envPath)}`, + globals: true, + server: { + deps: { + external: [ + "react-native", + "@react-native", + ...external, + ], + }, + }, + }, + }), + }, + enforce: "pre", + name: "react-native-vite-plugin", + }; +} diff --git a/test/env.ts b/test/env.ts index 1fe5107..206c2fb 100644 --- a/test/env.ts +++ b/test/env.ts @@ -3,7 +3,9 @@ import type { Environment } from "vitest"; const reactNativeEnv: Environment = { name: "react-native", setup: async () => { + await import("../src/lib/babelRegister"); await import("../src/load"); + return { teardown: () => undefined }; }, transformMode: "ssr", diff --git a/test/plugin.ts b/test/plugin.ts new file mode 100644 index 0000000..8c24439 --- /dev/null +++ b/test/plugin.ts @@ -0,0 +1,24 @@ +import type { Plugin } from "vite"; + +export function reactNativeVitestPlugin(): Plugin { + return { + config: { + handler: () => ({ + test: { + environment: "./test/env.ts", + globals: true, + server: { + deps: { + external: [ + "react-native", + "@react-native", + ], + }, + }, + }, + }), + }, + enforce: "pre", + name: "react-native-vite-plugin", + }; +} diff --git a/vite.config.ts b/vite.config.ts index 01e2dcc..e776f5a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ load: "./src/load.ts", main: "./src/main.ts", register: "./src/register.ts", + vitest: "./src/vitest/plugin.ts", + vitestEnv: "./src/vitest/env.ts", }, fileName: (_, entry) => entry, formats: ["cjs", "es"], @@ -25,6 +27,7 @@ export default defineConfig({ compilerOptions: { emitDeclarationOnly: true, incremental: false, + rootDir: "./src", }, include: ["src/**", "typings/**"], }), diff --git a/vitest.config.ts b/vitest.config.ts index d63e453..31dcb18 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,18 +1,11 @@ import { defineConfig } from "vitest/config"; +import { reactNativeVitestPlugin } from "./test/plugin"; + export default defineConfig({ + plugins: [reactNativeVitestPlugin()], test: { - environment: "./test/env.ts", - globals: true, include: ["test/**/*.test.ts?(x)"], - server: { - deps: { - external: [ - "react-native", - "@react-native", - ], - }, - }, setupFiles: "./test/setup.ts", }, }); diff --git a/vitest.d.ts b/vitest.d.ts new file mode 100644 index 0000000..1a475f9 --- /dev/null +++ b/vitest.d.ts @@ -0,0 +1 @@ +export * from "./dist/vitest/plugin";