Repository: wix/Detox
Pull request #2169 (2020-06-30 12:03:28)

Find devices for 'android.attached' based on 'adbName' pattern
For running parallel tests on attached devices

<!---
Thank you for contributing!
Before submitting a pull request that implements a new functionality or fixing a major bug,
please open an issue first and propose your solution. This way we can discuss before time is 
spent on a solution that may not work for us.

Please check the correct option in the below list and delete the irrelevant one.
For the second option, please fill the issue where the solution has been discussed.
-->

- [x] This is a small change 

---

**Description:**

I want to be able to run parallel tests on multiple attached Android devices (e.g. Genymotion). Currently, the `android.attached` configuration type accepts only one `adbName`.

noomorph: @d4vidi, let's look together into this. 
noomorph: Preliminary speaking, looks good to me, except maybe for 1-2 very minor things. I'll give it a try locally. 
d4vidi: nice one - hope to have a look soon!
DmytroKost: I noticed that when enabling artifacts (logs, videos), Detox fails to save them when running in parallel mode.

```
tests_1                | detox[188] DEBUG: [exec.js/SPAWN_CMD, #14] [pid=260] /opt/sdk/platform-tools/adb shell "logcat --pid=1930 -f /sdcard/62339204_0.log"
tests_1                | detox[188] DEBUG: [DetoxServer.js/CANNOT_FORWARD] role=testee not connected, cannot fw action (sessionId=c2967c73-58e6-887c-834b-4cba63136e9b)
tests_1                | detox[188] ERROR: [exec.js/SPAWN_STDERR, #14] error: more than one device/emulator


tests_1                | detox[174] ERROR: [exec.js/EXEC_FAIL, #22] ""/opt/sdk/platform-tools/adb"  shell du /sdcard/62743283_0.mp4" failed with code = 1, stdout and stderr:
tests_1                | 
tests_1                | detox[174] ERROR: [exec.js/EXEC_FAIL, #22] 
tests_1                | detox[174] ERROR: [exec.js/EXEC_FAIL, #22] error: more than one device/emulator
```

From the output I can see that it doesn't pass `-s device_name`.
That doesn't happen when running tests on local emulators. Did I miss something? I haven't yet spent time investigating the cause of the issue.
d4vidi: @DmytroKost I've revisited this now and I feel there are 2 things to consider.

1. Implementation
Should we decide to accept this as a _feature_ (and I personally think it could definitely be the right evolution for _attached_ devices support!), there are two drawbacks in putting support inside a driver -- bloatedness of drivers and unit-test coverage. Therefore, we could only accept this after refactoring the device lookup onto a separate, full-tested (tdd) finder class - much like [FreeEmulatorFinder](https://github.com/wix/Detox/blob/51b51c94e64dfa0322cfe19da2695d4a38b9cac8/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.js#L6), which is the equivalent one for emulators. See how it's tested, [here](https://github.com/wix/Detox/blob/ea973989ea762dc3270231f70f7f377e1b910b8a/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js#L1).

2. Configuration contract
I'm still not certain whether an adb-names array is the right way to represent this data. For that, I'll need @noomorph's insights...
noomorph: @d4vidi, I'm about to review this today or maximum — tomorrow. 

As for your question 2 — well, I was considering also RegExp-based solution. This way we can elegantly solve many cases:

```
"emulator-\\d+" // use only spawned emulators 
".*" // use anything that is free
"device_1|device_2|device_3" // use this kinda array of devices
``` 

What do you think?
DmytroKost: @d4vidi Yes, I can do that. Any insight into why artifacts don't work properly with multi-attached devices? https://github.com/wix/Detox/pull/2169#issuecomment-653050857
noomorph: Cannot deliver my promise to review it the PR yesterday, obviously, have to repromise that for tomorrow.
Cannot really say without a deeper look, @DmytroKost. Sorry for the long wait, thanks for the patience.
DmytroKost: @d4vidi @noomorph I've made the following changes:

1. Implemented `FreeAdbDeviceFinder` and added unit tests for it.
1. Extracted a parent class`FreeDeviceFinderBase` from `FreeEmulatorFinder` and reused it for  `FreeAdbDeviceFinder`. Same for the unit tests.
1. Implemented lookup strategy based or `adbName` being a regular expression.
1. Fixed the problem with artifacts by emitting a `bootDevice` event with the `devideId`.
coveralls: ## Pull Request Test Coverage Report for [Build 2435](https://coveralls.io/builds/31968172)

* **23** of **41**   **(56.1%)**  changed or added relevant lines in **4** files are covered.
* **1135** unchanged lines in **43** files lost coverage.
* Overall coverage decreased (**-26.2%**) to **59.359%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [detox/src/devices/drivers/android/AttachedAndroidDriver.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Fdevices%2Fdrivers%2Fandroid%2FAttachedAndroidDriver.js#L3) | 0 | 18 | 0.0%
<!-- | **Total:** | **23** | **41** | **56.1%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [detox/src/android/espressoapi/UIAutomator.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Fandroid%2Fespressoapi%2FUIAutomator.js#L11) | 2 | 0% |
| [detox/src/utils/debug.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Futils%2Fdebug.js#L1) | 2 | 0% |
| [detox/src/utils/sleep.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Futils%2Fsleep.js#L2) | 2 | 0% |
| [detox/src/android/espressoapi/DetoxViewActions.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Fandroid%2Fespressoapi%2FDetoxViewActions.js#L11) | 4 | 0% |
| [detox/src/artifacts/video/VideoArtifactPlugin.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Fartifacts%2Fvideo%2FVideoArtifactPlugin.js#L1) | 5 | 0% |
| [detox/src/android/espressoapi/Detox.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Fandroid%2Fespressoapi%2FDetox.js#L27) | 6 | 0% |
| [detox/src/artifacts/video/ADBScreenrecorderArtifact.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Fartifacts%2Fvideo%2FADBScreenrecorderArtifact.js#L1) | 6 | 0% |
| [detox/src/artifacts/video/ADBScreenrecorderPlugin.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Fartifacts%2Fvideo%2FADBScreenrecorderPlugin.js#L1) | 6 | 0% |
| [detox/src/devices/drivers/android/AttachedAndroidDriver.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Fdevices%2Fdrivers%2Fandroid%2FAttachedAndroidDriver.js#L1) | 6 | 0% |
| [detox/src/android/espressoapi/DetoxAssertion.js](https://coveralls.io/builds/31968172/source?filename=detox%2Fsrc%2Fandroid%2Fespressoapi%2FDetoxAssertion.js#L9) | 7 | 0% |
<!-- | **Total:** | **1135** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/31968172/badge)](https://coveralls.io/builds/31968172) |
| :-- | --: |
| Change from base [Build 1294](https://coveralls.io/builds/31937295): |  -26.2% |
| Covered Lines: | 2300 |
| Relevant Lines: | 3858 |

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

noomorph: @DmytroKost , if you don't mind, could you remerge master again into your branch, please? 
DmytroKost: > @DmytroKost , if you don't mind, could you remerge master again into your branch, please?

Done.
noomorph: @DmytroKost, I think it makes sense to add:

1. A mention about the RegExp matching in the docs:

    a. [Introduction.Android.md](https://github.com/wix/Detox/blob/cbf6966b640948db7c92035229294a6621e5319c/docs/Introduction.Android.md#2-apply-detox-configuration)

        Connect to already-attached android device. The device should be listed in the output of adb devices command under provided name. Use this type to connect to Genymotion emulator.

    b. [APIRef.Configuration.md](https://github.com/wix/Detox/blob/78bc945bc5dbf15c116cceb790c8ae5c9aac9af4/docs/APIRef.Configuration.md#device-configuration) — see example with `android.att.release`, maybe you could add right there an example of how you can specify multiple devices.

2. An example in the configs:
https://github.com/wix/Detox/blob/cbf6966b640948db7c92035229294a6621e5319c/examples/demo-react-native/detox.config.js

At the very least, let's add some `emulator-.*` configuration with `android.attached` type, that can be used for testing that your PR works. I wonder, maybe it will make sense to start running a smoke test with that attached configuration in CI, @d4vidi , what do you think?
DmytroKost: @noomorph Updated the docs and the `detox.config.js` example as requested.
noomorph: @DmytroKost , see my suggestions in https://github.com/DmytroKost/Detox/pull/1
d4vidi: Sorry, 'been overly booked in the last couple of days. I'll try my best to review asap.
DmytroKost: @noomorph Thank you for the feedback. I'll wait for the review from @d4vidi before making changes so that I don't have to go back and forth.
d4vidi: 'Promise to review this this week, probably within a day or 2!
DmytroKost: > We are approaching the finish line, let's make one more effort and we'll be there. 👍 Good job.

Effort made :)
noomorph: What's left is:

1. To decide finally what about those examples in configs and docs - we have two disputable places, AFAIC. That should be very quick to solve.
2. My small request to make `inferDeviceType` privateish `_inferDeviceType`.
3. @d4vidi had some unresolved comments for `FreeDeviceFinderBase.js ` and `FreeEmulatorFinder.test.js`, but honestly speaking, the files look good enough for me. Do we need anything else?

For me, looks like 99% is done.
d4vidi: Well it appears many things have changes here since I checked last... 😆 
I'll go over it again from scratch
d4vidi: Our CI is acting up. Ignore the results for now. I'll keep you posted.
d4vidi: So besides my minor reply to one of the comments, here are the 2 current concerns:
1. Seems that somehow the PR is wreaking havoc on our CI - [2491](https://jenkins-oss.wixpress.com/job/multi-detox-pr/2491/), [2490](https://jenkins-oss.wixpress.com/job/multi-detox-pr/2490/). We must figure out what's going on there before moving forward, of course.
2. I do not currently agree with the current unit-testing approach over the `FreeXYZFree` class family. It doesn't make sense to me that the tests associated with the concrete classes (`FreeEmulatorFinder`, `FreeAdbDeviceFinder`) only cover the logic of the protected `isDeviceMatching()` method <sup>*</sup>. My opinion is that unit tests should only invoke public API's, and should be as inheritance+private API's agnostic as possible. In that sense, The coverage over `FreeDeviceFinderBase` is completely unnecessary and should be cloned and transferred (twice, separately) to the unit tests of each of the concrete class' tests.

@DmytroKost if you find 2 to be too heavy at this point, let's focus and 1 and we'll sort it out later.

---
\* should be changed to `_isDeviceMatching`

DmytroKost: @d4vidi I can remove unit tests for `FreeEmulatorFinder` and `FreeAdbDeviceFinder`. Both implementations of `isDeviceMatching` are straight-forward and the value of unit-testing them is low.

Any clue why CI fails? Those failing E2E tests look flaky to me. Otherwise, why would different tests fail from different runs?
d4vidi: I find the tests strictly for `isDeviceMatching` useless for a different reason -- because it is a protected method. They should be bundled with calls to `findFreeDevice`.
d4vidi: As per CI - trying to figure out myself right now. Away from the scope of this PR, the builds seem stable...
DmytroKost: > I find the tests strictly for `isDeviceMatching` useless for a different reason -- because it is a protected method. They should be bundled with calls to `findFreeDevice`.

I'm not sure what you're suggesting
noomorph: @DmytroKost , I think @d4vidi suggests to copy `FreeDeviceFinderBase.test.js` twice and delete. The copies will be called `FreeDeviceFinder.test.js` and `FreeEmulatorFinder.test.js`. Both copies will run very similar test suites except for a few emulator-specific and device-specific cases, e.g.: `FreeDeviceFinder` is indifferent about whether a device is an emulator or not —  it just should match the specified regular expression, while `FreeEmulatorFinder` cares exclusively about emulators and a correct AVD name; however, both test suites will make sure that those finders omit offline and busy devices — that is the common part for both implementations.

I hope I've managed to convey the message.
d4vidi: @DmytroKost please rebase your branch over Detox' latest master. I believe this should solve CI issues.
DmytroKost: @d4vidi I merged `FreeAdbDeviceFinder` to `FreeDeviceFinder` with the following changes:

-  Added an underscore to `_isDeviceMatching`
-  Renamed `FreeDeviceFinderBase` to `FreeDeviceFinder`
-  Removed `FreeAdbDeviceFinder`
-  `FreeDeviceFinder._isDeviceMatching` tests candidate's adbName against a regex by default (copied the implementation from `FreeAdbDeviceFinder._isDeviceMatching`)
-  `FreeEmulatorFinder` extends `FreeDeviceFinder` and overrides `_isDeviceMatching`
-  Moved the unit test from `FreeAdbDeviceFinder.test.js` to `FreeDeviceFinder.test.js` and removed `FreeAdbDeviceFinder.test.js`
-  `FreeEmulatorFinder.test.js` tests only the `_isDeviceMatching` override through the public method `findFreeDevice`

DmytroKost: @d4vidi Rebase on master
d4vidi: Green at last!!!
noomorph: ![WOOT!!](https://media.giphy.com/media/JsPowYod2p20JBd4qf/giphy.gif)

Patch:
diff --git a/detox/package.json b/detox/package.json
index a0358d163..8352134bb 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -101,6 +101,8 @@
       "src/utils/sleep.js",
       "AAPT.js",
       "ADB.js",
+      "DeviceHandle.js",
+      "EmulatorHandle.js",
       "fsext.js",
       "debug.js",
       "src/ios/earlgreyapi",
diff --git a/detox/src/devices/DriverRegistry.js b/detox/src/devices/DriverRegistry.js
index 9e63691ab..0c7dcfc58 100644
--- a/detox/src/devices/DriverRegistry.js
+++ b/detox/src/devices/DriverRegistry.js
@@ -27,8 +27,8 @@ class DriverRegistry {
 DriverRegistry.default = new DriverRegistry({
   'ios.none': require('./drivers/ios/IosDriver'),
   'ios.simulator': require('./drivers/ios/SimulatorDriver'),
-  'android.emulator': require('./drivers/android/EmulatorDriver'),
-  'android.attached': require('./drivers/android/AttachedAndroidDriver'),
+  'android.emulator': require('./drivers/android/emulator/EmulatorDriver'),
+  'android.attached': require('./drivers/android/attached/AttachedAndroidDriver'),
 });
 
 module.exports = DriverRegistry;
diff --git a/detox/src/devices/drivers/DeviceDriverBase.js b/detox/src/devices/drivers/DeviceDriverBase.js
index d1215d8a7..e9b426731 100644
--- a/detox/src/devices/drivers/DeviceDriverBase.js
+++ b/detox/src/devices/drivers/DeviceDriverBase.js
@@ -19,7 +19,7 @@ class DeviceDriverBase {
   }
 
   async acquireFreeDevice(deviceQuery) {
-    await Promise.resolve('');
+    return await Promise.resolve('');
   }
 
   async prepare() {
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index 1757280d9..1a9f61810 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -24,6 +24,10 @@ const temporaryPath = require('../../../artifacts/utils/temporaryPath');
 const sleep = require('../../../utils/sleep');
 const retry = require('../../../utils/retry');
 const getAbsoluteBinaryPath = require('../../../utils/getAbsoluteBinaryPath');
+const DeviceRegistry = require('../../DeviceRegistry');
+const environment = require('../../../utils/environment');
+
+const ALLOCATE_DEVICE_LOG_EVT = 'ALLOCATE_DEVICE';
 
 class AndroidDriver extends DeviceDriverBase {
   constructor(config) {
@@ -40,6 +44,10 @@ class AndroidDriver extends DeviceDriverBase {
     this.devicePathBuilder = new AndroidDevicePathBuilder();
 
     this.instrumentation = new MonitoredInstrumentation(this.adb, logger);
+
+    this.deviceRegistry = new DeviceRegistry({
+      lockfilePath: environment.getDeviceLockFilePathAndroid()
+    });
   }
 
   declareArtifactPlugins() {
@@ -59,6 +67,21 @@ class AndroidDriver extends DeviceDriverBase {
     return await this.aapt.getPackageName(binaryPath);
   }
 
+  async allocateDevice(deviceQuery) {
+    log.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Trying to allocate a device based on "${deviceQuery}"`);
+    const adbName = await this.deviceRegistry.allocateDevice(() => this.doAllocateDevice(deviceQuery));
+    log.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Settled on ${adbName}`);
+    return adbName;
+  }
+
+  /**
+   * @protected
+   * @return {Promise<string>} adbName of a free matching device
+   */
+  async doAllocateDevice(deviceQuery) {
+    throw Error('Not implemented!');
+  }
+
   async installApp(deviceId, _binaryPath, _testBinaryPath) {
     const {
       binaryPath,
@@ -131,6 +154,7 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async cleanup(deviceId, bundleId) {
+    await this.deviceRegistry.disposeDevice(deviceId);
     await this._terminateInstrumentation();
     await super.cleanup(deviceId, bundleId);
   }
diff --git a/detox/src/devices/drivers/android/AttachedAndroidDriver.js b/detox/src/devices/drivers/android/AttachedAndroidDriver.js
deleted file mode 100644
index 159cc9ba8..000000000
--- a/detox/src/devices/drivers/android/AttachedAndroidDriver.js
+++ /dev/null
@@ -1,37 +0,0 @@
-const _ = require('lodash');
-const AndroidDriver = require('./AndroidDriver');
-const DetoxRuntimeError = require('../../../errors/DetoxRuntimeError');
-
-class AttachedAndroidDriver extends AndroidDriver {
-
-  constructor(config) {
-    super(config);
-    this._name = 'Unnamed Android Device';
-  }
-
-  get name() {
-    return this._name;
-  }
-
-  async acquireFreeDevice(deviceQuery) {
-    const adbName = _.isPlainObject(deviceQuery) ? deviceQuery.adbName : deviceQuery;
-    const { devices, stdout } = await this.adb.devices();
-
-    if (!devices.some(d => d.adbName === adbName)) {
-      throw new DetoxRuntimeError({
-        message: `Could not find '${adbName}' on the currently ADB attached devices:`,
-        debugInfo: stdout,
-        hint: `Make sure your device is connected.\n` +
-              `You can also try restarting adb with 'adb kill-server && adb start-server'.`
-      });
-    }
-
-    await this.adb.apiLevel(adbName);
-    await this.adb.unlockScreen(adbName);
-    this._name = adbName;
-
-    return adbName;
-  }
-}
-
-module.exports = AttachedAndroidDriver;
diff --git a/detox/src/devices/drivers/android/attached/AttachedAndroidDriver.js b/detox/src/devices/drivers/android/attached/AttachedAndroidDriver.js
new file mode 100644
index 000000000..a27c56f6f
--- /dev/null
+++ b/detox/src/devices/drivers/android/attached/AttachedAndroidDriver.js
@@ -0,0 +1,35 @@
+const _ = require('lodash');
+const AndroidDriver = require('../AndroidDriver');
+const FreeDeviceFinder = require('../tools/FreeDeviceFinder');
+
+class AttachedAndroidDriver extends AndroidDriver {
+  constructor(config) {
+    super(config);
+    
+    this.freeDeviceFinder = new FreeDeviceFinder(this.adb, this.deviceRegistry);
+    this._name = 'Unnamed Android Device';
+  }
+
+  get name() {
+    return this._name;
+  }
+
+  async acquireFreeDevice(deviceQuery) {
+    const adbNamePattern = _.isPlainObject(deviceQuery) ? deviceQuery.adbName : deviceQuery;
+
+    const adbName = await this.allocateDevice(adbNamePattern);
+
+    await this.adb.apiLevel(adbName);
+    await this.adb.unlockScreen(adbName);
+    await this.emitter.emit('bootDevice', { coldBoot: false, deviceId: adbName, type: 'device' });
+
+    this._name = adbName;
+    return adbName;
+  }
+
+  async doAllocateDevice(deviceQuery) {
+    return await this.freeDeviceFinder.findFreeDevice(deviceQuery);
+  }
+}
+
+module.exports = AttachedAndroidDriver;
diff --git a/detox/src/devices/drivers/android/EmulatorDriver.js b/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
similarity index 70%
rename from detox/src/devices/drivers/android/EmulatorDriver.js
rename to detox/src/devices/drivers/android/emulator/EmulatorDriver.js
index 4f533f618..84b50f198 100644
--- a/detox/src/devices/drivers/android/EmulatorDriver.js
+++ b/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
@@ -2,36 +2,31 @@ const _ = require('lodash');
 const fs = require('fs');
 const path = require('path');
 const ini = require('ini');
-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('./exec/EmulatorExec');
-const EmulatorTelnet = require('./tools/EmulatorTelnet');
-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 AndroidDriver = require('../AndroidDriver');
+const FreeEmulatorFinder = require('./FreeEmulatorFinder');
+const AVDValidator = require('./AVDValidator');
+const EmulatorLauncher = require('./EmulatorLauncher');
+const EmulatorVersionResolver = require('./EmulatorVersionResolver');
+const { EmulatorExec } = require('../exec/EmulatorExec');
+const EmulatorTelnet = require('../tools/EmulatorTelnet');
+const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
+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,
   max: 20000
 };
 
-const ALLOCATE_DEVICE_LOG_EVT = 'ALLOCATE_DEVICE';
 const EMU_BIN_STABLE_SKIN_VER = 28;
 
 class EmulatorDriver extends AndroidDriver {
   constructor(config) {
     super(config);
 
-    this.deviceRegistry = new DeviceRegistry({
-      lockfilePath: environment.getDeviceLockFilePathAndroid(),
-    });
-
+    this.freeDeviceFinder = new FreeEmulatorFinder(this.adb, this.deviceRegistry);
     const emulatorExec = new EmulatorExec();
     this._emuVersionResolver = new EmulatorVersionResolver(emulatorExec);
     this._emuLauncher = new EmulatorLauncher(emulatorExec);
@@ -42,7 +37,7 @@ class EmulatorDriver extends AndroidDriver {
   }
 
   get name() {
-    return this._name
+    return this._name;
   }
 
   async acquireFreeDevice(deviceQuery) {
@@ -51,7 +46,7 @@ class EmulatorDriver extends AndroidDriver {
     await this._avdValidator.validate(avdName);
     await this._fixEmulatorConfigIniSkinNameIfNeeded(avdName);
 
-    const adbName = await this._allocateDevice(avdName);
+    const adbName = await this.allocateDevice(avdName);
 
     await this._boot(avdName, adbName);
 
@@ -63,6 +58,11 @@ class EmulatorDriver extends AndroidDriver {
     return adbName;
   }
 
+  async doAllocateDevice(deviceQuery) {
+    const freeEmulatorAdbName = await this.freeDeviceFinder.findFreeDevice(deviceQuery);
+    return freeEmulatorAdbName || this._createDevice();
+  }
+
   async installApp(deviceId, _binaryPath, _testBinaryPath) {
     if (argparse.getArgValue('force-adb-install') === 'true') {
       return await super.installApp(deviceId, _binaryPath, _testBinaryPath);
@@ -75,11 +75,6 @@ class EmulatorDriver extends AndroidDriver {
     await this.appInstallHelper.install(deviceId, binaryPath, testBinaryPath);
   }
 
-  async cleanup(adbName, bundleId) {
-    await this.deviceRegistry.disposeDevice(adbName);
-    await super.cleanup(adbName, bundleId);
-  }
-
   /*async*/ binaryVersion() {
     return this._emuVersionResolver.resolve();
   }
@@ -94,7 +89,7 @@ class EmulatorDriver extends AndroidDriver {
     }
 
     await this._waitForBootToComplete(adbName);
-    await this.emitter.emit('bootDevice', { coldBoot, deviceId: adbName, type: adbName });
+    await this.emitter.emit('bootDevice', { coldBoot, deviceId: adbName, type: avdName });
   }
 
   async _waitForBootToComplete(deviceId) {
@@ -150,20 +145,7 @@ class EmulatorDriver extends AndroidDriver {
     }
   }
 
-  async _allocateDevice(avdName) {
-    log.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Trying to allocate a device based on ${avdName}`);
-    const adbName = await this.deviceRegistry.allocateDevice(() => this._doAllocateDevice(avdName));
-    log.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Settled on ${adbName}`);
-    return adbName;
-  }
-
-  async _doAllocateDevice(avdName) {
-    const freeEmulatorFinder = new FreeEmulatorFinder(this.adb, this.deviceRegistry, avdName);
-    const freeEmulatorAdbName = await freeEmulatorFinder.findFreeDevice();
-    return freeEmulatorAdbName || this._createDevice();
-  }
-
-  async _createDevice() {
+  _createDevice() {
     const {min, max} = DetoxEmulatorsPortRange;
     let port = Math.random() * (max - min) + min;
     port = port & 0xFFFFFFFE; // Should always be even
diff --git a/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.js b/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.js
index ac550dde9..2f42ee584 100644
--- a/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.js
+++ b/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.js
@@ -1,33 +1,11 @@
-const AdbDevicesHelper = require('../tools/AdbDevicesHelper');
-const log = require('../../../../utils/logger').child({ __filename });
-
-const DEVICE_LOOKUP_LOG_EVT = 'DEVICE_LOOKUP';
-
-class FreeEmulatorFinder {
-  constructor(adb, deviceRegistry, avdName) {
-    this._adbDevicesHelper = new AdbDevicesHelper(adb);
-    this._deviceRegistry = deviceRegistry;
-    this._avdName = avdName;
-
-    this._matcherFn = this._matcherFn.bind(this);
-  }
-
-  async findFreeDevice() {
-    return await this._adbDevicesHelper.lookupDevice(this._matcherFn);
-  }
-
-  async _matcherFn(candidate) {
-    const isEmulator = candidate.type === 'emulator';
-    const isBusy = this._deviceRegistry.isDeviceBusy(candidate.adbName);
-
-    if (isEmulator && !isBusy) {
-      if (await candidate.queryName() === this._avdName) {
-        log.debug({ event: DEVICE_LOOKUP_LOG_EVT }, `Found ${candidate.adbName}!`);
-        return true;
-      }
-      log.debug({ event: DEVICE_LOOKUP_LOG_EVT }, `${candidate.adbName} is available but AVD is different`);
-    }
-    return false;
+const FreeDeviceFinder = require('../tools/FreeDeviceFinder');
+
+class FreeEmulatorFinder extends FreeDeviceFinder {
+  /**
+   * @override
+   */
+  async _isDeviceMatching(candidate, avdName) {
+    return candidate.type === 'emulator' && (await candidate.queryName()) === avdName;
   }
 }
 
diff --git a/detox/src/devices/drivers/android/exec/ADB.js b/detox/src/devices/drivers/android/exec/ADB.js
index ca98165ef..8943c487f 100644
--- a/detox/src/devices/drivers/android/exec/ADB.js
+++ b/detox/src/devices/drivers/android/exec/ADB.js
@@ -2,9 +2,10 @@ const _ = require('lodash');
 const {execWithRetriesAndLogs, spawnAndLog} = require('../../../../utils/exec');
 const {escape} = require('../../../../utils/pipeCommands');
 const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
-const EmulatorTelnet = require('../tools/EmulatorTelnet');
 const {getAdbPath} = require('../../../../utils/environment');
 const {encodeBase64} = require('../../../../utils/encoding');
+const DeviceHandle = require('../tools/DeviceHandle');
+const EmulatorHandle = require('../tools/EmulatorHandle');
 
 class ADB {
   constructor() {
@@ -294,18 +295,6 @@ class ADB {
     const serial = deviceId ? ['-s', deviceId] : [];
     return spawnAndLog(this.adbBin, [...serial, ...params], spawnOptions);
   }
-
-  static inferDeviceType(adbName) {
-    if (adbName.startsWith('emulator-')) {
-      return 'emulator';
-    }
-
-    if ((/^((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:)){4}[0-9]{4}/.test(adbName))) {
-      return 'genymotion';
-    }
-
-    return 'device';
-  }
 }
 
 function assertEmulatorHasPort(device, stdout) {
@@ -326,40 +315,4 @@ function assertEmulatorHasPort(device, stdout) {
   throw new Error(errorMessage);
 }
 
-class DeviceHandle {
-  constructor(deviceString) {
-    const [adbName, status] = deviceString.split('\t');
-    this.type = ADB.inferDeviceType(adbName);
-    this.adbName = adbName;
-    this.status = status;
-  }
-}
-
-class EmulatorHandle extends DeviceHandle {
-  constructor(deviceString) {
-    super(deviceString);
-
-    this.port = this.adbName.split('-')[1];
-  }
-
-  queryName() {
-    if (!this._name) {
-      this._name = this._queryNameViaTelnet();
-    }
-
-    return this._name;
-  }
-
-  async _queryNameViaTelnet() {
-    const telnet = new EmulatorTelnet();
-
-    await telnet.connect(this.port);
-    try {
-      return await telnet.avdName();
-    } finally {
-      await telnet.quit();
-    }
-  }
-}
-
 module.exports = ADB;
diff --git a/detox/src/devices/drivers/android/tools/AdbDevicesHelper.js b/detox/src/devices/drivers/android/tools/AdbDevicesHelper.js
deleted file mode 100644
index be6cf1548..000000000
--- a/detox/src/devices/drivers/android/tools/AdbDevicesHelper.js
+++ /dev/null
@@ -1,17 +0,0 @@
-class AdbDevicesHelper {
-  constructor(adb) {
-    this.adb = adb;
-  }
-
-  async lookupDevice(matchFn) {
-    const { devices } = await this.adb.devices();
-    for (const candidate of devices) {
-      if (await matchFn(candidate)) {
-        return candidate.adbName;
-      }
-    }
-    return null;
-  }
-}
-
-module.exports = AdbDevicesHelper;
diff --git a/detox/src/devices/drivers/android/tools/DeviceHandle.js b/detox/src/devices/drivers/android/tools/DeviceHandle.js
new file mode 100644
index 000000000..3ee667fba
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/DeviceHandle.js
@@ -0,0 +1,22 @@
+class DeviceHandle {
+  constructor(deviceString) {
+    const [adbName, status] = deviceString.split('\t');
+    this.type = this._inferDeviceType(adbName);
+    this.adbName = adbName;
+    this.status = status;
+  }
+
+  _inferDeviceType(adbName) {
+    if (adbName.startsWith('emulator-')) {
+      return 'emulator';
+    }
+
+    if (/^((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:)){4}[0-9]{4}/.test(adbName)) {
+      return 'genymotion';
+    }
+
+    return 'device';
+  }
+}
+
+module.exports = DeviceHandle;
diff --git a/detox/src/devices/drivers/android/tools/EmulatorHandle.js b/detox/src/devices/drivers/android/tools/EmulatorHandle.js
new file mode 100644
index 000000000..1a829a20f
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/EmulatorHandle.js
@@ -0,0 +1,31 @@
+const DeviceHandle = require('./DeviceHandle');
+const EmulatorTelnet = require('./EmulatorTelnet');
+
+class EmulatorHandle extends DeviceHandle {
+  constructor(deviceString) {
+    super(deviceString);
+
+    this.port = this.adbName.split('-')[1];
+  }
+
+  queryName() {
+    if (!this._name) {
+      this._name = this._queryNameViaTelnet();
+    }
+
+    return this._name;
+  }
+
+  async _queryNameViaTelnet() {
+    const telnet = new EmulatorTelnet();
+
+    await telnet.connect(this.port);
+    try {
+      return await telnet.avdName();
+    } finally {
+      await telnet.quit();
+    }
+  }
+}
+
+module.exports = EmulatorHandle;
diff --git a/detox/src/devices/drivers/android/tools/FreeDeviceFinder.js b/detox/src/devices/drivers/android/tools/FreeDeviceFinder.js
new file mode 100644
index 000000000..70a3874fc
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/FreeDeviceFinder.js
@@ -0,0 +1,46 @@
+const log = require('../../../../utils/logger').child({ __filename });
+
+const DEVICE_LOOKUP_LOG_EVT = 'DEVICE_LOOKUP';
+
+class FreeDeviceFinder {
+  constructor(adb, deviceRegistry) {
+    this.adb = adb;
+    this.deviceRegistry = deviceRegistry;
+  }
+
+  async findFreeDevice(deviceQuery) {
+    const { devices } = await this.adb.devices();
+    for (const candidate of devices) {
+      const isBusy = this.deviceRegistry.isDeviceBusy(candidate.adbName);
+      if (isBusy) {
+        log.debug({ event: DEVICE_LOOKUP_LOG_EVT }, `Device ${candidate.adbName} is busy, skipping...`);
+        continue;
+      }
+
+      const isOffline = candidate.status === 'offline';
+      if (isOffline) {
+        log.debug({ event: DEVICE_LOOKUP_LOG_EVT }, `Device ${candidate.adbName} is offline, skipping...`);
+        continue;
+      }
+
+      const isMatching = await this._isDeviceMatching(candidate, deviceQuery);
+      if (isMatching) {
+        log.debug({ event: DEVICE_LOOKUP_LOG_EVT }, `Found a matching free device ${candidate.adbName}`);
+        return candidate.adbName;
+      } else {
+        log.debug({ event: DEVICE_LOOKUP_LOG_EVT }, `Device ${candidate.adbName} does not match "${deviceQuery}"`);
+      }
+    }
+
+    return null;
+  }
+
+  /**
+   * @protected
+   */
+  async _isDeviceMatching(candidate, deviceQuery) {
+    return RegExp(deviceQuery).test(candidate.adbName);
+  }
+}
+
+module.exports = FreeDeviceFinder;
diff --git a/detox/src/devices/drivers/android/tools/__mocks__/handles.js b/detox/src/devices/drivers/android/tools/__mocks__/handles.js
new file mode 100644
index 000000000..82111b37f
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/__mocks__/handles.js
@@ -0,0 +1,22 @@
+const DeviceHandle = require('../DeviceHandle');
+const EmulatorHandle = require('../EmulatorHandle');
+
+const mockAvdName = 'mockAvdName';
+class MockEmulatorHandle extends EmulatorHandle {
+  queryName() {
+    return Promise.resolve(mockAvdName);
+  }
+}
+
+const emulator5556 = new MockEmulatorHandle('emulator-5556\tdevice');
+const localhost5555 = new DeviceHandle('localhost:5555\tdevice');
+const ip5557 = new DeviceHandle('192.168.4.19:5557\tdevice');
+const deviceOffline = new MockEmulatorHandle('192.168.4.19:5559\toffline');
+
+module.exports = {
+  mockAvdName,
+  emulator5556,
+  localhost5555,
+  ip5557,
+  deviceOffline,
+};
diff --git a/docs/APIRef.Configuration.md b/docs/APIRef.Configuration.md
index 1684547d7..82d4132aa 100644
--- a/docs/APIRef.Configuration.md
+++ b/docs/APIRef.Configuration.md
@@ -28,7 +28,7 @@ Please find the [Detox example app](/examples/demo-react-native/detox.config.js)
 |`binaryPath`| Relative path to the ipa/app/apk due to be tested (make sure you build the app in a project relative path) |
 |`testBinaryPath`| (optional, Android only): relative path to the test app (apk) |
 |`utilBinaryPaths`| (optional, Android only): An **array** of relative paths of _utility_ app (apk) binary-files to preinstall on the tested devices - once before the test excution begins.<br />Note: these are not effected by various install-lifecycle events, such as launching an app with `device.launchApp({delete: true})`, which reinstalls the app. A good example of why this might come in handy is [Test Butler](https://github.com/linkedin/test-butler). |
-|`device`| Device query, e.g. `{ "byType": "iPhone 11 Pro" }` for iOS simulator or `{ "avdName": "Pixel_2_API_29" }` |
+|`device`| Device query, e.g. `{ "byType": "iPhone 11 Pro" }` for iOS simulator, `{ "avdName": "Pixel_2_API_29" }` for Android emulator or `{ "adbName": "<pattern>" }` for attached Android device with name matching the regex. |
 |`build`| **[optional]** Build command (either `xcodebuild`, `react-native run-ios`, etc...), will be later available through detox CLI tool.|
 
 **Example:**
@@ -66,6 +66,14 @@ Please find the [Detox example app](/examples/demo-react-native/detox.config.js)
           "adbName": "YOGAA1BBB412",
         }
       },
+      "android.genymotion.release": {
+        "binaryPath": "...",
+        "build": "...",
+        "type": "android.attached",
+        "device": {
+          "adbName": "^((1?\\d?\\d|25[0-5]|2[0-4]\\d)(\\.|:)){4}[0-9]{4}",
+        }
+      },
     }
   }
 }
diff --git a/docs/Introduction.Android.md b/docs/Introduction.Android.md
index 8f2fcc9b3..d4d0b06a3 100644
--- a/docs/Introduction.Android.md
+++ b/docs/Introduction.Android.md
@@ -64,6 +64,7 @@ Following device types could be used to control Android devices:
 
 - `android.attached`. Connect to already-attached android device. The device should be listed in the output of `adb devices` command under provided `name`.
   Use this type to connect to Genymotion emulator.
+  The `adbName` property accepts a regular expression pattern that allows to specify the pool of device candidates to which you wish to connect. Use this property to run tests in parallel on multiple attached devices.
 
 For a complete, working example, refer to the [Detox example app](/examples/demo-react-native/detox.config.js).
 


Test patch:
diff --git a/detox/src/devices/DriverRegistry.test.js b/detox/src/devices/DriverRegistry.test.js
index 06983c393..832ab28f4 100644
--- a/detox/src/devices/DriverRegistry.test.js
+++ b/detox/src/devices/DriverRegistry.test.js
@@ -1,7 +1,7 @@
 jest.mock('./drivers/ios/IosDriver');
 jest.mock('./drivers/ios/SimulatorDriver');
-jest.mock('./drivers/android/EmulatorDriver');
-jest.mock('./drivers/android/AttachedAndroidDriver');
+jest.mock('./drivers/android/emulator/EmulatorDriver');
+jest.mock('./drivers/android/attached/AttachedAndroidDriver');
 jest.mock('../utils/resolveModuleFromPath');
 
 describe('DriverRegistry', () => {
@@ -39,7 +39,7 @@ describe('DriverRegistry', () => {
     });
 
     it('should resolve "android.attached" to AttachedAndroidDriver', () => {
-      const AttachedAndroidDriver = require('./drivers/android/AttachedAndroidDriver');
+      const AttachedAndroidDriver = require('./drivers/android/attached/AttachedAndroidDriver');
       const driver = registry.resolve('android.attached', opts);
 
       expect(driver).toBeInstanceOf(AttachedAndroidDriver);
@@ -47,7 +47,7 @@ describe('DriverRegistry', () => {
     });
 
     it('should resolve "android.emulator" to EmulatorDriver', () => {
-      const EmulatorDriver = require('./drivers/android/EmulatorDriver');
+      const EmulatorDriver = require('./drivers/android/emulator/EmulatorDriver');
       const driver = registry.resolve('android.emulator', opts);
 
       expect(driver).toBeInstanceOf(EmulatorDriver);
diff --git a/detox/src/devices/drivers/android/AndroidDriver.test.js b/detox/src/devices/drivers/android/AndroidDriver.test.js
index 3e2d5065b..8ea54506f 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.test.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.test.js
@@ -119,7 +119,7 @@ describe('Android driver', () => {
   });
 
   describe('Cleanup', () => {
-    beforeEach(async () => await uut.cleanup());
+    beforeEach(async () => await uut.cleanup(deviceId));
 
     it('should terminate instrumentation', () =>
       expect(instrumentationObj().terminate).toHaveBeenCalled());
@@ -129,6 +129,10 @@ describe('Android driver', () => {
 
     it('should turn off the events emitter', () =>
       expect(emitter.off).toHaveBeenCalled());
+
+    it('should dispose device', () => {
+      expect(mockDisposeDevice).toHaveBeenCalledWith(deviceId);
+    });
   });
 
   describe('URL runtime delivery handling', () => {
@@ -463,6 +467,7 @@ describe('Android driver', () => {
     });
   });
 
+  const mockDisposeDevice = jest.fn();
   const setUpModuleDepMocks = () => {
     jest.mock('fs', () => ({
       existsSync: jest.fn(),
@@ -511,6 +516,12 @@ describe('Android driver', () => {
 
     const InvocationManager = jest.genMockFromModule('../../../invoke').InvocationManager;
     invocationManager = new InvocationManager();
+
+    jest.mock('../../DeviceRegistry', () => class {
+      constructor() {
+        this.disposeDevice = mockDisposeDevice;
+      }
+    });
   };
 
   const setUpClassDepMocks = () => {
diff --git a/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js b/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js
index ec6dbe850..3acf20771 100644
--- a/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js
+++ b/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js
@@ -1,94 +1,27 @@
-describe('FreeEmulatorFinder', () => {
-  const mockAdb = {};
-  const mockAvdName = 'mock-avd';
+const FreeEmulatorFinder = require('./FreeEmulatorFinder');
+const { mockAvdName, emulator5556, localhost5555 } = require('../tools/__mocks__/handles');
 
-  let AdbDevicesHelperClass;
-  beforeEach(() => {
-    jest.mock('../tools/AdbDevicesHelper');
-    AdbDevicesHelperClass = require('../tools/AdbDevicesHelper');
-  });
-  const adbDevicesHelperObj = () => AdbDevicesHelperClass.mock.instances[0];
+describe('FreeEmulatorFinder', () => {
+  const mockAdb = { devices: jest.fn() };
 
-  let mockDeviceRegistry;
   let uut;
   beforeEach(() => {
-    jest.mock('../../../../utils/logger');
-
-    const DeviceRegistry = jest.genMockFromModule('../../../DeviceRegistry');
-    mockDeviceRegistry = new DeviceRegistry();
-    mockDeviceFree();
-
-    const FreeEmulatorFinder = require('./FreeEmulatorFinder');
-    uut = new FreeEmulatorFinder(mockAdb, mockDeviceRegistry, mockAvdName);
-  });
-
-  it('should create an adb devices helper', async () => {
-    expect(AdbDevicesHelperClass).toHaveBeenCalledWith(mockAdb);
-  });
-
-  it('should pass a custom matcher func onto adb-devices helper for finding a free emulator', async () => {
-    await uut.findFreeDevice();
-    expect(adbDevicesHelperObj().lookupDevice).toHaveBeenCalledWith(expect.any(Function));
-  });
-
-  it('should return the matched emulator', async () => {
-    const adbName = 'mock-adb-name';
-    adbDevicesHelperObj().lookupDevice.mockReturnValue(adbName);
-
-    const deviceName = await uut.findFreeDevice();
-    expect(deviceName).toEqual(adbName);
+    mockAdbDevices([emulator5556]);
+    uut = new FreeEmulatorFinder(mockAdb, { isDeviceBusy: jest.fn().mockReturnValue(false) });
   });
 
-  describe('Device lookup matcher function', () => {
-    it('should restrict matching to emulators', async () => {
-      const emuDevice = anEmulatorDevice();
-      const nonEmuDevice = aNonEmuDevice();
-
-      await uut.findFreeDevice();
-
-      const matcherFn = resolveMatcherFn();
-      expect(await matcherFn(emuDevice)).toEqual(true);
-      expect(await matcherFn(nonEmuDevice)).toEqual(false);
-    });
-
-    it('should restrict to free (unoccupied) devices', async () => {
-      const device = anEmulatorDevice();
-      mockDeviceBusy();
-
-      await uut.findFreeDevice();
-
-      const matcherFn = resolveMatcherFn();
-      expect(await matcherFn(device)).toEqual(false);
-      expect(mockDeviceRegistry.isDeviceBusy).toHaveBeenCalledWith('mock-name');
-    });
-
-    it('should restrict to the AVD name in question', async () => {
-      const device = {
-        ...anEmulatorDevice(),
-        queryName: jest.fn().mockResolvedValue('other ' + mockAvdName),
-      };
-
-      await uut.findFreeDevice();
-
-      const matcherFn = resolveMatcherFn();
-      expect(await matcherFn(device)).toEqual(false);
-    });
-
-    const resolveMatcherFn = () => adbDevicesHelperObj().lookupDevice.mock.calls[0][0];
+  it('should return device when it is an emulator and avdName matches', async () => {
+    expect(await uut.findFreeDevice(mockAvdName)).toBe(emulator5556.adbName);
   });
 
-  const anEmulatorDevice = () => ({
-    type: 'emulator',
-    adbName: 'mock-name',
-    queryName: jest.fn().mockResolvedValue(mockAvdName),
+  it('should return null when avdName does not match', async () => {
+    expect(await uut.findFreeDevice('wrongAvdName')).toBe(null);
   });
 
-  const aNonEmuDevice = () => ({
-    type: 'non-emulator',
-    adbName: 'mock-name',
-    queryName: jest.fn().mockResolvedValue(mockAvdName),
+  it('should return null when not an emulator', async () => {
+    mockAdbDevices([localhost5555]);
+    expect(await uut.findFreeDevice(mockAvdName)).toBe(null);
   });
 
-  const mockDeviceBusy = () => mockDeviceRegistry.isDeviceBusy.mockReturnValue(true);
-  const mockDeviceFree = () => mockDeviceRegistry.isDeviceBusy.mockReturnValue(false);
+  const mockAdbDevices = (devices) => mockAdb.devices.mockResolvedValue({ devices });
 });
diff --git a/detox/src/devices/drivers/android/tools/AdbDevicesHelper.test.js b/detox/src/devices/drivers/android/tools/AdbDevicesHelper.test.js
deleted file mode 100644
index f8cec008f..000000000
--- a/detox/src/devices/drivers/android/tools/AdbDevicesHelper.test.js
+++ /dev/null
@@ -1,81 +0,0 @@
-describe('ADB devices helper', () => {
-  let adbMock;
-  let DeviceAllocation;
-  let deviceAllocation;
-
-  beforeEach(() => {
-    adbMock = {
-      devices: jest.fn().mockResolvedValue(new Error('todo in unit test')),
-    };
-
-    DeviceAllocation = require('./AdbDevicesHelper');
-    deviceAllocation = new DeviceAllocation(adbMock);
-  });
-
-  describe('find device', () => {
-    it('should query for devices', async () => {
-      const matchFn = jest.fn().mockResolvedValue(true);
-
-      mockNoAdbDevices();
-      await deviceAllocation.lookupDevice(matchFn);
-      expect(adbMock.devices).toHaveBeenCalled();
-    });
-
-    it('should return null if there are no devices', async () => {
-      const matchFn = jest.fn().mockResolvedValue(true);
-
-      mockNoAdbDevices();
-      const device = await deviceAllocation.lookupDevice(matchFn);
-      expect(device).toEqual(null);
-    });
-
-    it('should look up a device', async () => {
-      const matchFn = jest.fn().mockResolvedValue(true);
-
-      const device = aDevice();
-      mockAdbDevices([device]);
-
-      const matchingDeviceName = await deviceAllocation.lookupDevice(matchFn);
-      expect(matchingDeviceName).toEqual(device.adbName);
-    });
-
-    it('should return null for non-matching device', async () => {
-      const matchFn = jest.fn().mockResolvedValue(false);
-
-      const device = aDevice();
-      mockAdbDevices([device]);
-
-      const matchingDeviceName = await deviceAllocation.lookupDevice(matchFn);
-      expect(matchingDeviceName).toEqual(null);
-    });
-
-    it('should return first suitable device', async () => {
-      const matchFn = jest.fn()
-        .mockResolvedValueOnce(false)
-        .mockResolvedValueOnce(true)
-        .mockResolvedValueOnce(true);
-
-      mockAdbDevices([
-        aDevice('device1'),
-        aDevice('device2'),
-        aDevice('device3'),
-      ]);
-
-      const matchinDeviceName = await deviceAllocation.lookupDevice(matchFn);
-      expect(matchinDeviceName).toEqual('device2');
-    });
-  });
-
-  const aDevice = (adbName = 'mock-name') => ({
-    type: 'mock-type',
-    adbName,
-  });
-
-  const mockAdbDevices = (devices) => {
-    adbMock.devices.mockResolvedValue({
-      devices,
-    });
-  };
-
-  const mockNoAdbDevices = () => mockAdbDevices([]);
-});
diff --git a/detox/src/devices/drivers/android/tools/FreeDeviceFinder.test.js b/detox/src/devices/drivers/android/tools/FreeDeviceFinder.test.js
new file mode 100644
index 000000000..b9dca76d2
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/FreeDeviceFinder.test.js
@@ -0,0 +1,42 @@
+const FreeDeviceFinder = require('./FreeDeviceFinder');
+const { deviceOffline, emulator5556, localhost5555, ip5557 } = require('./__mocks__/handles');
+
+describe('FreeDeviceFinder', () => {
+  const mockAdb = { devices: jest.fn() };
+
+  let mockDeviceRegistry;
+  let uut;
+  beforeEach(() => {
+    mockAdbDevices([emulator5556]);
+    mockDeviceRegistry = { isDeviceBusy: jest.fn().mockReturnValue(false) };
+    uut = new FreeDeviceFinder(mockAdb, mockDeviceRegistry);
+  });
+
+  it('should return the only device when it matches, is online and not busy', async () => {
+    expect(await uut.findFreeDevice(emulator5556.adbName)).toEqual(emulator5556.adbName);
+  });
+
+  it('should return null when there are no devices', async () => {
+    mockAdbDevices([]);
+    expect(await uut.findFreeDevice(emulator5556.adbName)).toEqual(null);
+  });
+
+  it('should return null when device is busy', async () => {
+    mockAllDevicesBusy();
+    expect(await uut.findFreeDevice(emulator5556.adbName)).toEqual(null);
+  });
+
+  it('should return null when device is offline', async () => {
+    mockAdbDevices([deviceOffline]);
+    expect(await uut.findFreeDevice(deviceOffline.adbName)).toEqual(null);
+  });
+
+  it('should return first device that matches a regular expression', async () => {
+    mockAdbDevices([emulator5556, localhost5555, ip5557]);
+    const localhost = '^localhost:\\d+$';
+    expect(await uut.findFreeDevice(localhost)).toBe(localhost5555.adbName);
+  });
+
+  const mockAdbDevices = (devices) => mockAdb.devices.mockResolvedValue({ devices });
+  const mockAllDevicesBusy = () => mockDeviceRegistry.isDeviceBusy.mockReturnValue(true);
+});