Repository: wix/Detox
Pull request #2149 (2020-06-24 11:49:57)

Android driver refactor 1.0
- [ ] This is a small change 
- [ ] This change has been discussed in issue #<?> and the solution has been agreed upon with maintainers.

---

**Description:**
Following work on #1192 -- need to break some of the AndroidDriver monolith into sub-units. This is the main step - 1.0, following #2146 and #2148: refactor away all the core of instrumentation process management.

coveralls: ## Pull Request Test Coverage Report for [Build 2356](https://coveralls.io/builds/31668384)

* **49** of **52**   **(94.23%)**  changed or added relevant lines in **2** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.2%**) to **64.825%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [detox/src/devices/drivers/android/AndroidDriver.js](https://coveralls.io/builds/31668384/source?filename=detox%2Fsrc%2Fdevices%2Fdrivers%2Fandroid%2FAndroidDriver.js#L271) | 15 | 18 | 83.33%
<!-- | **Total:** | **49** | **52** | **94.23%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/31668384/badge)](https://coveralls.io/builds/31668384) |
| :-- | --: |
| Change from base [Build 1249](https://coveralls.io/builds/31653697): |  0.2% |
| Covered Lines: | 2899 |
| Relevant Lines: | 4092 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index 5570fd1bc..b55b61dde 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -2,7 +2,6 @@ const fs = require('fs');
 const URL = require('url').URL;
 const _ = require('lodash');
 const DeviceDriverBase = require('../DeviceDriverBase');
-const { encodeBase64 } = require('../../../utils/encoding');
 const logger = require('../../../utils/logger');
 const log = logger.child({ __filename });
 const invoke = require('../../../invoke');
@@ -12,7 +11,7 @@ const AAPT = require('./exec/AAPT');
 const APKPath = require('./tools/APKPath');
 const TempFileXfer = require('./tools/TempFileXfer');
 const AppUninstallHelper = require('./tools/AppUninstallHelper');
-const { prepareInstrumentationArgs } = require('./tools/instrumentationArgs');
+const Instrumentation = require('./tools/Instrumentation');
 const DetoxApi = require('../../../android/espressoapi/Detox');
 const EspressoDetoxApi = require('../../../android/espressoapi/EspressoDetox');
 const UiDeviceProxy = require('../../../android/espressoapi/UiDeviceProxy');
@@ -26,23 +25,14 @@ const temporaryPath = require('../../../artifacts/utils/temporaryPath');
 const DetoxRuntimeError = require('../../../errors/DetoxRuntimeError');
 const sleep = require('../../../utils/sleep');
 const retry = require('../../../utils/retry');
-const { interruptProcess } = require('../../../utils/exec');
 const getAbsoluteBinaryPath = require('../../../utils/getAbsoluteBinaryPath');
 const AndroidExpect = require('../../../android/expect');
 const { InstrumentationLogsParser } = require('./InstrumentationLogsParser');
 
-const reservedInstrumentationArgs = ['class', 'package', 'func', 'unit', 'size', 'perf', 'debug', 'log', 'emma', 'coverageFile'];
-const isReservedInstrumentationArg = (arg) => reservedInstrumentationArgs.includes(arg);
-
 class AndroidDriver extends DeviceDriverBase {
   constructor(config) {
     super(config);
 
-    this.instrumentationLogsParser = null;
-    this.instrumentationProcess = null;
-    this.instrumentationStackTrace = '';
-    this.instrumentationCloseListener = _.noop;
-
     this.invocationManager = new InvocationManager(this.client);
     this.matchers = new AndroidExpect(this.invocationManager);
     this.uiDevice = new UiDeviceProxy(this.invocationManager).getUIDevice();
@@ -51,6 +41,11 @@ class AndroidDriver extends DeviceDriverBase {
     this.aapt = new AAPT();
     this.fileXfer = new TempFileXfer(this.adb);
     this.devicePathBuilder = new AndroidDevicePathBuilder();
+
+    this.instrumentationLogsParser = null;
+    this.instrumentationStackTrace = '';
+    this.instrumentationCloseListener = _.noop;
+    this.instrumentation = new Instrumentation(this.adb, logger);
   }
 
   declareArtifactPlugins() {
@@ -98,7 +93,7 @@ class AndroidDriver extends DeviceDriverBase {
       }
     }
 
-    if (!this._isInstrumentationRunning()) {
+    if (!this.instrumentation.isRunning()) {
       await this._launchInstrumentationProcess(deviceId, bundleId, launchArgs);
       await sleep(500);
     } else if (launchArgs.detoxURLOverride) {
@@ -141,7 +136,7 @@ class AndroidDriver extends DeviceDriverBase {
           super.waitUntilReady(),
           new Promise((resolve, reject) => {
             this.instrumentationCloseListener = () => reject(this._getInstrumentationCrashError());
-            !this._isInstrumentationRunning() && this.instrumentationCloseListener();
+            !this.instrumentation.isRunning() && this.instrumentationCloseListener();
           }),
         ]);
       } finally {
@@ -164,18 +159,6 @@ class AndroidDriver extends DeviceDriverBase {
     await this.emitter.emit('terminateApp', { deviceId, bundleId });
   }
 
-  async _terminateInstrumentation() {
-    if (this._isInstrumentationRunning()) {
-      await interruptProcess(this.instrumentationProcess);
-      this.instrumentationProcess = null;
-      this.instrumentationCloseListener();
-    }
-  }
-
-  _isInstrumentationRunning() {
-    return !!this.instrumentationProcess;
-  }
-
   _getInstrumentationCrashError() {
     return new DetoxRuntimeError({
       message: 'Failed to run application on the device',
@@ -273,24 +256,30 @@ class AndroidDriver extends DeviceDriverBase {
     return testApkPath;
   }
 
-  async _launchInstrumentationProcess(deviceId, bundleId, rawLaunchArgs) {
-    const launchArgs = prepareInstrumentationArgs(rawLaunchArgs);
-    const additionalLaunchArgs = prepareInstrumentationArgs({ debug: false });
-    this._warnReservedArgsUsedIfNeeded(launchArgs);
-
+  async _launchInstrumentationProcess(deviceId, bundleId, userLaunchArgs) {
     const serverPort = new URL(this.client.configuration.server).port;
     await this.adb.reverse(deviceId, serverPort);
-    const testRunner = await this.adb.getInstrumentationRunner(deviceId, bundleId);
-    const spawnFlags = [...launchArgs.args, ...additionalLaunchArgs.args];
 
     this.instrumentationLogsParser = new InstrumentationLogsParser();
-    this.instrumentationProcess = this.adb.spawnInstrumentation(deviceId, spawnFlags, testRunner);
-    this.instrumentationProcess.childProcess.stdout.setEncoding('utf8');
-    this.instrumentationProcess.childProcess.stdout.on('data', this._extractStackTraceFromInstrumLogs.bind(this));
-    this.instrumentationProcess.childProcess.on('close', async () => {
-      await this._terminateInstrumentation();
-      await this.adb.reverseRemove(deviceId, serverPort);
-    });
+    this.instrumentation.setTerminationFn(() => this._onInstrumentationTerminated(deviceId, serverPort));
+    this.instrumentation.setLogListenFn(this._extractStackTraceFromInstrumLogs.bind(this));
+    await this.instrumentation.launch(deviceId, bundleId, userLaunchArgs);
+  }
+
+  async _onInstrumentationTerminated(deviceId, serverPort) {
+    await this.adb.reverseRemove(deviceId, serverPort);
+    this.instrumentationCloseListener();
+
+    this.instrumentation.setTerminationFn(null);
+    this.instrumentation.setLogListenFn(null);
+  }
+
+  async _terminateInstrumentation() {
+    await this.instrumentation.terminate();
+    this.instrumentationCloseListener();
+
+    this.instrumentation.setTerminationFn(null);
+    this.instrumentation.setLogListenFn(null);
   }
 
   _extractStackTraceFromInstrumLogs(logsDump) {
@@ -335,16 +324,6 @@ class AndroidDriver extends DeviceDriverBase {
   _resumeMainActivity() {
     return this.invocationManager.execute(DetoxApi.launchMainActivity());
   }
-
-  _warnReservedArgsUsedIfNeeded(preparedArgs) {
-    if (preparedArgs.usedReservedArgs.length) {
-      logger.warn([`Arguments [${preparedArgs.usedReservedArgs}] were passed in as launchArgs to device.launchApp() `,
-        'but are reserved to Android\'s test-instrumentation and will not be passed into the app. ',
-        'Ignore this message if this is what you meant to do. Refer to ',
-        'https://developer.android.com/studio/test/command-line#AMOptionsSyntax for ',
-        'further details.'].join(''));
-    }
-  }
 }
 
 module.exports = AndroidDriver;
diff --git a/detox/src/devices/drivers/android/tools/Instrumentation.js b/detox/src/devices/drivers/android/tools/Instrumentation.js
new file mode 100644
index 000000000..5a4a20cb0
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/Instrumentation.js
@@ -0,0 +1,80 @@
+const _ = require('lodash');
+const { interruptProcess } = require('../../../../utils/exec');
+const { prepareInstrumentationArgs } = require('./instrumentationArgs');
+
+class Instrumentation {
+  constructor(adb, logger, userTerminationFn = _.noop, userLogListenFn = _.noop) {
+    this.adb = adb;
+    this.logger = logger;
+    this.userTerminationFn = userTerminationFn;
+    this.userLogListenFn = userLogListenFn;
+    this.instrumentationProcess = null;
+    this._onTerminated = this._onTerminated.bind(this);
+    this._onLogData = this._onLogData.bind(this);
+  }
+
+  async launch(deviceId, bundleId, userLaunchArgs) {
+    const spawnArgs = this._getSpawnArgs(userLaunchArgs);
+
+    const testRunner = await this.adb.getInstrumentationRunner(deviceId, bundleId);
+
+    this.instrumentationProcess = this.adb.spawnInstrumentation(deviceId, spawnArgs, testRunner);
+    this.instrumentationProcess.childProcess.stdout.setEncoding('utf8');
+    this.instrumentationProcess.childProcess.stdout.on('data', this._onLogData);
+    this.instrumentationProcess.childProcess.on('close', this._onTerminated);
+  }
+
+  async terminate() {
+    if (this.instrumentationProcess) {
+      await this._killProcess();
+    }
+  }
+
+  isRunning() {
+    return !!this.instrumentationProcess;
+  }
+
+  setTerminationFn(userTerminationFn) {
+    this.userTerminationFn = userTerminationFn || _.noop;
+  }
+
+  setLogListenFn(userLogListenFn) {
+    this.userLogListenFn = userLogListenFn || _.noop;
+  }
+
+  _getSpawnArgs(userLaunchArgs) {
+    const launchArgs = prepareInstrumentationArgs(userLaunchArgs);
+    const additionalLaunchArgs = prepareInstrumentationArgs({ debug: false });
+    this._warnReservedArgsUsedIfNeeded(launchArgs);
+
+    return [...launchArgs.args, ...additionalLaunchArgs.args];
+  }
+
+  async _onLogData(data) {
+    await this.userLogListenFn(data);
+  }
+
+  async _onTerminated() {
+    if (this.instrumentationProcess) {
+      await this._killProcess();
+      await this.userTerminationFn();
+    }
+  }
+
+  async _killProcess() {
+    await interruptProcess(this.instrumentationProcess);
+    this.instrumentationProcess = null;
+  }
+
+  _warnReservedArgsUsedIfNeeded(preparedArgs) {
+    if (preparedArgs.usedReservedArgs.length) {
+      this.logger.warn([`Arguments [${preparedArgs.usedReservedArgs}] were passed in as launchArgs to device.launchApp() `,
+        'but are reserved to Android\'s test-instrumentation and will not be passed into the app. ',
+        'Ignore this message if this is what you meant to do. Refer to ',
+        'https://developer.android.com/studio/test/command-line#AMOptionsSyntax for ',
+        'further details.'].join(''));
+    }
+  }
+}
+
+module.exports = Instrumentation;


Test patch:
diff --git a/detox/src/devices/drivers/android/AndroidDriver.test.js b/detox/src/devices/drivers/android/AndroidDriver.test.js
index 8f91a8172..67466d6b1 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.test.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.test.js
@@ -7,6 +7,9 @@ describe('Android driver', () => {
   const mockGetAbsoluteBinaryPathImpl = (x) => `absolutePathOf(${x})`;
   const mockAPKPathGetTestApkPathImpl = (x) => `testApkPathOf(${x})`;
 
+  const mockInstrumentationRunning = () => instrumentation.isRunning.mockReturnValue(true);
+  const mockInstrumentationDead = () => instrumentation.isRunning.mockReturnValue(false);
+
   class MockADBClass {
     constructor() {
       Object.assign(this, adb);
@@ -40,6 +43,13 @@ describe('Android driver', () => {
     }
   }
 
+  class MockInstrumentationClass {
+    constructor(...args) {
+      instrumentation.mockCtor(...args);
+      Object.assign(this, instrumentation);
+    }
+  }
+
   let logger;
   let client;
   let getAbsoluteBinaryPath;
@@ -47,6 +57,7 @@ describe('Android driver', () => {
   let exec;
   let detoxApi;
   let instrumentationArgs;
+  let instrumentation;
   beforeEach(() => {
     jest.mock('fs', () => ({
       existsSync: jest.fn(),
@@ -101,6 +112,12 @@ describe('Android driver', () => {
     jest.mock('./tools/instrumentationArgs');
     instrumentationArgs = require('./tools/instrumentationArgs');
     instrumentationArgs.prepareInstrumentationArgs.mockReturnValue({args: [], usedReservedArgs: []});
+
+    const Instrumentation = jest.genMockFromModule('./tools/Instrumentation');
+    instrumentation = new Instrumentation();
+    instrumentation.mockCtor = jest.fn();
+    mockInstrumentationDead();
+    jest.mock('./tools/Instrumentation', () => MockInstrumentationClass);
   });
 
   let adb;
@@ -147,16 +164,25 @@ describe('Android driver', () => {
   });
 
   describe('Instrumentation bootstrap', () => {
+    it('should init an instrumentation manager', async () => {
+      expect(instrumentation.mockCtor).toHaveBeenCalledWith(adb, logger);
+    });
+
     it('should launch instrumentation upon app launch', async () => {
-      adb.getInstrumentationRunner.mockResolvedValue('mock test-runner');
+      const userArgs = {
+        anArg: 'aValue',
+      };
+      await uut.launchApp(deviceId, bundleId, userArgs, '');
+      expect(instrumentation.launch).toHaveBeenCalledWith(deviceId, bundleId, userArgs);
+    });
 
-      await uut.launchApp(deviceId, bundleId, {}, '');
+    it('should break if instrumentation launch fails', async () => {
+      instrumentation.launch.mockRejectedValue(new Error());
 
-      expect(adb.spawnInstrumentation).toHaveBeenCalledWith(
-        deviceId,
-        expect.anything(),
-        expect.anything(),
-      );
+      try {
+        await uut.launchApp(deviceId, bundleId, {}, '');
+        fail();
+      } catch (e) {}
     });
   });
 
@@ -173,35 +199,30 @@ describe('Android driver', () => {
     }
     const assertActivityStartNotInvoked = () => expect(detoxApi.startActivityFromUrl).not.toHaveBeenCalled();
 
-    const assertArgumentsPreparationWith = (args) => expect(instrumentationArgs.prepareInstrumentationArgs).toHaveBeenCalledWith(args);
-    const assertInstrumentationSpawned = () => expect(adb.spawnInstrumentation).toHaveBeenCalled();
-    const resetInstrumentationMock = () => adb.spawnInstrumentation.mockReset();
-    const assertInstrumentationNotSpawned = () => expect(adb.spawnInstrumentation).not.toHaveBeenCalled();
+    const assertInstrumentationLaunchedWith = (args) => expect(instrumentation.launch).toHaveBeenCalledWith(deviceId, bundleId, args);
+    const assertInstrumentationNotLaunched = () => expect(instrumentation.launch).not.toHaveBeenCalled();
 
     describe('in app launch (with dedicated arg)', () => {
       const args = {
         detoxURLOverride,
       };
 
-      it('should spawn instrumentation with the URL in a clean launch', async () => {
+      it('should launch instrumentation with the URL in a clean launch', async () => {
         adb.getInstrumentationRunner.mockResolvedValue('mock test-runner');
 
         await uut.launchApp(deviceId, bundleId, args, '');
 
-        assertArgumentsPreparationWith(args);
-        assertInstrumentationSpawned();
+        assertInstrumentationLaunchedWith(args);
       });
 
       it('should start the app with URL via invocation-manager', async () => {
         mockStartActivityInvokeApi();
+        mockInstrumentationRunning();
 
-        await uut.launchApp(deviceId, bundleId, {}, '');
-
-        resetInstrumentationMock();
         await uut.launchApp(deviceId, bundleId, args, '');
 
         assertActivityStartInvoked();
-        assertInstrumentationNotSpawned();
+        assertInstrumentationNotLaunched();
       });
     });
 
@@ -252,10 +273,8 @@ describe('Android driver', () => {
       expect(detoxApi.startActivityFromNotification).not.toHaveBeenCalled();
     }
 
-    const assertArgumentsPreparationWith = (args) => expect(instrumentationArgs.prepareInstrumentationArgs).toHaveBeenCalledWith(args);
-    const resetInstrumentationMock = () => adb.spawnInstrumentation.mockReset();
-    const assertInstrumentationSpawned = () => expect(adb.spawnInstrumentation).toHaveBeenCalled();
-    const assertInstrumentationNotSpawned = () => expect(adb.spawnInstrumentation).not.toHaveBeenCalled();
+    const assertInstrumentationLaunchedWith = (args) => expect(instrumentation.launch).toHaveBeenCalledWith(deviceId, bundleId, args);
+    const assertInstrumentationNotSpawned = () => expect(instrumentation.launch).not.toHaveBeenCalled();
 
     describe('in app launch (with dedicated arg)', () => {
       it('should prepare the device for receiving notification data file', async () => {
@@ -282,15 +301,17 @@ describe('Android driver', () => {
 
         await uut.launchApp(deviceId, bundleId, notificationArgs, '');
 
-        assertArgumentsPreparationWith({ detoxUserNotificationDataURL: mockNotificationDataTargetPath });
-        assertInstrumentationSpawned();
+        assertInstrumentationLaunchedWith({ detoxUserNotificationDataURL: mockNotificationDataTargetPath });
       });
     });
 
     [
       {
-        description: 'in 2nd app launch (i.e. when running)',
-        applyFn: () => uut.launchApp(deviceId, bundleId, notificationArgs, ''),
+        description: 'in app launch when already running',
+        applyFn: () => {
+          mockInstrumentationRunning();
+          return uut.launchApp(deviceId, bundleId, notificationArgs, '');
+        },
       },
       {
         description: 'via explicit payload-delivery call',
@@ -298,12 +319,7 @@ describe('Android driver', () => {
       },
     ].forEach((spec) => {
       describe(spec.description, () => {
-        beforeEach(async () => {
-          await uut.launchApp(deviceId, bundleId, {}, '')
-        });
-
         it('should pre-transfer notification data to device', async () => {
-          resetInstrumentationMock();
           await spec.applyFn();
 
           expect(fileXfer.prepareDestinationDir).toHaveBeenCalledWith(deviceId);
@@ -313,7 +329,6 @@ describe('Android driver', () => {
         it('should start the app with notification data using invocation-manager', async () => {
           mockStartActivityInvokeApi();
 
-          resetInstrumentationMock();
           await spec.applyFn();
 
           assertActivityStartInvoked();
@@ -346,20 +361,20 @@ describe('Android driver', () => {
   });
 
   describe('Device ready-wait', () => {
-    beforeEach(async () => {
-      await uut.launchApp(deviceId, bundleId, {}, '');
-    });
-
     it('should delegate wait to device being ready via client api', async () => {
+      mockInstrumentationRunning();
       await uut.waitUntilReady();
       expect(client.waitUntilReady).toHaveBeenCalled();
     }, 2000);
 
-    it('should fail if instrumentation dies prematurely while waiting for device-ready resolution', async () => {
+    it('should fail if instrumentation async\'ly-dies prematurely while waiting for device-ready resolution', async () => {
+      await uut.launchApp(deviceId, bundleId, {}, '');
+
+      mockInstrumentationRunning();
       const clientWaitResolve = mockDeviceReadyPromise();
 
       const promise = uut.waitUntilReady();
-      setTimeout(async () => await killInstrumentation(adb.spawnInstrumentation()), 1);
+      setTimeout(fakeInstrumentationTermination, 1);
 
       try {
         await promise;
@@ -378,75 +393,16 @@ describe('Android driver', () => {
       return clientResolve;
     };
 
-    const killInstrumentation = async (instrumProcess) => {
-      const { childProcess } = instrumProcess;
-
-      const stdoutSubscribeCallArgs = childProcess.stdout.on.mock.calls[0];
-      const stdoutListenerFn = stdoutSubscribeCallArgs[1];
-      stdoutListenerFn('Doesnt matter what we put here');
+    const fakeInstrumentationTermination = async () => {
+      const instrumentationLoggingCallback = instrumentation.setLogListenFn.mock.calls[0][0];
+      await instrumentationLoggingCallback('Doesnt matter what we put here because actual stacktrace should be read from logs-parser');
 
-      const closeEvCallArgs = childProcess.on.mock.calls[0];
-      const closeEvListenerFn = closeEvCallArgs[1];
-      await closeEvListenerFn();
+      const instrumentationTerminationCallback = instrumentation.setTerminationFn.mock.calls[0][0];
+      await instrumentationTerminationCallback();
+      mockInstrumentationDead();
     };
   });
 
-  describe('Launch args', () => {
-    it('should prepare user launch args', async () => {
-      instrumentationArgs.prepareInstrumentationArgs.mockReturnValue({args: [], usedReservedArgs: []});
-
-      const launchArgs = {
-        anArg: 'aValue',
-      };
-      await uut.launchApp(deviceId, bundleId, launchArgs, '');
-
-      expect(instrumentationArgs.prepareInstrumentationArgs).toHaveBeenCalledWith(launchArgs);
-    });
-
-    it('should prepare forced debug=false arg', async () => {
-      instrumentationArgs.prepareInstrumentationArgs.mockReturnValue({args: [], usedReservedArgs: []});
-      await uut.launchApp(deviceId, bundleId, {}, '');
-      expect(instrumentationArgs.prepareInstrumentationArgs).toHaveBeenCalledWith({ debug: false });
-    });
-
-    it('should spawn instrumentation with prepared arguments', async () => {
-      const mockedPreparedUserArgs = ['mocked', 'prepared-args'];
-      const mockedPreparedDebugArg = ['debug', 'mocked'];
-      instrumentationArgs.prepareInstrumentationArgs
-        .mockReturnValueOnce({ args: mockedPreparedUserArgs, usedReservedArgs: [] })
-        .mockReturnValueOnce({ args: mockedPreparedDebugArg, usedReservedArgs: [] });
-
-      await uut.launchApp(deviceId, bundleId, {}, '');
-      expect(adb.spawnInstrumentation).toHaveBeenCalledWith(expect.anything(), [...mockedPreparedUserArgs, ...mockedPreparedDebugArg], undefined);
-    });
-
-    it('should log reserved instrumentation args usage if used in user args', async () => {
-      const mockedPreparedUserArgs = ['mocked', 'prepared-args'];
-      const usedReservedArgs = ['aaa', 'zzz'];
-      const mockedPreparedDebugArg = ['debug', 'mocked'];
-      instrumentationArgs.prepareInstrumentationArgs
-        .mockReturnValueOnce({ args: mockedPreparedUserArgs, usedReservedArgs })
-        .mockReturnValueOnce({ args: mockedPreparedDebugArg, usedReservedArgs: ['shouldnt', 'care'] });
-
-      await uut.launchApp(deviceId, bundleId, {}, '');
-
-      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Arguments [aaa,zzz] were passed in as launchArgs to device.launchApp()'));
-      expect(logger.warn).toHaveBeenCalledTimes(1);
-    });
-
-    it('should NOT log reserved instrumentation args usage if none used by user', async () => {
-      const mockedPreparedUserArgs = ['mocked', 'prepared-args'];
-      const mockedPreparedDebugArg = ['debug', 'mocked'];
-      instrumentationArgs.prepareInstrumentationArgs
-        .mockReturnValueOnce({ args: mockedPreparedUserArgs, usedReservedArgs: [] })
-        .mockReturnValueOnce({ args: mockedPreparedDebugArg, usedReservedArgs: ['shouldnt', 'care'] });
-
-      await uut.launchApp(deviceId, bundleId, {}, '');
-
-      expect(logger.warn).not.toHaveBeenCalled();
-    });
-  });
-
   describe('App installation', () => {
     const binaryPath = 'mock-bin-path';
     const testBinaryPath = 'mock-test-bin-path';
diff --git a/detox/src/devices/drivers/android/tools/Instrumentation.test.js b/detox/src/devices/drivers/android/tools/Instrumentation.test.js
new file mode 100644
index 000000000..9519bb297
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/Instrumentation.test.js
@@ -0,0 +1,264 @@
+describe('Instrumentation', () => {
+  const deviceId = 'mock-device-id';
+  const bundleId = 'mock-bundle-id';
+
+  let exec;
+  let adb;
+  let instrumentationArgs;
+  let logger;
+  beforeEach(() => {
+    const ADB = jest.genMockFromModule('../exec/ADB');
+    adb = new ADB();
+
+    jest.mock('../../../../utils/exec');
+    exec = require('../../../../utils/exec');
+
+    jest.mock('./instrumentationArgs');
+    instrumentationArgs = require('./instrumentationArgs');
+    instrumentationArgs.prepareInstrumentationArgs.mockReturnValue({args: [], usedReservedArgs: []});
+
+    logger = {
+      warn: jest.fn(),
+    }
+  });
+
+  let childProcess;
+  let instrumentationProcess;
+  let logListenCallback;
+  let userTerminationCallback;
+  let uut;
+  beforeEach(() => {
+    childProcess = {
+      on: jest.fn(),
+      stdout: {
+        setEncoding: jest.fn(),
+        on: jest.fn(),
+      },
+    };
+    instrumentationProcess = {
+      childProcess,
+    };
+    adb.spawnInstrumentation.mockReturnValue(instrumentationProcess);
+
+    logListenCallback = jest.fn();
+    userTerminationCallback = jest.fn();
+
+    const Instrumentation = require('./Instrumentation');
+    uut = new Instrumentation(adb, logger, userTerminationCallback, logListenCallback);
+  });
+
+  it('should spawn Android instrumentation with device ID', async () => {
+    await uut.launch(deviceId, bundleId, []);
+    expect(adb.spawnInstrumentation).toHaveBeenCalledWith(deviceId, expect.anything(), undefined);
+  });
+
+  it('should spawn instrumentation with test runner', async () => {
+    const testRunner = 'mock-android-test-runner-name';
+    adb.getInstrumentationRunner.mockResolvedValue(testRunner);
+
+    await uut.launch(deviceId, bundleId, []);
+    expect(adb.spawnInstrumentation).toHaveBeenCalledWith(expect.anything(), expect.anything(), testRunner);
+    expect(adb.getInstrumentationRunner).toHaveBeenCalledWith(deviceId, bundleId);
+  });
+
+  describe('spawning launch-arguments processing', () => {
+    it('should prepare user launch-arguments', async () => {
+      instrumentationArgs.prepareInstrumentationArgs.mockReturnValue({args: [], usedReservedArgs: []});
+      const userArgs = {
+        arg1: 'value1',
+      };
+      await uut.launch(deviceId, bundleId, userArgs);
+
+      expect(instrumentationArgs.prepareInstrumentationArgs).toHaveBeenCalledWith(userArgs);
+    });
+
+    it('should prepare forced debug=false launch argument', async () => {
+      instrumentationArgs.prepareInstrumentationArgs.mockReturnValue({args: [], usedReservedArgs: []});
+      await uut.launch(deviceId, bundleId, { });
+      expect(instrumentationArgs.prepareInstrumentationArgs).toHaveBeenCalledWith({ debug: false });
+    });
+
+    it('should spawn instrumentation with processed user launch-arguments', async () => {
+      const mockedPreparedUserArgs = ['mocked', 'prepared-args'];
+      const mockedPreparedDebugArg = ['debug', 'mocked'];
+      instrumentationArgs.prepareInstrumentationArgs
+        .mockReturnValueOnce({ args: mockedPreparedUserArgs, usedReservedArgs: [] })
+        .mockReturnValueOnce({ args: mockedPreparedDebugArg, usedReservedArgs: [] });
+
+      await uut.launch(deviceId, bundleId, {});
+      expect(adb.spawnInstrumentation).toHaveBeenCalledWith(expect.anything(), [...mockedPreparedUserArgs, ...mockedPreparedDebugArg], undefined);
+    });
+
+    it('should log reserved instrumentation args usage if used in user args', async () => {
+      const mockedPreparedUserArgs = ['mocked', 'prepared-args'];
+      const mockedPreparedDebugArg = ['debug', 'mocked'];
+      const usedReservedArgs = ['aaa', 'zzz'];
+      instrumentationArgs.prepareInstrumentationArgs
+        .mockReturnValueOnce({ args: mockedPreparedUserArgs, usedReservedArgs })
+        .mockReturnValueOnce({ args: mockedPreparedDebugArg, usedReservedArgs: ['shouldnt', 'care'] });
+
+      await uut.launch(deviceId, bundleId, {});
+
+      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Arguments [aaa,zzz] were passed in as launchArgs to device.launchApp()'));
+      expect(logger.warn).toHaveBeenCalledTimes(1);
+    });
+
+    it('should NOT log reserved instrumentation args usage if none used by user', async () => {
+      const mockedPreparedUserArgs = ['mocked', 'prepared-args'];
+      const mockedPreparedDebugArg = ['debug', 'mocked'];
+      instrumentationArgs.prepareInstrumentationArgs
+        .mockReturnValueOnce({ args: mockedPreparedUserArgs, usedReservedArgs: [] })
+        .mockReturnValueOnce({ args: mockedPreparedDebugArg, usedReservedArgs: ['shouldnt', 'care'] });
+
+      await uut.launch(deviceId, bundleId, {});
+
+      expect(logger.warn).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('instrumentation child-process unplanned termination', () => {
+    it('should terminate instrumentation', async () => {
+      await uut.launch(deviceId, bundleId, []);
+
+      expect(childProcess.on).toHaveBeenCalledWith('close', expect.any(Function));
+
+      await invokeTerminationCallback();
+      expect(exec.interruptProcess).toHaveBeenCalledWith(instrumentationProcess);
+    });
+
+    it('should fail if termination callback breaks', async () => {
+      exec.interruptProcess.mockRejectedValue(new Error());
+
+      await uut.launch(deviceId, bundleId, []);
+
+      try {
+        await invokeTerminationCallback();
+        fail();
+      } catch(error) {}
+    });
+
+    it('should not terminate if dispatched twice', async () => {
+      await uut.launch(deviceId, bundleId, []);
+      await invokeTerminationCallback();
+      await invokeTerminationCallback();
+      expect(exec.interruptProcess).toHaveBeenCalledTimes(1);
+    });
+
+    it('should exec user\'s top-level custom termination callback', async () => {
+      await uut.launch(deviceId, bundleId, []);
+      await invokeTerminationCallback();
+      expect(userTerminationCallback).toHaveBeenCalled();
+    });
+  });
+
+  describe('user-initiated termination', () => {
+    it('should terminate upon a termination API call', async () => {
+      await uut.launch(deviceId, bundleId, []);
+      await uut.terminate();
+      expect(exec.interruptProcess).toHaveBeenCalledWith(instrumentationProcess);
+    });
+
+    it('should break if process interruption fails', async () => {
+      exec.interruptProcess.mockRejectedValue(new Error());
+      await uut.launch(deviceId, bundleId, []);
+
+      try {
+        await uut.terminate();
+        fail();
+      } catch(error) {}
+    });
+
+    it('should not terminate if not running', async () => {
+      await uut.terminate();
+      expect(exec.interruptProcess).not.toHaveBeenCalled();
+    });
+
+    it('should not terminate if already terminated', async () => {
+      await uut.launch(deviceId, bundleId, []);
+      await uut.terminate();
+      await uut.terminate();
+      expect(exec.interruptProcess).toHaveBeenCalledTimes(1);
+    });
+
+    it('should NOT exec user\'s top-level custom termination callback', async () => {
+      await uut.launch(deviceId, bundleId, []);
+      await uut.terminate();
+      expect(userTerminationCallback).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('instrumentation run-status querying', () => {
+    it('should be true when running', async () => {
+      await uut.launch(deviceId, bundleId, []);
+      expect(uut.isRunning()).toEqual(true);
+    });
+
+    it('should false if terminated', async () => {
+      await uut.launch(deviceId, bundleId, []);
+      await uut.terminate();
+      expect(uut.isRunning()).toEqual(false);
+    });
+  });
+
+  describe('instrumentation output-log tapping', () => {
+    it('should be made possible to tap into, with utf-8 encoding', async () => {
+      await uut.launch(deviceId, bundleId, []);
+      expect(childProcess.stdout.on).toHaveBeenCalledWith('data', expect.any(Function));
+      expect(childProcess.stdout.setEncoding).toHaveBeenCalledWith('utf8');
+
+      await invokeDataCallbackWith('mock data');
+      expect(logListenCallback).toHaveBeenCalledWith('mock data');
+    });
+  });
+
+  it('should allow for runtime setup of termination callback', async () => {
+    const runtimeCallback = jest.fn();
+
+    const Instrumentation = require('./Instrumentation');
+    uut = new Instrumentation(adb, logger, undefined, undefined);
+    uut.setTerminationFn(runtimeCallback);
+    await uut.launch(deviceId, bundleId, []);
+    await invokeTerminationCallback();
+
+    expect(runtimeCallback).toHaveBeenCalled();
+  });
+
+  it('should allow for clearing of termination callback', async () => {
+    uut.setTerminationFn(null);
+    await uut.launch(deviceId, bundleId, []);
+    await uut.terminate();
+    expect(userTerminationCallback).not.toHaveBeenCalled();
+  });
+
+  it('should allow for runtime setup of log-tapping callback', async () => {
+    const runtimeCallback = jest.fn();
+
+    const Instrumentation = require('./Instrumentation');
+    uut = new Instrumentation(adb, logger, undefined, undefined);
+    await uut.launch(deviceId, bundleId, []);
+
+    uut.setLogListenFn(runtimeCallback);
+    await invokeDataCallbackWith('mock data');
+
+    expect(runtimeCallback).toHaveBeenCalledWith('mock data');
+  });
+
+  it('should allow for clearing of log-tapping callback', async () => {
+    uut.setLogListenFn(null);
+    await uut.launch(deviceId, bundleId, []);
+    await invokeDataCallbackWith('data');
+    expect(logListenCallback).not.toHaveBeenCalled();
+  });
+
+  const extractDataCallback = () => childProcess.stdout.on.mock.calls[0][1];
+  const invokeDataCallbackWith = async (data) => {
+    const fn = extractDataCallback();
+    await fn(data);
+  }
+
+  const extractTerminationCallback = () => childProcess.on.mock.calls[0][1];
+  const invokeTerminationCallback = async () => {
+    const terminationFn = extractTerminationCallback();
+    await terminationFn();
+  }
+});