diff --git a/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt b/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt index fd5200b18c..998caca3b1 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt @@ -86,6 +86,7 @@ object DetoxMain { associateActionHandler("isReady", readyHandler) associateActionHandler("reactNativeReload", rnReloadHandler) associateActionHandler("invoke", InvokeActionHandler(MethodInvocation(), serverAdapter)) + associateActionHandler("backdoor", BackdoorActionHandler(rnHostHolder, serverAdapter, testEngineFacade)) associateActionHandler("cleanup", CleanupActionHandler(serverAdapter, testEngineFacade) { dispatchAction(TERMINATION_ACTION, "", 0) }) diff --git a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt index 1404f63abf..3d7d64bbf9 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt @@ -6,6 +6,7 @@ import com.wix.detox.TestEngineFacade import com.wix.detox.common.extractRootCause import com.wix.detox.instruments.DetoxInstrumentsException import com.wix.detox.instruments.DetoxInstrumentsManager +import com.wix.detox.reactnative.ReactNativeExtension import com.wix.invoke.MethodInvocation import org.json.JSONObject import java.lang.reflect.InvocationTargetException @@ -40,6 +41,17 @@ open class ReactNativeReloadActionHandler( } } +class BackdoorActionHandler( + private val appContext: Context, + private val outboundServerAdapter: OutboundServerAdapter, + private val testEngineFacade: TestEngineFacade) + : DetoxActionHandler { + + override fun handle(params: String, messageId: Long) { + ReactNativeExtension.emitBackdoorEvent(appContext, params) + outboundServerAdapter.sendMessage("backdoorDone", emptyMap(), messageId) + } +} class InvokeActionHandler @JvmOverloads constructor( private val methodInvocation: MethodInvocation, diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeExtension.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeExtension.kt index 1711eea37c..88ce785313 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeExtension.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeExtension.kt @@ -6,8 +6,13 @@ import android.util.Log import androidx.test.platform.app.InstrumentationRegistry import com.facebook.react.ReactApplication import com.facebook.react.ReactInstanceManager +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactContext +import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter import com.wix.detox.LaunchArgs +import com.wix.detox.common.JsonConverter +import org.json.JSONObject + private const val LOG_TAG = "DetoxRNExt" @@ -119,6 +124,19 @@ object ReactNativeExtension { } } + @JvmStatic + fun emitBackdoorEvent(applicationContext: Context, params: String) { + if (!ReactNativeInfo.isReactNativeApp()) { + return + } + + val bundle = JsonConverter(JSONObject(params)).toBundle() + val payload = Arguments.fromBundle(bundle) + + val reactContext = getCurrentReactContextSafe(applicationContext as ReactApplication) ?: return + reactContext.getJSModule(RCTDeviceEventEmitter::class.java).emit("detoxBackdoor", payload) + } + private fun reloadReactNativeInBackground(reactApplication: ReactApplication) { val rnReloader = ReactNativeReLoader(InstrumentationRegistry.getInstrumentation(), reactApplication) rnReloader.reloadInBackground() diff --git a/detox/detox.d.ts b/detox/detox.d.ts index 978fcf0dbe..31bc634637 100644 --- a/detox/detox.d.ts +++ b/detox/detox.d.ts @@ -885,6 +885,14 @@ declare global { */ takeScreenshot(name: string): Promise; + /** + * Sends a backdoor message to the app being tested. + * The message should be an object with `action` property and any other custom data. + * For more information, see {@link https://wix.github.io/Detox/docs/guide/mocking} + * and {@link https://wix.github.io/Detox/docs/api/device#devicebackdoormessage} + */ + backdoor(message: { action: string; [key: string]: any }): Promise; + /** * (iOS only) Saves a view hierarchy snapshot (*.viewhierarchy) of the currently opened application * to a temporary folder and schedules putting it to the artifacts folder upon the completion of diff --git a/detox/ios/Detox/DetoxManager.swift b/detox/ios/Detox/DetoxManager.swift index 805ee35bd0..653551194d 100644 --- a/detox/ios/Detox/DetoxManager.swift +++ b/detox/ios/Detox/DetoxManager.swift @@ -402,8 +402,11 @@ public class DetoxManager : NSObject, WebSocketDelegate { rvParams["captureViewHierarchyError"] = "User ran process with -detoxDisableHierarchyDump YES" } self.webSocket.sendAction(done, params: rvParams, messageId: messageId) - default: - fatalError("Unknown action type received: \(type)") + case "backdoor": + ReactNativeSupport.emitBackdoorEvent(params) + self.safeSend(action: done, messageId: messageId) + default: + fatalError("Unknown action type received: \(type)") } } diff --git a/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeHeaders.h b/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeHeaders.h index e47c0f014d..9d4f16600a 100644 --- a/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeHeaders.h +++ b/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeHeaders.h @@ -25,6 +25,11 @@ typedef void (^RN_RCTJavaScriptCallback)(id json, NSError *error); - (id) uiManager; - (id)moduleForName:(NSString *)moduleName; - (id)moduleForClass:(Class)moduleClass; +- (void)enqueueJSCall:(NSString *)module + method:(NSString *)method + args:(NSArray *)args + completion:(dispatch_block_t)completion; + @property (nonatomic, readonly, getter=isLoading) BOOL loading; @property (nonatomic, readonly, getter=isValid) BOOL valid; diff --git a/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.h b/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.h index a4d6a7781b..6f83595095 100644 --- a/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.h +++ b/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.h @@ -14,6 +14,7 @@ + (void)reloadApp; + (void)waitForReactNativeLoadWithCompletionHandler:(void(^)(void))handler; ++ (void)emitBackdoorEvent:(id)data; @end diff --git a/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.m b/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.m index b04284cce2..8a25cb4c02 100644 --- a/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.m +++ b/detox/ios/Detox/Utilities/ReactNativeSupport/ReactNativeSupport.m @@ -55,4 +55,15 @@ + (void)waitForReactNativeLoadWithCompletionHandler:(void (^)(void))handler [DTXReactNativeSupport waitForReactNativeLoadWithCompletionHandler:handler]; } ++ (void)emitBackdoorEvent:(id)data +{ + if([DTXReactNativeSupport hasReactNative] == NO) + { + return; + } + + id bridge = [NSClassFromString(@"RCTBridge") valueForKey:@"currentBridge"]; + [bridge enqueueJSCall:@"RCTNativeAppEventEmitter" method:@"emit" args:@[@"detoxBackdoor", data] completion:nil]; +} + @end diff --git a/detox/react-native/BackdoorEmitter.js b/detox/react-native/BackdoorEmitter.js new file mode 100644 index 0000000000..a97d285a06 --- /dev/null +++ b/detox/react-native/BackdoorEmitter.js @@ -0,0 +1,144 @@ +/* eslint-disable node/no-unsupported-features/es-syntax */ + +export class BackdoorEmitter { + constructor(emitter) { + this._emitter = emitter; + this._handlers = {}; + this._listeners = {}; + this._subscription = this._emitter.addListener('detoxBackdoor', this._onBackdoorEvent); + } + + /** + * Strict mode prevents interference errors when action handlers are not properly removed + * or overwritten one by another. It is enabled by default. + */ + strict = true; + + /** + * Registers a handler for a backdoor action. + * Note that there can only be one handler per action to avoid confusion, + * because in future versions the handlers will be able to return a promise or a value. + * + * @param {string} actionName + * @param {function} handler + * @throws {Error} if a handler for this action has already been set + * @throws {Error} if handler is not a function + * @example + * detoxBackdoor.registerActionHandler('displayText', ({ text }) => { + * setText(text); + * }); + */ + registerActionHandler(actionName, handler) { + if (typeof actionName !== 'string') { + throw new Error('Detox backdoor action name must be a string'); + } + + if (typeof handler !== 'function') { + throw new Error(`Detox backdoor handler for action "${actionName}" must be a function`); + } + + if (this.strict && this._handlers[actionName]) { + throw new Error(`Detox backdoor handler for action "${actionName}" has already been set`); + } + + this._handlers[actionName] = handler; + } + + /** + * Removes a handler for a backdoor action. + * By default, unremoved handlers will prevent new handlers from being set. + */ + clearActionHandler(actionName) { + delete this._handlers[actionName]; + } + + /** + * Adds a listener for a backdoor action. + * There can be multiple listeners per action, but you cannot return a value from a listener. + * @param {string} actionName + * @param {function} listener + * @throws {Error} if actionName is not a string + * @throws {Error} if listener is not a function + * @example + * detoxBackdoor.addActionListener('logMessage', ({ message }) => { + * console.log(message); + * }); + */ + addActionListener(actionName, listener) { + if (typeof actionName !== 'string') { + throw new Error('Detox backdoor action name must be a string'); + } + + if (typeof listener !== 'function') { + throw new Error(`Detox backdoor listener for action "${actionName}" must be a function`); + } + + if (!this._listeners[actionName]) { + this._listeners[actionName] = []; + } + + this._listeners[actionName].push(listener); + } + + /** + * Removes a listener for a backdoor action. + * @param {string} actionName + * @param {function} listener + * @throws {Error} if actionName is not a string + * @throws {Error} if listener is not a function + */ + removeActionListener(actionName, listener) { + if (typeof actionName !== 'string') { + throw new Error('Detox backdoor action name must be a string'); + } + + if (typeof listener !== 'function') { + throw new Error(`Detox backdoor listener for action "${actionName}" must be a function`); + } + + const listeners = this._listeners[actionName]; + if (listeners) { + const index = listeners.indexOf(listener); + if (index !== -1) { + listeners.splice(index, 1); + } + } + } + + /** + * This fallback handler is called when no handler is set for a backdoor action. + * By default, it throws an error in strict mode and logs a warning otherwise. + * You can override it to provide a custom behavior. + * @param {object} event + * @param {string} event.action + * + * @example + * detoxBackdoor.onUnhandledAction = ({ action, ...args }) => { /* noop *\/ }; + */ + onUnhandledAction = ({ action }) => { + const message = `Failed to find Detox backdoor handler for action "${action}"`; + + if (this.strict) { + throw new Error(message); + } else { + console.warn(message); + } + }; + + /** @private */ + _onBackdoorEvent = (event) => { + const listeners = this._listeners[event.action]; + if (listeners) { + for (const listener of listeners) { + listener(event); + } + } + + const handler = this._handlers[event.action]; + if (handler) { + handler(event); + } else if (!listeners) { + this.onUnhandledAction(event); + } + }; +} diff --git a/detox/react-native/index.js b/detox/react-native/index.js new file mode 100644 index 0000000000..956a3eba96 --- /dev/null +++ b/detox/react-native/index.js @@ -0,0 +1,10 @@ +/* eslint-disable node/no-unsupported-features/es-syntax, import/namespace, node/no-unpublished-import */ +import { DeviceEventEmitter, NativeAppEventEmitter, Platform } from 'react-native'; + +import { BackdoorEmitter } from './BackdoorEmitter'; + +export const detoxBackdoor = new BackdoorEmitter( + Platform.OS === 'ios' + ? NativeAppEventEmitter + : DeviceEventEmitter +); diff --git a/detox/src/client/Client.js b/detox/src/client/Client.js index c4a25a8e52..84bce949fb 100644 --- a/detox/src/client/Client.js +++ b/detox/src/client/Client.js @@ -237,6 +237,10 @@ class Client { await this.sendAction(new actions.Shake()); } + async backdoor(data) { + return await this.sendAction(new actions.Backdoor(data)); + } + async setOrientation(orientation) { await this.sendAction(new actions.SetOrientation(orientation)); } diff --git a/detox/src/client/Client.test.js b/detox/src/client/Client.test.js index 69b02b9ae5..46a4a2d469 100644 --- a/detox/src/client/Client.test.js +++ b/detox/src/client/Client.test.js @@ -363,6 +363,7 @@ describe('Client', () => { ['waitForActive', 'waitForActiveDone', actions.WaitForActive], ['waitUntilReady', 'ready', actions.Ready], ['currentStatus', 'currentStatusResult', actions.CurrentStatus, {}, { status: { app_status: 'idle' } }], + ['backdoor', 'backdoorDone', actions.Backdoor], ])('.%s', (methodName, expectedResponseType, Action, params, expectedResponseParams) => { beforeEach(async () => { await client.connect(); diff --git a/detox/src/client/actions/actions.js b/detox/src/client/actions/actions.js index 25db10beb5..5d6d5e19b9 100644 --- a/detox/src/client/actions/actions.js +++ b/detox/src/client/actions/actions.js @@ -320,8 +320,28 @@ class CaptureViewHierarchy extends Action { } } +class Backdoor extends Action { + constructor(params) { + super ('backdoor', params); + } + + get isAtomic() { + return true; + } + + get timeout() { + return 0; + } + + async handle(response) { + this.expectResponseOfType(response, 'backdoorDone'); + return response; + } +} + module.exports = { Action, + Backdoor, Login, WaitForBackground, WaitForActive, diff --git a/detox/src/devices/runtime/RuntimeDevice.js b/detox/src/devices/runtime/RuntimeDevice.js index 8d7f07f55d..6f5ce635ac 100644 --- a/detox/src/devices/runtime/RuntimeDevice.js +++ b/detox/src/devices/runtime/RuntimeDevice.js @@ -16,6 +16,7 @@ class RuntimeDevice { runtimeErrorComposer, }, deviceDriver) { const methodNames = [ + 'backdoor', 'captureViewHierarchy', 'clearKeychain', 'disableSynchronization', @@ -191,6 +192,10 @@ class RuntimeDevice { return this.deviceDriver.takeScreenshot(name); } + async backdoor(data) { + await this.deviceDriver.backdoor(data); + } + async captureViewHierarchy(name = 'capture') { return this.deviceDriver.captureViewHierarchy(name); } diff --git a/detox/src/devices/runtime/RuntimeDevice.test.js b/detox/src/devices/runtime/RuntimeDevice.test.js index f32bcb5772..d3c88824ab 100644 --- a/detox/src/devices/runtime/RuntimeDevice.test.js +++ b/detox/src/devices/runtime/RuntimeDevice.test.js @@ -703,6 +703,13 @@ describe('Device', () => { }); }); + it(`backdoor() should pass to device driver`, async () => { + const device = await aValidDevice(); + await device.backdoor({ action: 'test' }); + expect(driverMock.driver.backdoor).toHaveBeenCalledWith({ action: 'test' }); + expect(driverMock.driver.backdoor).toHaveBeenCalledTimes(1); + }); + it(`sendToHome() should pass to device driver`, async () => { const device = await aValidDevice(); await device.sendToHome(); diff --git a/detox/src/devices/runtime/drivers/DeviceDriverBase.js b/detox/src/devices/runtime/drivers/DeviceDriverBase.js index c38765aab4..48fcc3a81a 100644 --- a/detox/src/devices/runtime/drivers/DeviceDriverBase.js +++ b/detox/src/devices/runtime/drivers/DeviceDriverBase.js @@ -127,6 +127,10 @@ class DeviceDriverBase { return await this.client.reloadReactNative(); } + async backdoor(data) { + return await this.client.backdoor(data); + } + createPayloadFile(notification) { const notificationFilePath = path.join(this.createRandomDirectory(), `payload.json`); fs.writeFileSync(notificationFilePath, JSON.stringify(notification, null, 2)); diff --git a/detox/test/e2e/03.actions.test.js b/detox/test/e2e/03.actions.test.js index 315db569f0..ab72951a8f 100644 --- a/detox/test/e2e/03.actions.test.js +++ b/detox/test/e2e/03.actions.test.js @@ -27,6 +27,11 @@ describe('Actions', () => { await driver.tapsElement.assertTappedOnce(); }); + it('should be able to send backdoor commands', async () => { + await device.backdoor({ action: 'greet', text: 'Arbitrary Text' }); + await expect(element(by.text('Arbitrary Text!!!'))).toBeVisible(); + }); + it.each([ 'activate', 'magicTap', diff --git a/detox/test/metro.config.js b/detox/test/metro.config.js index f90cae0692..a9a5c1ff92 100644 --- a/detox/test/metro.config.js +++ b/detox/test/metro.config.js @@ -1,17 +1,5 @@ -let createBlacklist; -try { - // RN .64 - createBlacklist = require('metro-config/src/defaults/exclusionList'); -} catch (ex) { - try { - createBlacklist = require('metro-config/src/defaults/blacklist'); - } catch (e) { - createBlacklist = require('metro-bundler').createBlacklist; - } -} - - -const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); +const path = require('node:path'); +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); /** * Metro configuration @@ -22,11 +10,18 @@ const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); const config = {}; const baseConfig = mergeConfig(getDefaultConfig(__dirname), config); - - module.exports = { ...baseConfig, resolver: { - blacklistRE: createBlacklist([/detox\/node_modules\/react-native\/.*/]), + ...baseConfig.resolver, + + nodeModulesPaths: [ + path.resolve('node_modules'), + path.resolve('../node_modules'), + path.resolve('../../node_modules'), + ], }, + watchFolders: [ + path.resolve('..'), + ] }; diff --git a/detox/test/src/Screens/ActionsScreen.js b/detox/test/src/Screens/ActionsScreen.js index ef8957c73e..6be8db28ca 100644 --- a/detox/test/src/Screens/ActionsScreen.js +++ b/detox/test/src/Screens/ActionsScreen.js @@ -14,6 +14,7 @@ import { } from 'react-native'; import TextInput from '../Views/TextInput'; import Slider from '@react-native-community/slider'; +import { detoxBackdoor } from 'detox/react-native'; let LegacySlider; try { @@ -55,6 +56,14 @@ export default class ActionsScreen extends Component { componentDidMount() { BackHandler.addEventListener('hardwareBackPress', this.backHandler.bind(this)); + + detoxBackdoor.registerActionHandler('greet', ({ text }) => { + this.setState({ greeting: text }); + }); + } + + componentWillUnmount() { + detoxBackdoor.clearActionHandler('greet'); } render() { diff --git a/detox/test/types/detox-module-tests.ts b/detox/test/types/detox-module-tests.ts index d3ae389e91..c6c3ee7882 100644 --- a/detox/test/types/detox-module-tests.ts +++ b/detox/test/types/detox-module-tests.ts @@ -70,6 +70,7 @@ describe('Test', () => { .toBeVisible() .withTimeout(2000); await device.pressBack(); + await device.backdoor({ action: 'someAction', anyArgument: 42 }); await waitFor(element(by.text('Text5'))) .toBeVisible() .whileElement(by.id('ScrollView630')) diff --git a/docs/api/device.md b/docs/api/device.md index e71b4d0838..b21c21571e 100644 --- a/docs/api/device.md +++ b/docs/api/device.md @@ -401,9 +401,58 @@ if (device.getPlatform() === 'ios') { Takes a screenshot of the device. For full details on taking screenshots with Detox, refer to the [screen-shots guide](../guide/taking-screenshots.md). -### `device.captureViewHierarchy([name])` +### `device.backdoor(message)` -**iOS Only.** Saves a view hierarchy snapshot (`*.viewhierarchy`) of the +:::tip + +Learn how to use Backdoor API in our [Mocking Guide](../guide/mocking.md#dynamic-mocking-with-backdoor-api). + +::: + +Sends a backdoor message to the app being tested. +The message should be an object with `action` property and any other custom data. + +```js +await device.backdoor({ + action: 'my-testing-action', + // the rest is optional and arbitrary + arg1: 'value1', + arg2: 2 +}); +``` + +On the application side, you have to implement a handler for the backdoor message, e.g.: + +```js +import { detoxBackdoor } from 'detox/react-native'; // to be used only from React Native + +detoxBackdoor.registerActionHandler('my-testing-action', ({ arg1, arg2 }) => { + // ... +}); + +// There can be only one handler per action, so you're advised to remove it when it's no longer needed +detoxBackdoor.clearActionHandler('my-testing-action'); + +// You can supress errors about overwriting existing handlers +detoxBackdoor.strict = false; + +// If you want to have multiple listeners for the same action, you can use `addActionListener` instead +const listener = ({ arg1, arg2 }) => { /* Note that you can't return a value from a listener */ }; +detoxBackdoor.addActionListener('my-testing-action', listener); +// You can remove a listener in a similar way +detoxBackdoor.removeActionListener('my-testing-action', listener); + +// You can set a global handler for all actions without a handler and listeners +detoxBackdoor.onUnhandledAction = ({ action, ...params }) => { + // By default, it throws an error or logs a warning (in non-strict mode) +}; +``` + +Make sure your code using `detoxBackdoor` is not included in production builds. + +### `device.captureViewHierarchy([name])` **iOS Only** + +Saves a view hierarchy snapshot (`*.viewhierarchy`) of the currently opened application to a temporary folder and schedules putting it to the artifacts' folder upon the completion of the current test. The file can be opened later in Xcode 12.0 and above. diff --git a/docs/guide/mocking.md b/docs/guide/mocking.md index 74e7af99f1..38ac9efb2a 100644 --- a/docs/guide/mocking.md +++ b/docs/guide/mocking.md @@ -1,4 +1,8 @@ -# Mocking +--- +id: mocking +--- + +# Mocking Guide :::info @@ -35,18 +39,14 @@ Let's start with the quicker way. 1. Pick a module that you are going to mock, e.g.: - ```js file=src/config.js - // src/config.js - + ```js title=src/config.js export const SERVER_URL = 'https://production.mycompany.name/api'; export const FETCH_TIMEOUT = 60000; ``` -1. Create a mock module alongside, with an arbitrary extension (e.g. `.mock.js`): - - ```js file=src/config.js - // src/config.mock.js +1. Create a mock module alongside, with an arbitrary extension (e.g. `.e2e.js`): + ```js title=src/config.e2e.js export * from './config.js'; // override the url from the original file: @@ -56,42 +56,48 @@ Let's start with the quicker way. 1. Stop your _Metro bundler_ if it has been already running, and run it again with the corresponding file extension override, e.g.: ```bash - npx react-native start --sourceExts mock.js,js,json,ts,tsx + npx react-native start --sourceExts e2e.js,js,json,ts,tsx ``` - This command is already enough to start your application in an altered mode, and you can start running your tests. Now, if some module imports `./src/config`, you tell _Metro bundler_ to prefer `./src/config.mock.js` over the plain `./src/config.js`, which means the consumer gets the mocked implementation. + This command is already enough to start your application in an altered mode, and you can start running your tests. Now, if some module imports `./src/config`, you tell _Metro bundler_ to prefer `./src/config.e2e.js` over the plain `./src/config.js`, which means the consumer gets the mocked implementation. -> CAVEAT: whichever file extension you might take for the mock files – make sure you don’t accidentally "pick up" unforeseen file overrides from `node_modules/**/*.your-extension.js`! -> _Metro bundler_ does not limit itself to your project files only – applying those `--sourceExts` also affects the resolution of the `node_modules` content! +:::caution Caveat + +Whichever file extension you might take for the mock files – make sure you don’t accidentally "pick up" unforeseen file overrides from `node_modules/**/*.your-extension.js`! +_Metro bundler_ does not limit itself to your project files only – applying those `--sourceExts` also affects the resolution of the `node_modules` content! + +::: ## Configuring Metro bundler While the mentioned way is good enough for the **debug mode**, it falls short for the **release builds**. The problem is that the `--sourceExts` argument is supported only by `react-native start` command. Hence, you’d need a CLI-independent way to configure your Metro bundler, and that is patching your project's `metro.config.js`: -```diff title="metro.config.js" - /** - * Metro configuration for React Native - * https://github.com/facebook/react-native - * - * @format - */ -+const defaultSourceExts = require('metro-config/src/defaults/defaults').sourceExts; - - module.exports = { -+ resolver: { -+ sourceExts: process.env.MY_APP_MODE === 'mocked' -+ ? ['mock.js', ...defaultSourceExts] -+ : defaultSourceExts, -+ }, - transformer: { - getTransformOptions: async () => ({ - transform: { - experimentalImportSupport: false, - inlineRequires: true, - }, - }), - }, - }; +```js title="metro.config.js" +/** + * Metro configuration for React Native + * https://github.com/facebook/react-native + * + * @format + */ +const defaultSourceExts = require('metro-config/src/defaults/defaults').sourceExts; + +module.exports = { +// highlight-start + resolver: { + sourceExts: process.env.MY_APP_MODE === 'mocked' + ? ['e2e.js', ...defaultSourceExts] + : defaultSourceExts, + }, +// highlight-end + transformer: { + getTransformOptions: async () => ({ + transform: { + experimentalImportSupport: false, + inlineRequires: true, + }, + }), + }, +}; ``` This way, we are enforcing a custom convention that if the Metro bundler finds the `MY_APP_MODE=mocked` environment variable, it should apply our `sourceExts` override instead of the default values. @@ -119,10 +125,109 @@ xcodebuild -workspace ... -configuration release -scheme ... Please note that preparing React Native apps for the release mode requires groundwork for both [iOS](https://reactnative.dev/docs/publishing-to-app-store) and [Android](https://reactnative.dev/docs/signed-apk-android), which is out of scope of this current article. -As you might have noticed, this tutorial has no direct connection to Detox itself, which is a correct observation. -The suggested mocking techniques are a part of the React Native world itself, so please consult the further resources: +As you might have noticed, until now, this tutorial had no direct connection to Detox itself, and that's a correct observation. +The suggested static mocking techniques are a part of the React Native world itself, so please consult the further resources if you need more information: - - -Happy Detoxing! +## Dynamic Mocking with Backdoor API + +In scenarios where static mocking is not sufficiently flexible, Detox's [Backdoor API](../api/device.md#devicebackdoormessage) presents a strategy for **dynamic mocking** during test runtime. +This allows tests to instruct the app to modify its internal state without interacting with the UI, thereby providing additional control over the app's behavior during test execution. + +:::info Before you continue + +The dynamic mocking is an extension of the static mocking approach, so make sure your **read the previous section**, as it provides the necessary background. + +::: + +Imagine you have a time service in your app, responsible for providing the current time: + +```js title=src/services/TimeService.js +export class TimeService { + now() { + return Date.now(); + } +} +``` + +Now, for testing purposes, we can create a mocked counterpart that allows its internal time to be set dynamically: + +```js title=src/services/TimeService.e2e.js +import { detoxBackdoor } from 'detox/react-native'; + +export class FakeTimeService { + #now = Date.now(); + + constructor() { + detoxBackdoor.registerActionHandler('set-mock-time', ({ time }) => this.setNow(time)); + } + + // If you have a single instance through the app, you might not need this. + dispose() { + detoxBackdoor.removeActionHandler('set-mock-time'); + } + + setNow(time) { + this.#now = time; + } + + now() { + return this.#now; + } +} +``` + +In the mock implementation, `TimeService.e2e.js`, we're listening for `detoxBackdoor` actions with `set-mock-time` name and, when received, we adjust the internal `#now` value if the `action` is `"set-mock-time"`. + +:::tip + +- **One-at-a-Time:** Only a single action handler can be set per action name. Design your logic accordingly if you have multiple instances needing action handling. + +- **Bidirectional flow:** Not supported yet, but planned for the future. Every action handler will be able to return a value back to the caller, which is why the limitation is in place. + +- **Strict Mode (Default=On):** This throws errors upon accidental overwrites of handlers, or firing unknown backdoor actions. Although not recommended, you can disable this behavior by setting: + + ```js + detoxBackdoor.strict = false; + ``` + +- **Handle with Care:** Ensure your handlers do not throw unhandled exceptions. At the moment, Detox won’t report them back seamlessly to the test runner. If your app crashes, especially in _devRelease_ mode – read the crash message with attention before pointing fingers at Detox! :slightly_smiling_face: + +::: + +If you still would like to have multiple listeners for the same action, you can use the `detoxBackdoor.addActionListener` method instead: + +```js +const listener = ({ time }) => console.log(`Received time: ${time}`); +detoxBackdoor.addActionListener('set-mock-time', listener); +detoxBackdoor.removeActionListener('set-mock-time', listener); +``` + +The weaker side of action listeners is that they will never be able to return a value back to the caller by design. + +Now, when your app mocks are in place, you can open your Detox test file and instruct the app to use the mocked time service: + +```js +await device.backdoor({ + action: "set-mock-time", + time: 1672531199000, +}); +``` + +Summarizing the above, Backdoor API enables your tests to directly "speak" to your app, altering its state without UI interaction. + +:::danger Security Notice + +Avoid using `detoxBackdoor` in your production code, as it might expose a security vulnerability. + +Leave these action handlers to **mock files only**, and make sure they are excluded from the public release builds. +Backdoor API is a **testing tool**, and it should be isolated to test environments only. + +::: + +Provided that your app is designed with testability in mind, Backdoor API can be a powerful tool for testing cases where native tools fall short: +changing the internal clock, simulating network conditions, geolocation, quick authentication, and more. + +Use it wisely, and... happy Detoxing!