Repository: wix/Detox
Pull request #2019 (2020-04-26 20:10:21)

Adb install wtf
- [ ] This is a small change 
- [ ] This change is associated with issue #1857 and the solution has been agreed upon with maintainers.

---

**Description:**
Hopefully mitigate issues we've been having with ADB, namely the problem previously coined as  _adb jam_ (see #1857). The purposed strategy is - at least for google emulators, to get around usage of `adb install` in particular<sup>*</sup>, and rather push the binaries onto the device and install them "manually" using the package manager.

> _* we don't know for sure the problem's domain is restricted to `adb install`, but it does seem that - at least for the majority of cases, the problem resides primarily there._


Patch:
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index aa1dd601f..8aaab8e0d 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -9,6 +9,7 @@ const InvocationManager = invoke.InvocationManager;
 const ADB = require('./tools/ADB');
 const AAPT = require('./tools/AAPT');
 const APKPath = require('./tools/APKPath');
+const AppUninstallHelper = require('./tools/AppUninstallHelper');
 const DeviceDriverBase = require('../DeviceDriverBase');
 const DetoxApi = require('../../../android/espressoapi/Detox');
 const EspressoDetoxApi = require('../../../android/espressoapi/EspressoDetox');
@@ -64,40 +65,28 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async getBundleIdFromBinary(apkPath) {
-    return await this.aapt.getPackageName(getAbsoluteBinaryPath(apkPath));
+    const binaryPath = getAbsoluteBinaryPath(apkPath);
+    return await this.aapt.getPackageName(binaryPath);
   }
 
-  async installApp(deviceId, binaryPath, testBinaryPath) {
-    binaryPath = getAbsoluteBinaryPath(binaryPath);
+  async installApp(deviceId, _binaryPath, _testBinaryPath) {
+    const {
+      binaryPath,
+      testBinaryPath,
+    } = this._getInstallPaths(_binaryPath, _testBinaryPath);
     await this.adb.install(deviceId, binaryPath);
-    await this.adb.install(deviceId, testBinaryPath ? getAbsoluteBinaryPath(testBinaryPath) : this.getTestApkPath(binaryPath));
-  }
-
-  async pressBack(deviceId) {
-    await this.uiDevice.pressBack();
-  }
-
-  getTestApkPath(originalApkPath) {
-    const testApkPath = APKPath.getTestApkPath(originalApkPath);
-
-    if (!fs.existsSync(testApkPath)) {
-      throw new Error(`'${testApkPath}' could not be found, did you run './gradlew assembleAndroidTest' ?`);
-    }
-
-    return testApkPath;
+    await this.adb.install(deviceId, testBinaryPath);
   }
 
   async uninstallApp(deviceId, bundleId) {
     await this.emitter.emit('beforeUninstallApp', { deviceId, bundleId });
+    const uninstallHelper = new AppUninstallHelper(this.adb);
+    await uninstallHelper.uninstall(deviceId, bundleId);
+  }
 
-    if (await this.adb.isPackageInstalled(deviceId, bundleId)) {
-      await this.adb.uninstall(deviceId, bundleId);
-    }
-
-    const testBundle = `${bundleId}.test`;
-    if (await this.adb.isPackageInstalled(deviceId, testBundle)) {
-      await this.adb.uninstall(deviceId, testBundle);
-    }
+  async uninstallAppByApk(deviceId, apkPath) {
+    const bundleId = await this.getBundleIdFromBinary(apkPath);
+    await this.uninstallApp(deviceId, bundleId);
   }
 
   async launchApp(deviceId, bundleId, launchArgs, languageAndLocale) {
@@ -150,6 +139,10 @@ class AndroidDriver extends DeviceDriverBase {
       }
   }
 
+  async pressBack(deviceId) {
+    await this.uiDevice.pressBack();
+  }
+
   async sendToHome(deviceId, params) {
     await this.uiDevice.pressHome();
   }
@@ -252,6 +245,24 @@ class AndroidDriver extends DeviceDriverBase {
     await this.invocationManager.execute(call);
   }
 
+  _getInstallPaths(_binaryPath, _testBinaryPath) {
+    const binaryPath = getAbsoluteBinaryPath(_binaryPath);
+    const testBinaryPath = _testBinaryPath ? getAbsoluteBinaryPath(_testBinaryPath) : this._getTestApkPath(binaryPath);
+    return {
+      binaryPath,
+      testBinaryPath,
+    };
+  }
+
+  _getTestApkPath(originalApkPath) {
+    const testApkPath = APKPath.getTestApkPath(originalApkPath);
+
+    if (!fs.existsSync(testApkPath)) {
+      throw new Error(`'${testApkPath}' could not be found, did you run './gradlew assembleAndroidTest'?`);
+    }
+    return testApkPath;
+  }
+
   async _launchInstrumentationProcess(deviceId, bundleId, rawLaunchArgs) {
     const launchArgs = this._prepareLaunchArgs(rawLaunchArgs, true);
     const additionalLaunchArgs = this._prepareLaunchArgs({debug: false});
diff --git a/detox/src/devices/drivers/android/EmulatorDriver.js b/detox/src/devices/drivers/android/EmulatorDriver.js
index 24c022969..96b17fe70 100644
--- a/detox/src/devices/drivers/android/EmulatorDriver.js
+++ b/detox/src/devices/drivers/android/EmulatorDriver.js
@@ -6,14 +6,16 @@ const AndroidDriver = require('./AndroidDriver');
 const FreeEmulatorFinder = require('./emulator/FreeEmulatorFinder');
 const AVDValidator = require('./emulator/AVDValidator');
 const EmulatorLauncher = require('./emulator/EmulatorLauncher');
+const EmulatorVersionResolver = require('./emulator/EmulatorVersionResolver');
 const { EmulatorExec } = require('./tools/EmulatorExec');
 const EmulatorTelnet = require('./tools/EmulatorTelnet');
-const EmulatorVersionResolver = require('./emulator/EmulatorVersionResolver');
+const AppInstallHelper = require('./tools/AppInstallHelper');
 const DetoxRuntimeError = require('../../../errors/DetoxRuntimeError');
 const DeviceRegistry = require('../../DeviceRegistry');
 const environment = require('../../../utils/environment');
 const retry = require('../../../utils/retry');
 const log = require('../../../utils/logger').child({ __filename });
+const argparse = require('../../../utils/argparse');
 
 const DetoxEmulatorsPortRange = {
   min: 10000,
@@ -61,6 +63,22 @@ class EmulatorDriver extends AndroidDriver {
     return adbName;
   }
 
+  async installApp(deviceId, _binaryPath, _testBinaryPath) {
+    if (argparse.getArgValue('force-adb-install') === 'true') {
+      return await super.installApp(deviceId, _binaryPath, _testBinaryPath);
+    }
+
+    const {
+      binaryPath,
+      testBinaryPath,
+    } = this._getInstallPaths(_binaryPath, _testBinaryPath);
+
+    await this.uninstallAppByApk(deviceId, binaryPath);
+
+    const installHelper = new AppInstallHelper(this.adb);
+    await installHelper.install(deviceId, binaryPath, testBinaryPath);
+  }
+
   async cleanup(adbName, bundleId) {
     await this.deviceRegistry.disposeDevice(adbName);
     await super.cleanup(adbName, bundleId);
diff --git a/detox/src/devices/drivers/android/tools/AAPT.js b/detox/src/devices/drivers/android/tools/AAPT.js
index 39b973139..072022058 100644
--- a/detox/src/devices/drivers/android/tools/AAPT.js
+++ b/detox/src/devices/drivers/android/tools/AAPT.js
@@ -1,10 +1,7 @@
-const _ = require('lodash');
 const {getAaptPath} = require('../../../../utils/environment');
-const path = require('path');
 const exec = require('../../../../utils/exec').execWithRetriesAndLogs;
 const escape = require('../../../../utils/pipeCommands').escape.inQuotedString;
 const egrep = require('../../../../utils/pipeCommands').search.fragment;
-const fsext = require('../../../../utils/fsext');
 
 class AAPT {
   constructor() {
diff --git a/detox/src/devices/drivers/android/tools/ADB.js b/detox/src/devices/drivers/android/tools/ADB.js
index 0b38d6081..44bf9856d 100644
--- a/detox/src/devices/drivers/android/tools/ADB.js
+++ b/detox/src/devices/drivers/android/tools/ADB.js
@@ -104,6 +104,10 @@ class ADB {
     }
   }
 
+  /*async*/ remoteInstall(deviceId, path) {
+    return this.shell(deviceId, `pm install ${path}`);
+  }
+
   async uninstall(deviceId, appId) {
     await this.adbCmd(deviceId, `uninstall ${appId}`);
   }
@@ -216,6 +220,10 @@ class ADB {
     return this.spawn(deviceId, ['shell', shellCommand]);
   }
 
+  async push(deviceId, src, dst) {
+    await this.adbCmd(deviceId, `push "${src}" "${dst}"`);
+  }
+
   async pull(deviceId, src, dst = '') {
     await this.adbCmd(deviceId, `pull "${src}" "${dst}"`);
   }
diff --git a/detox/src/devices/drivers/android/tools/AppInstallHelper.js b/detox/src/devices/drivers/android/tools/AppInstallHelper.js
new file mode 100644
index 000000000..5e97e1788
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/AppInstallHelper.js
@@ -0,0 +1,36 @@
+const EMU_TEMP_PATH = '/data/local/tmp';
+const EMU_TEMP_INSTALL_PATH = `${EMU_TEMP_PATH}/detox`;
+
+class AppInstallHelper {
+  constructor(adb, tempDir = EMU_TEMP_INSTALL_PATH) {
+    this._adb = adb;
+    this._tempDir = tempDir;
+  }
+
+  async install(deviceId, appBinaryPath, testBinaryPath) {
+    await this._prepareTempDirOnDevice(deviceId);
+
+    const appBinaryPathOnTarget = this._getTargetBinaryPath(false);
+    await this._pushAndInstallBinary(deviceId, appBinaryPath, appBinaryPathOnTarget);
+
+    const testBinaryPathOnTarget = this._getTargetBinaryPath(true);
+    await this._pushAndInstallBinary(deviceId, testBinaryPath, testBinaryPathOnTarget);
+  }
+
+  async _prepareTempDirOnDevice(deviceId) {
+    await this._adb.shell(deviceId, `rm -fr ${this._tempDir}`);
+    await this._adb.shell(deviceId, `mkdir -p ${this._tempDir}`);
+  }
+
+  async _pushAndInstallBinary(deviceId, binaryPath, binaryPathOnTarget) {
+    await this._adb.push(deviceId, binaryPath, binaryPathOnTarget);
+    await this._adb.remoteInstall(deviceId, binaryPathOnTarget);
+  }
+
+  _getTargetBinaryPath(isTestBinary) {
+    const filename = isTestBinary ? 'Test.apk' : 'Application.apk';
+    return `${this._tempDir}/${filename}`;
+  }
+}
+
+module.exports = AppInstallHelper;
diff --git a/detox/src/devices/drivers/android/tools/AppUninstallHelper.js b/detox/src/devices/drivers/android/tools/AppUninstallHelper.js
new file mode 100644
index 000000000..345885899
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/AppUninstallHelper.js
@@ -0,0 +1,18 @@
+class AppUninstallHelper {
+  constructor(adb) {
+    this._adb = adb;
+  }
+
+  async uninstall(deviceId, bundleId) {
+    if (await this._adb.isPackageInstalled(deviceId, bundleId)) {
+      await this._adb.uninstall(deviceId, bundleId);
+    }
+
+    const testBundleId = `${bundleId}.test`;
+    if (await this._adb.isPackageInstalled(deviceId, testBundleId)) {
+      await this._adb.uninstall(deviceId, testBundleId);
+    }
+  }
+}
+
+module.exports = AppUninstallHelper;
diff --git a/docs/APIRef.DetoxCLI.md b/docs/APIRef.DetoxCLI.md
index f5d8a4409..2532c52ff 100644
--- a/docs/APIRef.DetoxCLI.md
+++ b/docs/APIRef.DetoxCLI.md
@@ -84,6 +84,7 @@ Initiating your test suite. <sup>[[1]](#notice-passthrough)</sup>
 | --device-launch-args | A list of passthrough-arguments to use when (if) devices (Android emulator / iOS simulator) are launched by Detox.<br />**Note: the value must be specified after an equal size (`=`) and inside quotes.** Usage example:<br />`--device-launch-args="-http-proxy http://1.1.1.1:8000 -no-snapshot-load"` |
 | --no-color                                    | Disable colors in log output |
 | --use-custom-logger | Use Detox' custom console-logging implementation, for logging Detox (non-device) logs. Disabling will fallback to node.js / test-runner's implementation (e.g. Jest / Mocha).<br />*Default: true* |
+| --force-adb-install | Due to problems with the `adb install` command on Android, Detox resorts to a different scheme for install APK's. Setting true will disable that and force usage of `adb install`, instead.<br/>This flag is temporary until the Detox way proves stable.<br/>*Default: false* |
 | --help                                        | Show help |
 
 ##### Notices


Test patch:
diff --git a/detox/local-cli/test.js b/detox/local-cli/test.js
index bf19c83a2..5821ddd2d 100644
--- a/detox/local-cli/test.js
+++ b/detox/local-cli/test.js
@@ -132,6 +132,12 @@ module.exports.builder = {
     group: 'Execution:',
     describe: `Use Detox' custom console-logging implementation, for logging Detox (non-device) logs. Disabling will fallback to node.js / test-runner's implementation (e.g. Jest / Mocha).`,
   },
+  'force-adb-install': {
+    boolean: true,
+    default: false,
+    group: 'Execution:',
+    describe: `Due to problems with the "adb install" command on Android, Detox resorts to a different scheme for install APK's. Setting true will disable that and force usage of "adb install", instead.`,
+  }
 };
 
 const collectExtraArgs = require('./utils/collectExtraArgs')(module.exports.builder);
@@ -231,6 +237,7 @@ module.exports.handler = async function test(program) {
         (program.artifactsLocation ? `--artifacts-location "${program.artifactsLocation}"` : ''),
         (program.deviceName ? `--device-name "${program.deviceName}"` : ''),
         (program.useCustomLogger ? `--use-custom-logger "${program.useCustomLogger}"` : ''),
+        (program.forceAdbInstall ? `--force-adb-install "${program.forceAdbInstall}"` : ''),
       ]),
       ...getPassthroughArguments(),
     ]).join(' ');
@@ -291,7 +298,8 @@ module.exports.handler = async function test(program) {
         'reportSpecs',
         'readOnlyEmu',
         'deviceLaunchArgs',
-        'useCustomLogger'
+        'useCustomLogger',
+        'forceAdbInstall',
       ]),
       DETOX_START_TIMESTAMP: Date.now(),
     };
diff --git a/detox/src/devices/drivers/android/AndroidDriver.test.js b/detox/src/devices/drivers/android/AndroidDriver.test.js
index b320134af..97b147dc5 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.test.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.test.js
@@ -3,10 +3,21 @@ describe('Android driver', () => {
   const deviceId = 'device-id-mock';
   const bundleId = 'bundle-id-mock';
 
+  const mockGetAbsoluteBinaryPathImpl = (x) => `absolutePathOf(${x})`;
+  const mockAPKPathGetTestApkPathImpl = (x) => `testApkPathOf(${x})`;
+
   let logger;
   let client;
+  let getAbsoluteBinaryPath;
+  let fs;
   let exec;
   beforeEach(() => {
+    jest.mock('fs', () => ({
+      existsSync: jest.fn(),
+      realpathSync: jest.fn(),
+    }));
+    fs = require('fs');
+
     jest.mock('../../../utils/encoding', () => ({
       encodeBase64: (x) => `base64(${x})`,
     }));
@@ -19,6 +30,15 @@ describe('Android driver', () => {
       InstrumentationLogsParser: mockInstrumentationLogsParserClass,
     }));
 
+    jest.mock('../../../utils/getAbsoluteBinaryPath', () =>
+      jest.fn().mockImplementation((x) => `absolutePathOf(${x})`),
+    );
+    getAbsoluteBinaryPath = require('../../../utils/getAbsoluteBinaryPath');
+
+    jest.mock('./tools/APKPath', () => ({
+      getTestApkPath: mockAPKPathGetTestApkPathImpl,
+    }));
+
     const mockLogger = {
       warn: jest.fn(),
     };
@@ -213,6 +233,49 @@ describe('Android driver', () => {
     });
   });
 
+  describe('App installation', () => {
+    const binaryPath = 'mock-bin-path';
+    const testBinaryPath = 'mock-test-bin-path';
+
+    it('should adb-install the app\'s binary', async () => {
+      await uut.installApp(deviceId, binaryPath, testBinaryPath);
+
+      expect(getAbsoluteBinaryPath).toHaveBeenCalledWith(binaryPath);
+      expect(uut.adb.install).toHaveBeenCalledWith(deviceId, mockGetAbsoluteBinaryPathImpl(binaryPath));
+    });
+
+    it('should adb-install the test binary', async () => {
+      await uut.installApp(deviceId, binaryPath, testBinaryPath);
+
+      expect(getAbsoluteBinaryPath).toHaveBeenCalledWith(binaryPath);
+      expect(uut.adb.install).toHaveBeenCalledWith(deviceId, mockGetAbsoluteBinaryPathImpl(testBinaryPath));
+    });
+
+    it('should resort to auto test-binary path resolution, if not specific', async () => {
+      const expectedTestBinPath = mockAPKPathGetTestApkPathImpl(mockGetAbsoluteBinaryPathImpl(binaryPath));
+
+      fs.existsSync.mockReturnValue(true);
+
+      await uut.installApp(deviceId, binaryPath, undefined);
+
+      expect(fs.existsSync).toHaveBeenCalledWith(expectedTestBinPath);
+      expect(uut.adb.install).toHaveBeenCalledWith(deviceId, expectedTestBinPath);
+    });
+
+    it('should throw if auto test-binary path resolves an invalid file', async () => {
+      const expectedTestBinPath = mockAPKPathGetTestApkPathImpl(mockGetAbsoluteBinaryPathImpl(binaryPath));
+
+      fs.existsSync.mockReturnValue(false);
+
+      try {
+        await uut.installApp(deviceId, binaryPath, undefined);
+        fail('Expected an error');
+      } catch (err) {
+        expect(err.message).toContain(`'${expectedTestBinPath}'`);
+      }
+    });
+  });
+
   describe('net-port reversing', () => {
     const deviceId = 1010;
     const port = 1337;
@@ -234,6 +297,7 @@ class mockADBClass {
     this.getInstrumentationRunner = jest.fn();
     this.reverse = jest.fn();
     this.reverseRemove = jest.fn();
+    this.install = jest.fn();
 
     this.adbBin = 'ADB binary mock';
   }
diff --git a/detox/src/devices/drivers/android/tools/ADB.test.js b/detox/src/devices/drivers/android/tools/ADB.test.js
index bcfac0f21..f6a6e7d81 100644
--- a/detox/src/devices/drivers/android/tools/ADB.test.js
+++ b/detox/src/devices/drivers/android/tools/ADB.test.js
@@ -1,4 +1,3 @@
-
 describe('ADB', () => {
   const adbBinPath = `/Android/sdk-mock/platform-tools/adb`;
 
@@ -130,9 +129,6 @@ describe('ADB', () => {
     });
   });
 
-  describe('lookup device', () => {
-  });
-
   it(`install`, async () => {
     await adb.install('emulator-5556', 'path inside "quotes" to/app');
 
@@ -167,6 +163,27 @@ describe('ADB', () => {
     expect(await adb.pidof('', 'com.google.android.ext.services')).toBe(NaN);
   });
 
+  it('push', async () => {
+    const deviceId = 'mockEmulator';
+    const sourceFile = '/mock-source/file.xyz';
+    const destFile = '/sdcard/file.abc';
+    await adb.push(deviceId, sourceFile, destFile);
+
+    expect(exec).toHaveBeenCalledWith(
+      expect.stringContaining(`-s mockEmulator push "${sourceFile}" "${destFile}"`),
+      undefined, undefined, expect.anything());
+  });
+
+  it('remote-install', async () => {
+    const deviceId = 'mockEmulator';
+    const binaryPath = '/mock-path/filename.mock';
+    await adb.remoteInstall(deviceId, binaryPath);
+
+    expect(exec).toHaveBeenCalledWith(
+      expect.stringContaining(`-s mockEmulator shell "pm install ${binaryPath}"`),
+      undefined, undefined, expect.anything());
+  });
+
   describe('unlockScreen', () => {
     const deviceId = 'mockEmulator';
 
diff --git a/detox/src/devices/drivers/android/tools/AppInstallHelper.test.js b/detox/src/devices/drivers/android/tools/AppInstallHelper.test.js
new file mode 100644
index 000000000..2d03374e9
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/AppInstallHelper.test.js
@@ -0,0 +1,86 @@
+const deviceId = 'mock-device-id';
+const deviceTempDir = '/mock-tmp-dir';
+const appBinaryPath = '/mock-app-binary-path/binary.apk';
+const testBinaryPath = '/mock-test-binary-path/test/binary.apk';
+
+describe('Android app installation helper', () => {
+  let adb;
+  class MockAdbClass {
+    constructor() {
+      this.shell = (...args) => adb.shell(...args);
+      this.push = (...args) => adb.push(...args);
+      this.remoteInstall = (...args) => adb.remoteInstall(...args);
+    }
+  }
+
+  beforeEach(() => {
+    const ADBMock = jest.genMockFromModule('./ADB');
+    adb = new ADBMock();
+    jest.mock('./ADB', () => MockAdbClass);
+  });
+
+  let uut;
+  beforeEach(() => {
+    const AppInstallHelper = require('./AppInstallHelper');
+    uut = new AppInstallHelper(adb, deviceTempDir);
+  });
+
+  it('should recreate a temp dir on the device', async () => {
+    await uut.install(deviceId, appBinaryPath, testBinaryPath);
+
+    expect(adb.shell).toHaveBeenCalledWith(deviceId, `rm -fr ${deviceTempDir}`);
+    expect(adb.shell).toHaveBeenCalledWith(deviceId, `mkdir -p ${deviceTempDir}`);
+  });
+
+  it('should throw if shell command fails', async () => {
+    adb.shell.mockRejectedValue(new Error('mocked error in adb-shell'));
+
+    try {
+      await uut.install(deviceId, appBinaryPath, testBinaryPath);
+      fail('expected to throw');
+    } catch (err) {}
+  });
+
+  it('should push app-binary file to temp dir on device', async () => {
+    await uut.install(deviceId, appBinaryPath, testBinaryPath);
+    expect(adb.push).toHaveBeenCalledWith(deviceId, appBinaryPath, '/mock-tmp-dir/Application.apk');
+  });
+
+  it('should push test-binary file to temp dir on device', async () => {
+    await uut.install(deviceId, appBinaryPath, testBinaryPath);
+    expect(adb.push).toHaveBeenCalledWith(deviceId, testBinaryPath, '/mock-tmp-dir/Test.apk');
+  });
+
+  it('should fail if adb-push fails', async () => {
+    adb.push.mockRejectedValue(new Error('mocked error in adb-push'));
+
+    try {
+      await uut.install(deviceId, appBinaryPath, testBinaryPath);
+      fail('expected to throw');
+    } catch(err) {}
+  });
+
+  it('should remote-install both binaries via shell', async () => {
+    await uut.install(deviceId, appBinaryPath, testBinaryPath);
+    expect(adb.remoteInstall).toHaveBeenCalledWith(deviceId, '/mock-tmp-dir/Application.apk');
+    expect(adb.remoteInstall).toHaveBeenCalledWith(deviceId, '/mock-tmp-dir/Test.apk');
+  });
+
+  it('should fail if remote-install fails', async () => {
+    adb.remoteInstall.mockRejectedValue(new Error('mocked error in remote-install'));
+
+    try {
+      await uut.install(deviceId, appBinaryPath, testBinaryPath);
+      fail('expected to throw');
+    } catch(err) {}
+  });
+
+  it('should use a default temp-dir', async () => {
+    const AppInstallHelper = require('./AppInstallHelper');
+    uut = new AppInstallHelper(adb, undefined);
+
+    await uut.install(deviceId, appBinaryPath, testBinaryPath);
+
+    expect(adb.push).toHaveBeenCalledWith(deviceId, appBinaryPath, '/data/local/tmp/detox/Application.apk');
+  });
+});
diff --git a/detox/src/devices/drivers/android/tools/AppUninstallHelper.test.js b/detox/src/devices/drivers/android/tools/AppUninstallHelper.test.js
new file mode 100644
index 000000000..3ff8be1ac
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/AppUninstallHelper.test.js
@@ -0,0 +1,76 @@
+const deviceId = 'mock-device-id';
+const bundleId = 'mock-bundle-id';
+const testBundleId = 'mock-bundle-id.test';
+
+describe('Android app uninstall helper', () => {
+  let adb;
+  class MockAdbClass {
+    constructor() {
+      this.uninstall = (...args) => adb.uninstall(...args);
+      this.isPackageInstalled = (...args) => adb.isPackageInstalled(...args);
+    }
+  }
+
+  beforeEach(() => {
+    const ADBMock = jest.genMockFromModule('./ADB');
+    adb = new ADBMock();
+    adb.isPackageInstalled.mockResolvedValue(true);
+
+    jest.mock('./ADB', () => MockAdbClass);
+  });
+
+  let uut;
+  beforeEach(() => {
+    const AppUninstallHelper = require('./AppUninstallHelper');
+    uut = new AppUninstallHelper(adb);
+  });
+
+  it('should uninstall the app\'s binary using adb', async () => {
+    await uut.uninstall(deviceId, bundleId);
+    expect(adb.uninstall).toHaveBeenCalledWith(deviceId, bundleId);
+  });
+
+  it('should fail if app uninstall fails', async () => {
+    adb.uninstall.mockRejectedValue(new Error('mocked error in adb.uninstall'));
+    try {
+      await uut.uninstall(deviceId, bundleId);
+      fail('expected an error');
+    } catch (err) {}
+  });
+
+  it('should avoid uninstalling app if not already installed', async () => {
+    adb.isPackageInstalled.mockResolvedValue(false);
+
+    await uut.uninstall(deviceId, bundleId);
+
+    expect(adb.isPackageInstalled).toHaveBeenCalledWith(deviceId, bundleId);
+    expect(adb.uninstall).not.toHaveBeenCalledWith(deviceId, bundleId);
+  });
+
+  it('should uninstall the test binary using adb', async () => {
+    await uut.uninstall(deviceId, bundleId);
+    expect(adb.uninstall).toHaveBeenCalledWith(deviceId, testBundleId);
+  });
+
+  it('should fail if test binary uninstall fails', async () => {
+    adb.uninstall
+      .mockResolvedValueOnce(true)
+      .mockRejectedValueOnce(new Error('mocked error in adb.uninstall'));
+
+    try {
+      await uut.uninstall(deviceId, bundleId);
+      fail('expected an error');
+    } catch (err) {}
+  });
+
+  it('should avoid uninstalling test binary if not already installed', async () => {
+    adb.isPackageInstalled
+      .mockResolvedValueOnce(true)
+      .mockResolvedValueOnce(false);
+
+    await uut.uninstall(deviceId, bundleId);
+
+    expect(adb.isPackageInstalled).toHaveBeenCalledWith(deviceId, testBundleId);
+    expect(adb.uninstall).not.toHaveBeenCalledWith(deviceId, testBundleId);
+  });
+});