Upgrade mobly to a489d904870e349ce47cc972d0f20c4723316cce

This project was upgraded with external_updater.
Usage: tools/external_updater/updater.sh update external/python/mobly
For more info, check https://cs.android.com/android/platform/superproject/main/+/main:tools/external_updater/README.md

Test: http://sponge2/13621805-ae77-44fd-9262-c7ae6930c069 (passed)
Change-Id: Ib9fe4462ce8cfcfa13450be3e49b0d8b221d2779
diff --git a/METADATA b/METADATA
index eeddc00..a2d8da0 100644
--- a/METADATA
+++ b/METADATA
@@ -9,12 +9,12 @@
   last_upgrade_date {
     year: 2025
     month: 2
-    day: 12
+    day: 25
   }
   homepage: "https://github.com/google/mobly"
   identifier {
     type: "Git"
     value: "https://github.com/google/mobly"
-    version: "1.12.4"
+    version: "a489d904870e349ce47cc972d0f20c4723316cce"
   }
 }
diff --git a/docs/tutorial.md b/docs/tutorial.md
index f53b948..93122ec 100644
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -336,11 +336,11 @@
     # When a test run starts, Mobly calls this function to figure out what
     # tests need to be generated. So you need to specify what tests to generate
     # in this function.
-    def setup_generated_tests(self):
+    def pre_run(self):
         messages = [('Hello', 'World'), ('Aloha', 'Obama'),
                     ('konichiwa', 'Satoshi')]
         # Call `generate_tests` function to specify the tests to generate. This
-        # function can only be called within `setup_generated_tests`. You could
+        # function can only be called within `pre_run`. You could
         # call this function multiple times to generate multiple groups of
         # tests.
         self.generate_tests(
diff --git a/mobly/base_instrumentation_test.py b/mobly/base_instrumentation_test.py
index f41a500..5713422 100644
--- a/mobly/base_instrumentation_test.py
+++ b/mobly/base_instrumentation_test.py
@@ -83,10 +83,7 @@
   .. code-block:: none
 
     android.app.Instrumentation
-    android.support.test.internal.runner.listener.InstrumentationResultPrinter
-
-  TODO: Convert android.support.* to androidx.*,
-  (https://android-developers.googleblog.com/2018/05/hello-world-androidx.html).
+    androidx.test.internal.runner.listener.InstrumentationResultPrinter
   """
 
   CLASS = 'class'
@@ -119,10 +116,7 @@
 
   .. code-block:: none
 
-    android.support.test.internal.runner.listener.InstrumentationResultPrinter
-
-  TODO: Convert android.support.* to androidx.*,
-  (https://android-developers.googleblog.com/2018/05/hello-world-androidx.html).
+    androidx.test.internal.runner.listener.InstrumentationResultPrinter
   """
 
   UNKNOWN = None
diff --git a/mobly/base_suite.py b/mobly/base_suite.py
index 06cdeed..1ef0a68 100644
--- a/mobly/base_suite.py
+++ b/mobly/base_suite.py
@@ -14,6 +14,8 @@
 
 import abc
 
+import logging
+
 
 class BaseSuite(abc.ABC):
   """Class used to define a Mobly suite.
@@ -34,11 +36,20 @@
   def __init__(self, runner, config):
     self._runner = runner
     self._config = config.copy()
+    self._test_selector = None
 
   @property
   def user_params(self):
     return self._config.user_params
 
+  def set_test_selector(self, test_selector):
+    """Sets test selector.
+
+    Don't override or call this method. This should only be used by the Mobly
+    framework.
+    """
+    self._test_selector = test_selector
+
   def add_test_class(self, clazz, config=None, tests=None, name_suffix=None):
     """Adds a test class to the suite.
 
@@ -47,12 +58,27 @@
       config: config_parser.TestRunConfig, the config to run the class with. If
         not specified, the default config passed from google3 infra is used.
       tests: list of strings, names of the tests to run in this test class, in
-        the execution order. If not specified, all tests in the class are
-        executed.
+        the execution order. Or a string with prefix `re:` for full regex match
+        of test cases; all matched test cases will be executed; an error is
+        raised if no match is found.
+        If not specified, all tests in the class are executed.
+        CLI argument `tests` takes precedence over this argument.
       name_suffix: string, suffix to append to the class name for reporting.
         This is used for differentiating the same class executed with different
         parameters in a suite.
     """
+    if self._test_selector:
+      cls_name = clazz.__name__
+      if (cls_name, name_suffix) in self._test_selector:
+        tests = self._test_selector[(cls_name, name_suffix)]
+      elif cls_name in self._test_selector:
+        tests = self._test_selector[cls_name]
+      else:
+        logging.info(
+            'Skipping test class %s due to CLI argument `tests`.', cls_name
+        )
+        return
+
     if not config:
       config = self._config
     self._runner.add_test_class(config, clazz, tests, name_suffix)
diff --git a/mobly/base_test.py b/mobly/base_test.py
index e5060af..a62fac2 100644
--- a/mobly/base_test.py
+++ b/mobly/base_test.py
@@ -19,6 +19,7 @@
 import inspect
 import logging
 import os
+import re
 import sys
 
 from mobly import controller_manager
@@ -31,14 +32,13 @@
 # Macro strings for test result reporting.
 TEST_CASE_TOKEN = '[Test]'
 RESULT_LINE_TEMPLATE = TEST_CASE_TOKEN + ' %s %s'
+TEST_SELECTOR_REGEX_PREFIX = 're:'
 
 TEST_STAGE_BEGIN_LOG_TEMPLATE = '[{parent_token}]#{child_token} >>> BEGIN >>>'
 TEST_STAGE_END_LOG_TEMPLATE = '[{parent_token}]#{child_token} <<< END <<<'
 
 # Names of execution stages, in the order they happen during test runs.
 STAGE_NAME_PRE_RUN = 'pre_run'
-# Deprecated, use `STAGE_NAME_PRE_RUN` instead.
-STAGE_NAME_SETUP_GENERATED_TESTS = 'setup_generated_tests'
 STAGE_NAME_SETUP_CLASS = 'setup_class'
 STAGE_NAME_SETUP_TEST = 'setup_test'
 STAGE_NAME_TEARDOWN_TEST = 'teardown_test'
@@ -370,10 +370,6 @@
     try:
       with self._log_test_stage(stage_name):
         self.pre_run()
-      # TODO(angli): Remove this context block after the full deprecation of
-      # `setup_generated_tests`.
-      with self._log_test_stage(stage_name):
-        self.setup_generated_tests()
       return True
     except Exception as e:
       logging.exception('%s failed for %s.', stage_name, self.TAG)
@@ -395,19 +391,6 @@
     requested is unknown at this point.
     """
 
-  def setup_generated_tests(self):
-    """[DEPRECATED] Use `pre_run` instead.
-
-    Preprocesses that need to be done before setup_class.
-
-    This phase is used to do pre-test processes like generating tests.
-    This is the only place `self.generate_tests` should be called.
-
-    If this function throws an error, the test class will be marked failure
-    and the "Requested" field will be 0 because the number of tests
-    requested is unknown at this point.
-    """
-
   def _setup_class(self):
     """Proxy function to guarantee the base implementation of setup_class
     is called.
@@ -904,8 +887,7 @@
   def generate_tests(self, test_logic, name_func, arg_sets, uid_func=None):
     """Generates tests in the test class.
 
-    This function has to be called inside a test class's `self.pre_run` or
-    `self.setup_generated_tests`.
+    This function has to be called inside a test class's `self.pre_run`.
 
     Generated tests are not written down as methods, but as a list of
     parameter sets. This way we reduce code repetition and improve test
@@ -926,9 +908,7 @@
         arguments as the test logic function and returns a string that
         is the corresponding UID.
     """
-    self._assert_function_names_in_stack(
-        [STAGE_NAME_PRE_RUN, STAGE_NAME_SETUP_GENERATED_TESTS]
-    )
+    self._assert_function_names_in_stack([STAGE_NAME_PRE_RUN])
     root_msg = 'During test generation of "%s":' % test_logic.__name__
     for args in arg_sets:
       test_name = name_func(*args)
@@ -1003,7 +983,8 @@
     """Resolves test method names to bound test methods.
 
     Args:
-      test_names: A list of strings, each string is a test method name.
+      test_names: A list of strings, each string is a test method name or a
+        regex for matching test names.
 
     Returns:
       A list of tuples of (string, function). String is the test method
@@ -1014,21 +995,52 @@
         This can only be caused by user input.
     """
     test_methods = []
+    # Process the test name selector one by one.
     for test_name in test_names:
-      if not test_name.startswith('test_'):
-        raise Error(
-            'Test method name %s does not follow naming '
-            'convention test_*, abort.' % test_name
+      if test_name.startswith(TEST_SELECTOR_REGEX_PREFIX):
+        # process the selector as a regex.
+        regex_matching_methods = self._get_regex_matching_test_methods(
+            test_name.removeprefix(TEST_SELECTOR_REGEX_PREFIX)
         )
+        test_methods += regex_matching_methods
+        continue
+      # process the selector as a regular test name string.
+      self._assert_valid_test_name(test_name)
+      if test_name not in self.get_existing_test_names():
+        raise Error(f'{self.TAG} does not have test method {test_name}.')
       if hasattr(self, test_name):
         test_method = getattr(self, test_name)
       elif test_name in self._generated_test_table:
         test_method = self._generated_test_table[test_name]
-      else:
-        raise Error('%s does not have test method %s.' % (self.TAG, test_name))
       test_methods.append((test_name, test_method))
     return test_methods
 
+  def _get_regex_matching_test_methods(self, test_name_regex):
+    matching_name_tuples = []
+    for name, method in inspect.getmembers(self, callable):
+      if (
+          name.startswith('test_')
+          and re.fullmatch(test_name_regex, name) is not None
+      ):
+        matching_name_tuples.append((name, method))
+    for name, method in self._generated_test_table.items():
+      if re.fullmatch(test_name_regex, name) is not None:
+        self._assert_valid_test_name(name)
+        matching_name_tuples.append((name, method))
+    if not matching_name_tuples:
+      raise Error(
+          f'{test_name_regex} does not match with any valid test case '
+          f'in {self.TAG}, abort!'
+      )
+    return matching_name_tuples
+
+  def _assert_valid_test_name(self, test_name):
+    if not test_name.startswith('test_'):
+      raise Error(
+          'Test method name %s does not follow naming '
+          'convention test_*, abort.' % test_name
+      )
+
   def _skip_remaining_tests(self, exception):
     """Marks any requested test that has not been executed in a class as
     skipped.
diff --git a/mobly/controllers/android_device.py b/mobly/controllers/android_device.py
index b147da4..66a40d5 100644
--- a/mobly/controllers/android_device.py
+++ b/mobly/controllers/android_device.py
@@ -588,28 +588,6 @@
     self._user_added_device_info.update({name: info})
 
   @property
-  def sl4a(self):
-    """Attribute for direct access of sl4a client.
-
-    Not recommended. This is here for backward compatibility reasons.
-
-    Preferred: directly access `ad.services.sl4a`.
-    """
-    if self.services.has_service_by_name('sl4a'):
-      return self.services.sl4a
-
-  @property
-  def ed(self):
-    """Attribute for direct access of sl4a's event dispatcher.
-
-    Not recommended. This is here for backward compatibility reasons.
-
-    Preferred: directly access `ad.services.sl4a.ed`.
-    """
-    if self.services.has_service_by_name('sl4a'):
-      return self.services.sl4a.ed
-
-  @property
   def debug_tag(self):
     """A string that represents a device object in debug info. Default value
     is the device serial.
diff --git a/mobly/controllers/android_device_lib/event_dispatcher.py b/mobly/controllers/android_device_lib/event_dispatcher.py
deleted file mode 100644
index 80610ef..0000000
--- a/mobly/controllers/android_device_lib/event_dispatcher.py
+++ /dev/null
@@ -1,443 +0,0 @@
-# Copyright 2016 Google Inc.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-from concurrent import futures
-import queue
-import re
-import threading
-import time
-import traceback
-
-
-class EventDispatcherError(Exception):
-  pass
-
-
-class IllegalStateError(EventDispatcherError):
-  """Raise when user tries to put event_dispatcher into an illegal state."""
-
-
-class DuplicateError(EventDispatcherError):
-  """Raise when a duplicate is being created and it shouldn't."""
-
-
-class EventDispatcher:
-  """Class managing events for an sl4a connection."""
-
-  DEFAULT_TIMEOUT = 60
-
-  def __init__(self, sl4a):
-    self._sl4a = sl4a
-    self.started = False
-    self.executor = None
-    self.poller = None
-    self.event_dict = {}
-    self.handlers = {}
-    self.lock = threading.RLock()
-
-  def poll_events(self):
-    """Continuously polls all types of events from sl4a.
-
-    Events are sorted by name and store in separate queues.
-    If there are registered handlers, the handlers will be called with
-    corresponding event immediately upon event discovery, and the event
-    won't be stored. If exceptions occur, stop the dispatcher and return
-    """
-    while self.started:
-      event_obj = None
-      event_name = None
-      try:
-        event_obj = self._sl4a.eventWait(50000)
-      except Exception:
-        if self.started:
-          print("Exception happened during polling.")
-          print(traceback.format_exc())
-          raise
-      if not event_obj:
-        continue
-      elif "name" not in event_obj:
-        print("Received Malformed event {}".format(event_obj))
-        continue
-      else:
-        event_name = event_obj["name"]
-      # if handler registered, process event
-      if event_name in self.handlers:
-        self.handle_subscribed_event(event_obj, event_name)
-      if event_name == "EventDispatcherShutdown":
-        self._sl4a.closeSl4aSession()
-        break
-      else:
-        self.lock.acquire()
-        if event_name in self.event_dict:  # otherwise, cache event
-          self.event_dict[event_name].put(event_obj)
-        else:
-          q = queue.Queue()
-          q.put(event_obj)
-          self.event_dict[event_name] = q
-        self.lock.release()
-
-  def register_handler(self, handler, event_name, args):
-    """Registers an event handler.
-
-    One type of event can only have one event handler associated with it.
-
-    Args:
-      handler: The event handler function to be registered.
-      event_name: Name of the event the handler is for.
-      args: User arguments to be passed to the handler when it's called.
-
-    Raises:
-      IllegalStateError: Raised if attempts to register a handler after
-        the dispatcher starts running.
-      DuplicateError: Raised if attempts to register more than one
-        handler for one type of event.
-    """
-    if self.started:
-      raise IllegalStateError("Can't register service after polling is started")
-    self.lock.acquire()
-    try:
-      if event_name in self.handlers:
-        raise DuplicateError(
-            "A handler for {} already exists".format(event_name)
-        )
-      self.handlers[event_name] = (handler, args)
-    finally:
-      self.lock.release()
-
-  def start(self):
-    """Starts the event dispatcher.
-
-    Initiates executor and start polling events.
-
-    Raises:
-      IllegalStateError: Can't start a dispatcher again when it's already
-        running.
-    """
-    if not self.started:
-      self.started = True
-      self.executor = futures.ThreadPoolExecutor(max_workers=32)
-      self.poller = self.executor.submit(self.poll_events)
-    else:
-      raise IllegalStateError("Dispatcher is already started.")
-
-  def clean_up(self):
-    """Clean up and release resources after the event dispatcher polling
-    loop has been broken.
-
-    The following things happen:
-    1. Clear all events and flags.
-    2. Close the sl4a client the event_dispatcher object holds.
-    3. Shut down executor without waiting.
-    """
-    if not self.started:
-      return
-    self.started = False
-    self.clear_all_events()
-    # At this point, the sl4a apk is destroyed and nothing is listening on
-    # the socket. Avoid sending any sl4a commands; just clean up the socket
-    # and return.
-    self._sl4a.disconnect()
-    self.poller.set_result("Done")
-    # The polling thread is guaranteed to finish after a max of 60 seconds,
-    # so we don't wait here.
-    self.executor.shutdown(wait=False)
-
-  def pop_event(self, event_name, timeout=DEFAULT_TIMEOUT):
-    """Pop an event from its queue.
-
-    Return and remove the oldest entry of an event.
-    Block until an event of specified name is available or
-    times out if timeout is set.
-
-    Args:
-      event_name: Name of the event to be popped.
-      timeout: Number of seconds to wait when event is not present.
-        Never times out if None.
-
-    Returns:
-      The oldest entry of the specified event. None if timed out.
-
-    Raises:
-      IllegalStateError: Raised if pop is called before the dispatcher
-        starts polling.
-    """
-    if not self.started:
-      raise IllegalStateError("Dispatcher needs to be started before popping.")
-
-    e_queue = self.get_event_q(event_name)
-
-    if not e_queue:
-      raise TypeError("Failed to get an event queue for {}".format(event_name))
-
-    try:
-      # Block for timeout
-      if timeout:
-        return e_queue.get(True, timeout)
-      # Non-blocking poll for event
-      elif timeout == 0:
-        return e_queue.get(False)
-      else:
-        # Block forever on event wait
-        return e_queue.get(True)
-    except queue.Empty:
-      raise queue.Empty(
-          "Timeout after {}s waiting for event: {}".format(timeout, event_name)
-      )
-
-  def wait_for_event(
-      self, event_name, predicate, timeout=DEFAULT_TIMEOUT, *args, **kwargs
-  ):
-    """Wait for an event that satisfies a predicate to appear.
-
-    Continuously pop events of a particular name and check against the
-    predicate until an event that satisfies the predicate is popped or
-    timed out. Note this will remove all the events of the same name that
-    do not satisfy the predicate in the process.
-
-    Args:
-      event_name: Name of the event to be popped.
-      predicate: A function that takes an event and returns True if the
-        predicate is satisfied, False otherwise.
-      timeout: Number of seconds to wait.
-      *args: Optional positional args passed to predicate().
-      **kwargs: Optional keyword args passed to predicate().
-
-    Returns:
-      The event that satisfies the predicate.
-
-    Raises:
-      queue.Empty: Raised if no event that satisfies the predicate was
-        found before time out.
-    """
-    deadline = time.perf_counter() + timeout
-
-    while True:
-      event = None
-      try:
-        event = self.pop_event(event_name, 1)
-      except queue.Empty:
-        pass
-
-      if event and predicate(event, *args, **kwargs):
-        return event
-
-      if time.perf_counter() > deadline:
-        raise queue.Empty(
-            "Timeout after {}s waiting for event: {}".format(
-                timeout, event_name
-            )
-        )
-
-  def pop_events(self, regex_pattern, timeout):
-    """Pop events whose names match a regex pattern.
-
-    If such event(s) exist, pop one event from each event queue that
-    satisfies the condition. Otherwise, wait for an event that satisfies
-    the condition to occur, with timeout.
-
-    Results are sorted by timestamp in ascending order.
-
-    Args:
-      regex_pattern: The regular expression pattern that an event name
-        should match in order to be popped.
-      timeout: Number of seconds to wait for events in case no event
-        matching the condition exits when the function is called.
-
-    Returns:
-      Events whose names match a regex pattern.
-      Empty if none exist and the wait timed out.
-
-    Raises:
-      IllegalStateError: Raised if pop is called before the dispatcher
-        starts polling.
-      queue.Empty: Raised if no event was found before time out.
-    """
-    if not self.started:
-      raise IllegalStateError("Dispatcher needs to be started before popping.")
-    deadline = time.perf_counter() + timeout
-    while True:
-      # TODO: fix the sleep loop
-      results = self._match_and_pop(regex_pattern)
-      if len(results) != 0 or time.perf_counter() > deadline:
-        break
-      time.sleep(1)
-    if len(results) == 0:
-      raise queue.Empty(
-          "Timeout after {}s waiting for event: {}".format(
-              timeout, regex_pattern
-          )
-      )
-
-    return sorted(results, key=lambda event: event["time"])
-
-  def _match_and_pop(self, regex_pattern):
-    """Pop one event from each of the event queues whose names
-    match (in a sense of regular expression) regex_pattern.
-    """
-    results = []
-    self.lock.acquire()
-    for name in self.event_dict.keys():
-      if re.match(regex_pattern, name):
-        q = self.event_dict[name]
-        if q:
-          try:
-            results.append(q.get(False))
-          except Exception:
-            pass
-    self.lock.release()
-    return results
-
-  def get_event_q(self, event_name):
-    """Obtain the queue storing events of the specified name.
-
-    If no event of this name has been polled, wait for one to.
-
-    Returns:
-      A queue storing all the events of the specified name.
-      None if timed out.
-
-    Raises:
-      queue.Empty: Raised if the queue does not exist and timeout has
-        passed.
-    """
-    self.lock.acquire()
-    if event_name not in self.event_dict or self.event_dict[event_name] is None:
-      self.event_dict[event_name] = queue.Queue()
-    self.lock.release()
-
-    event_queue = self.event_dict[event_name]
-    return event_queue
-
-  def handle_subscribed_event(self, event_obj, event_name):
-    """Execute the registered handler of an event.
-
-    Retrieve the handler and its arguments, and execute the handler in a
-      new thread.
-
-    Args:
-      event_obj: Json object of the event.
-      event_name: Name of the event to call handler for.
-    """
-    handler, args = self.handlers[event_name]
-    self.executor.submit(handler, event_obj, *args)
-
-  def _handle(
-      self,
-      event_handler,
-      event_name,
-      user_args,
-      event_timeout,
-      cond,
-      cond_timeout,
-  ):
-    """Pop an event of specified type and calls its handler on it. If
-    condition is not None, block until condition is met or timeout.
-    """
-    if cond:
-      cond.wait(cond_timeout)
-    event = self.pop_event(event_name, event_timeout)
-    return event_handler(event, *user_args)
-
-  def handle_event(
-      self,
-      event_handler,
-      event_name,
-      user_args,
-      event_timeout=None,
-      cond=None,
-      cond_timeout=None,
-  ):
-    """Handle events that don't have registered handlers
-
-    In a new thread, poll one event of specified type from its queue and
-    execute its handler. If no such event exists, the thread waits until
-    one appears.
-
-    Args:
-      event_handler: Handler for the event, which should take at least
-        one argument - the event json object.
-      event_name: Name of the event to be handled.
-      user_args: User arguments for the handler; to be passed in after
-        the event json.
-      event_timeout: Number of seconds to wait for the event to come.
-      cond: A condition to wait on before executing the handler. Should
-        be a threading.Event object.
-      cond_timeout: Number of seconds to wait before the condition times
-        out. Never times out if None.
-
-    Returns:
-      A concurrent.Future object associated with the handler.
-      If blocking call worker.result() is triggered, the handler
-      needs to return something to unblock.
-    """
-    worker = self.executor.submit(
-        self._handle,
-        event_handler,
-        event_name,
-        user_args,
-        event_timeout,
-        cond,
-        cond_timeout,
-    )
-    return worker
-
-  def pop_all(self, event_name):
-    """Return and remove all stored events of a specified name.
-
-    Pops all events from their queue. May miss the latest ones.
-    If no event is available, return immediately.
-
-    Args:
-      event_name: Name of the events to be popped.
-
-    Returns:
-      List of the desired events.
-
-    Raises:
-      IllegalStateError: Raised if pop is called before the dispatcher
-        starts polling.
-    """
-    if not self.started:
-      raise IllegalStateError("Dispatcher needs to be started before popping.")
-    results = []
-    try:
-      self.lock.acquire()
-      while True:
-        e = self.event_dict[event_name].get(block=False)
-        results.append(e)
-    except (queue.Empty, KeyError):
-      return results
-    finally:
-      self.lock.release()
-
-  def clear_events(self, event_name):
-    """Clear all events of a particular name.
-
-    Args:
-      event_name: Name of the events to be popped.
-    """
-    self.lock.acquire()
-    try:
-      q = self.get_event_q(event_name)
-      q.queue.clear()
-    except queue.Empty:
-      return
-    finally:
-      self.lock.release()
-
-  def clear_all_events(self):
-    """Clear all event queues and their cached events."""
-    self.lock.acquire()
-    self.event_dict.clear()
-    self.lock.release()
diff --git a/mobly/controllers/android_device_lib/fastboot.py b/mobly/controllers/android_device_lib/fastboot.py
index cac08f1..e4aab8e 100644
--- a/mobly/controllers/android_device_lib/fastboot.py
+++ b/mobly/controllers/android_device_lib/fastboot.py
@@ -17,6 +17,9 @@
 
 from mobly import utils
 
+# Command to use for running fastboot commands.
+FASTBOOT = 'fastboot'
+
 
 def exe_cmd(*cmds):
   """Executes commands in a new shell. Directing stderr to PIPE.
@@ -60,16 +63,17 @@
 
   def __init__(self, serial=''):
     self.serial = serial
-    if serial:
-      self.fastboot_str = 'fastboot -s {}'.format(serial)
-    else:
-      self.fastboot_str = 'fastboot'
+
+  def fastboot_str(self):
+    if self.serial:
+      return '{} -s {}'.format(FASTBOOT, self.serial)
+    return FASTBOOT
 
   def _exec_fastboot_cmd(self, name, arg_str):
-    return exe_cmd(' '.join((self.fastboot_str, name, arg_str)))
+    return exe_cmd(' '.join((self.fastboot_str(), name, arg_str)))
 
   def args(self, *args):
-    return exe_cmd(' '.join((self.fastboot_str,) + args))
+    return exe_cmd(' '.join((self.fastboot_str(),) + args))
 
   def __getattr__(self, name):
     def fastboot_call(*args):
diff --git a/mobly/controllers/android_device_lib/service_manager.py b/mobly/controllers/android_device_lib/service_manager.py
index 17d5a1f..08fbaa1 100644
--- a/mobly/controllers/android_device_lib/service_manager.py
+++ b/mobly/controllers/android_device_lib/service_manager.py
@@ -122,6 +122,25 @@
       ):
         func(self._service_objects[alias])
 
+  def get_service_alias_by_class(self, service_class):
+    """Gets the aslias name of a registered service.
+
+    The same service class can be registered multiple times with different
+    aliases. When not well managed, duplication and race conditions can arise.
+    One can use this API to de-duplicate as needed.
+
+    Args:
+      service_class: class, the class of a service type.
+
+    Returns:
+      list of strings, the aliases the service is registered with.
+    """
+    aliases = []
+    for alias, service_object in self._service_objects.items():
+      if isinstance(service_object, service_class):
+        aliases.append(alias)
+    return aliases
+
   def list_live_services(self):
     """Lists the aliases of all the services that are alive.
 
diff --git a/mobly/controllers/android_device_lib/services/sl4a_service.py b/mobly/controllers/android_device_lib/services/sl4a_service.py
deleted file mode 100644
index d5c5128..0000000
--- a/mobly/controllers/android_device_lib/services/sl4a_service.py
+++ /dev/null
@@ -1,60 +0,0 @@
-# Copyright 2018 Google Inc.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-"""Module for the Sl4aService."""
-
-from mobly.controllers.android_device_lib import sl4a_client
-from mobly.controllers.android_device_lib.services import base_service
-
-
-class Sl4aService(base_service.BaseService):
-  """Service for managing sl4a's client.
-
-  Direct calls on the service object will forwarded to the client object as
-  syntactic sugar. So `Sl4aService.doFoo()` is equivalent to
-  `Sl4aClient.doFoo()`.
-  """
-
-  def __init__(self, device, configs=None):
-    del configs  # Never used.
-    self._ad = device
-    self._sl4a_client = None
-
-  @property
-  def is_alive(self):
-    return self._sl4a_client is not None
-
-  def start(self):
-    self._sl4a_client = sl4a_client.Sl4aClient(ad=self._ad)
-    self._sl4a_client.start_app_and_connect()
-
-  def stop(self):
-    if self.is_alive:
-      self._sl4a_client.stop_app()
-      self._sl4a_client = None
-
-  def pause(self):
-    # Need to stop dispatcher because it continuously polls the device.
-    # It's not necessary to stop the sl4a client.
-    self._sl4a_client.stop_event_dispatcher()
-    self._sl4a_client.clear_host_port()
-
-  def resume(self):
-    # Restore sl4a if needed.
-    self._sl4a_client.restore_app_connection()
-
-  def __getattr__(self, name):
-    """Forwards the getattr calls to the client itself."""
-    if self._sl4a_client:
-      return getattr(self._sl4a_client, name)
-    return self.__getattribute__(name)
diff --git a/mobly/controllers/android_device_lib/sl4a_client.py b/mobly/controllers/android_device_lib/sl4a_client.py
deleted file mode 100644
index ac59f5b..0000000
--- a/mobly/controllers/android_device_lib/sl4a_client.py
+++ /dev/null
@@ -1,168 +0,0 @@
-# Copyright 2016 Google Inc.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-"""JSON RPC interface to android scripting engine."""
-
-import time
-
-from mobly import utils
-from mobly.controllers.android_device_lib import event_dispatcher
-from mobly.controllers.android_device_lib import jsonrpc_client_base
-
-_APP_NAME = 'SL4A'
-_DEVICE_SIDE_PORT = 8080
-_LAUNCH_CMD = (
-    'am start -a com.googlecode.android_scripting.action.LAUNCH_SERVER '
-    '--ei com.googlecode.android_scripting.extra.USE_SERVICE_PORT %s '
-    'com.googlecode.android_scripting/.activity.ScriptingLayerServiceLauncher'
-)
-# Maximum time to wait for the app to start on the device (10 minutes).
-# TODO: This timeout is set high in order to allow for retries in
-# start_app_and_connect. Decrease it when the call to connect() has the option
-# for a quicker timeout than the default _cmd() timeout.
-# TODO: Evaluate whether the high timeout still makes sense for sl4a. It was
-# designed for user snippets which could be very slow to start depending on the
-# size of the snippet and main apps. sl4a can probably use a much smaller value.
-_APP_START_WAIT_TIME = 2 * 60
-
-
-class Sl4aClient(jsonrpc_client_base.JsonRpcClientBase):
-  """A client for interacting with SL4A using Mobly Snippet Lib.
-
-  Extra public attributes:
-  ed: Event dispatcher instance for this sl4a client.
-  """
-
-  def __init__(self, ad):
-    """Initializes an Sl4aClient.
-
-    Args:
-      ad: AndroidDevice object.
-    """
-    super().__init__(app_name=_APP_NAME, ad=ad)
-    self._ad = ad
-    self.ed = None
-    self._adb = ad.adb
-
-  def start_app_and_connect(self):
-    """Overrides superclass."""
-    # Check that sl4a is installed
-    out = self._adb.shell('pm list package')
-    if not utils.grep('com.googlecode.android_scripting', out):
-      raise jsonrpc_client_base.AppStartError(
-          self._ad, '%s is not installed on %s' % (_APP_NAME, self._adb.serial)
-      )
-    self.disable_hidden_api_blacklist()
-
-    # sl4a has problems connecting after disconnection, so kill the apk and
-    # try connecting again.
-    try:
-      self.stop_app()
-    except Exception as e:
-      self.log.warning(e)
-
-    # Launch the app
-    self.device_port = _DEVICE_SIDE_PORT
-    self._adb.shell(_LAUNCH_CMD % self.device_port)
-
-    # Try to start the connection (not restore the connectivity).
-    # The function name restore_app_connection is used here is for the
-    # purpose of reusing the same code as it does when restoring the
-    # connection. And we do not want to come up with another function
-    # name to complicate the API. Change the name if necessary.
-    self.restore_app_connection()
-
-  def restore_app_connection(self, port=None):
-    """Restores the sl4a after device got disconnected.
-
-    Instead of creating new instance of the client:
-      - Uses the given port (or find a new available host_port if none is
-      given).
-      - Tries to connect to remote server with selected port.
-
-    Args:
-      port: If given, this is the host port from which to connect to remote
-        device port. If not provided, find a new available port as host
-        port.
-
-    Raises:
-      AppRestoreConnectionError: When the app was not able to be started.
-    """
-    self.host_port = port or utils.get_available_host_port()
-    self._retry_connect()
-    self.ed = self._start_event_client()
-
-  def stop_app(self):
-    """Overrides superclass."""
-    try:
-      if self._conn:
-        # Be polite; let the dest know we're shutting down.
-        try:
-          self.closeSl4aSession()
-        except Exception:
-          self.log.exception(
-              'Failed to gracefully shut down %s.', self.app_name
-          )
-
-        # Close the socket connection.
-        self.disconnect()
-        self.stop_event_dispatcher()
-
-      # Terminate the app
-      self._adb.shell('am force-stop com.googlecode.android_scripting')
-    finally:
-      # Always clean up the adb port
-      self.clear_host_port()
-
-  def stop_event_dispatcher(self):
-    # Close Event Dispatcher
-    if self.ed:
-      try:
-        self.ed.clean_up()
-      except Exception:
-        self.log.exception('Failed to shutdown sl4a event dispatcher.')
-      self.ed = None
-
-  def _retry_connect(self):
-    self._adb.forward(['tcp:%d' % self.host_port, 'tcp:%d' % self.device_port])
-    expiration_time = time.perf_counter() + _APP_START_WAIT_TIME
-    started = False
-    while time.perf_counter() < expiration_time:
-      self.log.debug('Attempting to start %s.', self.app_name)
-      try:
-        self.connect()
-        started = True
-        break
-      except Exception:
-        self.log.debug(
-            '%s is not yet running, retrying', self.app_name, exc_info=True
-        )
-      time.sleep(1)
-    if not started:
-      raise jsonrpc_client_base.AppRestoreConnectionError(
-          self._ad,
-          '%s failed to connect for %s at host port %s, device port %s'
-          % (self.app_name, self._adb.serial, self.host_port, self.device_port),
-      )
-
-  def _start_event_client(self):
-    # Start an EventDispatcher for the current sl4a session
-    event_client = Sl4aClient(self._ad)
-    event_client.host_port = self.host_port
-    event_client.device_port = self.device_port
-    event_client.connect(
-        uid=self.uid, cmd=jsonrpc_client_base.JsonRpcCommand.CONTINUE
-    )
-    ed = event_dispatcher.EventDispatcher(event_client)
-    ed.start()
-    return ed
diff --git a/mobly/suite_runner.py b/mobly/suite_runner.py
index b0b064c..a7f7cf1 100644
--- a/mobly/suite_runner.py
+++ b/mobly/suite_runner.py
@@ -114,8 +114,10 @@
       '--test_case',
       nargs='+',
       type=str,
-      metavar='[ClassA[.test_a] ClassB[.test_b] ...]',
-      help='A list of test classes and optional tests to execute.',
+      metavar='[ClassA[_test_suffix][.test_a] '
+      'ClassB[_test_suffix][.test_b] ...]',
+      help='A list of test classes and optional tests to execute. '
+      'Note: test_suffix based names are only supported when running by suite class',
   )
   parser.add_argument(
       '-tb',
@@ -137,21 +139,63 @@
   return parser.parse_known_args(argv)[0]
 
 
+def _find_suite_classes_in_module(module):
+  """Finds all test suite classes in the given module.
+
+  Walk through module members and find all classes that is a subclass of
+  BaseSuite.
+
+  Args:
+    module: types.ModuleType, the module object to find test suite classes.
+
+  Returns:
+    A list of test suite classes.
+  """
+  test_suites = []
+  for _, module_member in module.__dict__.items():
+    if inspect.isclass(module_member):
+      if issubclass(module_member, base_suite.BaseSuite):
+        test_suites.append(module_member)
+  return test_suites
+
+
 def _find_suite_class():
-  """Finds the test suite class in the current module.
+  """Finds the test suite class.
+
+  First search for test suite classes in the __main__ module. If no test suite
+  class is found, search in the module that is calling
+  `suite_runner.run_suite_class`.
 
   Walk through module members and find the subclass of BaseSuite. Only
-  one subclass is allowed in a module.
+  one subclass is allowed.
 
   Returns:
       The test suite class in the test module.
   """
-  test_suites = []
-  main_module_members = sys.modules['__main__']
-  for _, module_member in main_module_members.__dict__.items():
-    if inspect.isclass(module_member):
-      if issubclass(module_member, base_suite.BaseSuite):
-        test_suites.append(module_member)
+  # Try to find test suites in __main__ module first.
+  test_suites = _find_suite_classes_in_module(sys.modules['__main__'])
+
+  # Try to find test suites in the module of the caller of `run_suite_class`.
+  if len(test_suites) == 0:
+    logging.debug(
+        'No suite class found in the __main__ module, trying to find it in the '
+        'module of the caller of suite_runner.run_suite_class method.'
+    )
+    stacks = inspect.stack()
+    if len(stacks) < 2:
+      logging.debug(
+          'Failed to get the caller stack of run_suite_class. Got stacks: %s',
+          stacks,
+      )
+    else:
+      run_suite_class_caller_frame_info = inspect.stack()[2]
+      caller_frame = run_suite_class_caller_frame_info.frame
+      module = inspect.getmodule(caller_frame)
+      if module is None:
+        logging.debug('Failed to find module for frame %s', caller_frame)
+      else:
+        test_suites = _find_suite_classes_in_module(module)
+
   if len(test_suites) != 1:
     logging.error(
         'Expected 1 test class per file, found %s.',
@@ -161,6 +205,33 @@
   return test_suites[0]
 
 
+def _print_test_names_for_suite(suite_class):
+  """Prints the names of all the tests in a suite classes.
+
+  Args:
+    suite_class: a test suite_class to be run.
+  """
+  config = config_parser.TestRunConfig()
+  runner = test_runner.TestRunner(
+      log_dir=config.log_path, testbed_name=config.testbed_name
+  )
+  cls = suite_class(runner, config)
+  try:
+    cls.setup_suite(config)
+  finally:
+    cls.teardown_suite()
+
+  last = ''
+  for name in runner.get_full_test_names():
+    tag = name.split('.')[0]
+    # Print tags when we encounter a new one. Prefer this to grouping by
+    # tag first since we should print any duplicate entries.
+    if tag != last:
+      last = tag
+      print('==========> %s <==========' % tag)
+    print(name)
+
+
 def _print_test_names(test_classes):
   """Prints the names of all the tests in all test classes.
   Args:
@@ -197,7 +268,7 @@
   cli_args = _parse_cli_args(argv)
   suite_class = _find_suite_class()
   if cli_args.list_tests:
-    _print_test_names([suite_class])
+    _print_test_names_for_suite(suite_class)
     sys.exit(0)
   test_configs = config_parser.load_test_config_file(
       cli_args.config, cli_args.test_bed
@@ -210,6 +281,8 @@
       log_dir=config.log_path, testbed_name=config.testbed_name
   )
   suite = suite_class(runner, config)
+  test_selector = _parse_raw_test_selector(cli_args.tests)
+  suite.set_test_selector(test_selector)
   console_level = logging.DEBUG if cli_args.verbose else logging.INFO
   ok = False
   with runner.mobly_logger(console_level=console_level):
@@ -287,8 +360,8 @@
   that class are selected.
 
   Args:
-    test_classes: list of strings, names of all the classes that are part
-      of a suite.
+    test_classes: list of `type[base_test.BaseTestClass]`, all the test classes
+      that are part of a suite.
     selected_tests: list of strings, list of tests to execute. If empty,
       all classes `test_classes` are selected. E.g.
 
@@ -324,31 +397,81 @@
     return class_to_tests
 
   # The user is selecting some tests to run. Parse the selectors.
-  # Dict from test_name class name to list of tests to execute (or None for all
-  # tests).
-  test_class_name_to_tests = collections.OrderedDict()
-  for test_name in selected_tests:
-    if '.' in test_name:  # Has a test method
-      (test_class_name, test_name) = test_name.split('.', maxsplit=1)
-      if test_class_name not in test_class_name_to_tests:
-        # Never seen this class before
-        test_class_name_to_tests[test_class_name] = [test_name]
-      elif test_class_name_to_tests[test_class_name] is None:
-        # Already running all tests in this class, so ignore this extra
-        # test.
-        pass
-      else:
-        test_class_name_to_tests[test_class_name].append(test_name)
-    else:  # No test method; run all tests in this class.
-      test_class_name_to_tests[test_name] = None
+  test_class_name_to_tests = _parse_raw_test_selector(selected_tests)
 
-  # Now transform class names to class objects.
-  # Dict from test_name class name to instance.
+  # Now compute the tests to run for each test class.
+  # Dict from test class name to class instance.
   class_name_to_class = {cls.__name__: cls for cls in test_classes}
-  for test_class_name, tests in test_class_name_to_tests.items():
+  for test_tuple, tests in test_class_name_to_tests.items():
+    (test_class_name, test_suffix) = test_tuple
+    if test_suffix != None:
+      raise Error('Suffixed tests only compatible with suite class runs')
     test_class = class_name_to_class.get(test_class_name)
     if not test_class:
-      raise Error('Unknown test_name class %s' % test_class_name)
+      raise Error('Unknown test_class name %s' % test_class_name)
     class_to_tests[test_class] = tests
 
   return class_to_tests
+
+
+def _parse_raw_test_selector(selected_tests):
+  """Parses test selector from CLI arguments.
+
+  This function transforms a list of selector strings (such as FooTest or
+  FooTest.test_method_a) to a dict where keys are a tuple containing
+  (test_class_name, test_suffix) and values are lists of selected tests in
+  those classes. None means all tests in that class are selected.
+
+  Args:
+    selected_tests: list of strings, list of tests to execute of the form:
+      <test_class_name>[_<test_suffix>][.<test_name>].
+
+    .. code-block:: python
+      [
+        'BarTest',
+        'FooTest_A',
+        'FooTest_B'
+        'FooTest_C.test_method_a'
+        'FooTest_C.test_method_b'
+        'BazTest.test_method_a',
+        'BazTest.test_method_b'
+      ]
+
+  Returns:
+    dict: Keys are a tuple of (test_class_name, test_suffix), and values are
+    lists of test names within class.
+      E.g. the example in
+      `tests` would translate to:
+
+      .. code-block:: python
+        {
+          (BarTest, None): None,
+          (FooTest, 'A'): None,
+          (FooTest, 'B'): None,
+          (FooTest,)'C'): ['test_method_a', 'test_method_b'],
+          (BazTest, None): ['test_method_a', 'test_method_b']
+        }
+  """
+  if selected_tests is None:
+    return None
+  test_class_to_tests = collections.OrderedDict()
+  for test in selected_tests:
+    test_class_name = test
+    test_name = None
+    test_suffix = None
+    if '.' in test_class_name:
+      (test_class_name, test_name) = test_class_name.split('.', maxsplit=1)
+    if '_' in test_class_name:
+      (test_class_name, test_suffix) = test_class_name.split('_', maxsplit=1)
+
+    key = (test_class_name, test_suffix)
+    if key not in test_class_to_tests:
+      test_class_to_tests[key] = []
+
+    # If the test name is None, it means all tests in the class are selected.
+    if test_name is None:
+      test_class_to_tests[key] = None
+    # Only add the test if we're not already running all tests in the class.
+    elif test_class_to_tests[key] is not None:
+      test_class_to_tests[key].append(test_name)
+  return test_class_to_tests
diff --git a/mobly/test_runner.py b/mobly/test_runner.py
index 5c97113..b32f5b0 100644
--- a/mobly/test_runner.py
+++ b/mobly/test_runner.py
@@ -126,8 +126,12 @@
       '--test_case',
       nargs='+',
       type=str,
-      metavar='[test_a test_b...]',
-      help='A list of tests in the test class to execute.',
+      metavar='[test_a test_b re:test_(c|d)...]',
+      help=(
+          'A list of tests in the test class to execute. Each value can be a '
+          'test name string or a `re:` prefixed string for full regex match of'
+          ' test names.'
+      ),
   )
   parser.add_argument(
       '-tb',
@@ -306,6 +310,53 @@
         return None
       return self._end_counter - self._start_counter
 
+  def get_full_test_names(self):
+    """Returns the names of all tests that will be run in this test runner.
+
+    Returns:
+      A list of test names. Each test name is in the format of
+      <test.TAG>.<test_name>.
+    """
+    test_names = []
+    for test_run_info in self._test_run_infos:
+      test_config = test_run_info.config.copy()
+      test_config.test_class_name_suffix = test_run_info.test_class_name_suffix
+      test = test_run_info.test_class(test_config)
+
+      tests = self._get_test_names_from_class(test)
+      if test_run_info.tests is not None:
+        # If tests is provided, verify that all tests exist in the class.
+        tests_set = set(tests)
+        for test_name in test_run_info.tests:
+          if test_name not in tests_set:
+            raise Error(
+                'Unknown test method: %s in class %s', (test_name, test.TAG)
+            )
+          test_names.append(f'{test.TAG}.{test_name}')
+      else:
+        test_names.extend([f'{test.TAG}.{n}' for n in tests])
+
+    return test_names
+
+  def _get_test_names_from_class(self, test):
+    """Returns the names of all the tests in a test class.
+
+    Args:
+      test: module, the test module to print names from.
+    """
+    try:
+      # Executes pre-setup procedures, this is required since it might
+      # generate test methods that we want to return as well.
+      test._pre_run()
+      if test.tests:
+        # Specified by run list in class.
+        return list(test.tests)
+      else:
+        # No test method specified by user, list all in test class.
+        return test.get_existing_test_names()
+    finally:
+      test._clean_up()
+
   def __init__(self, log_dir, testbed_name):
     """Constructor for TestRunner.
 
diff --git a/pyproject.toml b/pyproject.toml
index 601ee9e..bb35d30 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@
 
 [tool.setuptools]
 include-package-data = false
-script-files = [ "tools/sl4a_shell.py", "tools/snippet_shell.py",]
+script-files = ["tools/snippet_shell.py"]
 
 [tool.pyink]
 line-length = 80
diff --git a/tests/lib/integration_test_suite.py b/tests/lib/integration_test_suite.py
new file mode 100644
index 0000000..dd95ab0
--- /dev/null
+++ b/tests/lib/integration_test_suite.py
@@ -0,0 +1,31 @@
+# Copyright 2024 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from mobly import base_suite
+from mobly import suite_runner
+from tests.lib import integration_test
+
+
+class IntegrationTestSuite(base_suite.BaseSuite):
+
+  def setup_suite(self, config):
+    self.add_test_class(integration_test.IntegrationTest)
+
+
+def main():
+  suite_runner.run_suite_class()
+
+
+if __name__ == "__main__":
+  main()
diff --git a/tests/mobly/base_suite_test.py b/tests/mobly/base_suite_test.py
new file mode 100644
index 0000000..43f9213
--- /dev/null
+++ b/tests/mobly/base_suite_test.py
@@ -0,0 +1,139 @@
+# Copyright 2024 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import io
+import os
+import shutil
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+from mobly import base_suite
+from mobly import base_test
+from mobly import suite_runner
+from mobly import test_runner
+from mobly import config_parser
+from tests.lib import integration2_test
+from tests.lib import integration_test
+
+
+class FakeTest1(base_test.BaseTestClass):
+
+  def test_a(self):
+    pass
+
+  def test_b(self):
+    pass
+
+  def test_c(self):
+    pass
+
+
+class FakeTest2(base_test.BaseTestClass):
+
+  def test_2(self):
+    pass
+
+
+class FakeTestSuite(base_suite.BaseSuite):
+
+  def setup_suite(self, config):
+    self.add_test_class(FakeTest1, config)
+    self.add_test_class(FakeTest2, config)
+
+
+class FakeTestSuiteWithFilteredTests(base_suite.BaseSuite):
+
+  def setup_suite(self, config):
+    self.add_test_class(FakeTest1, config, ['test_a', 'test_b'])
+    self.add_test_class(FakeTest2, config, ['test_2'])
+
+
+class BaseSuiteTest(unittest.TestCase):
+
+  def setUp(self):
+    super().setUp()
+    self.mock_config = mock.Mock(autospec=config_parser.TestRunConfig)
+    self.mock_test_runner = mock.Mock(autospec=test_runner.TestRunner)
+
+  def test_setup_suite(self):
+    suite = FakeTestSuite(self.mock_test_runner, self.mock_config)
+    suite.set_test_selector(None)
+
+    suite.setup_suite(self.mock_config)
+
+    self.mock_test_runner.add_test_class.assert_has_calls(
+        [
+            mock.call(self.mock_config, FakeTest1, mock.ANY, mock.ANY),
+            mock.call(self.mock_config, FakeTest2, mock.ANY, mock.ANY),
+        ],
+    )
+
+  def test_setup_suite_with_test_selector(self):
+    suite = FakeTestSuite(self.mock_test_runner, self.mock_config)
+    test_selector = {
+        'FakeTest1': ['test_a', 'test_b'],
+        'FakeTest2': None,
+    }
+
+    suite.set_test_selector(test_selector)
+    suite.setup_suite(self.mock_config)
+
+    self.mock_test_runner.add_test_class.assert_has_calls(
+        [
+            mock.call(
+                self.mock_config, FakeTest1, ['test_a', 'test_b'], mock.ANY
+            ),
+            mock.call(self.mock_config, FakeTest2, None, mock.ANY),
+        ],
+    )
+
+  def test_setup_suite_test_selector_takes_precedence(self):
+    suite = FakeTestSuiteWithFilteredTests(
+        self.mock_test_runner, self.mock_config
+    )
+    test_selector = {
+        'FakeTest1': ['test_a', 'test_c'],
+        'FakeTest2': None,
+    }
+
+    suite.set_test_selector(test_selector)
+    suite.setup_suite(self.mock_config)
+
+    self.mock_test_runner.add_test_class.assert_has_calls(
+        [
+            mock.call(
+                self.mock_config, FakeTest1, ['test_a', 'test_c'], mock.ANY
+            ),
+            mock.call(self.mock_config, FakeTest2, None, mock.ANY),
+        ],
+    )
+
+  def test_setup_suite_with_skip_test_class(self):
+    suite = FakeTestSuite(self.mock_test_runner, self.mock_config)
+    test_selector = {'FakeTest1': None}
+
+    suite.set_test_selector(test_selector)
+    suite.setup_suite(self.mock_config)
+
+    self.mock_test_runner.add_test_class.assert_has_calls(
+        [
+            mock.call(self.mock_config, FakeTest1, None, mock.ANY),
+        ],
+    )
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/tests/mobly/base_test_test.py b/tests/mobly/base_test_test.py
index bfc7b75..f389133 100755
--- a/tests/mobly/base_test_test.py
+++ b/tests/mobly/base_test_test.py
@@ -201,6 +201,96 @@
     actual_record = bt_cls.results.passed[0]
     self.assertEqual(actual_record.test_name, 'test_something')
 
+  def test_cli_test_selection_with_regex(self):
+    class MockBaseTest(base_test.BaseTestClass):
+
+      def __init__(self, controllers):
+        super().__init__(controllers)
+        self.tests = ('test_never',)
+
+      def test_foo(self):
+        pass
+
+      def test_a(self):
+        pass
+
+      def test_b(self):
+        pass
+
+      def test_something_1(self):
+        pass
+
+      def test_something_2(self):
+        pass
+
+      def test_something_3(self):
+        pass
+
+      def test_never(self):
+        # This should not execute since it's not selected by cmd line input.
+        never_call()
+
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    bt_cls.run(test_names=['re:test_something_.*', 'test_foo', 're:test_(a|b)'])
+    self.assertEqual(len(bt_cls.results.passed), 6)
+    self.assertEqual(bt_cls.results.passed[0].test_name, 'test_something_1')
+    self.assertEqual(bt_cls.results.passed[1].test_name, 'test_something_2')
+    self.assertEqual(bt_cls.results.passed[2].test_name, 'test_something_3')
+    self.assertEqual(bt_cls.results.passed[3].test_name, 'test_foo')
+    self.assertEqual(bt_cls.results.passed[4].test_name, 'test_a')
+    self.assertEqual(bt_cls.results.passed[5].test_name, 'test_b')
+
+  def test_cli_test_selection_with_regex_generated_tests(self):
+    class MockBaseTest(base_test.BaseTestClass):
+
+      def __init__(self, controllers):
+        super().__init__(controllers)
+        self.tests = ('test_never',)
+
+      def pre_run(self):
+        self.generate_tests(
+            test_logic=self.logic,
+            name_func=lambda i: f'test_something_{i}',
+            arg_sets=[(i + 1,) for i in range(3)],
+        )
+
+      def test_foo(self):
+        pass
+
+      def logic(self, _):
+        pass
+
+      def test_never(self):
+        # This should not execute since it's not selected by cmd line input.
+        never_call()
+
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    bt_cls.run(test_names=['re:test_something_.*', 'test_foo'])
+    self.assertEqual(len(bt_cls.results.passed), 4)
+    self.assertEqual(bt_cls.results.passed[0].test_name, 'test_something_1')
+    self.assertEqual(bt_cls.results.passed[1].test_name, 'test_something_2')
+    self.assertEqual(bt_cls.results.passed[2].test_name, 'test_something_3')
+    self.assertEqual(bt_cls.results.passed[3].test_name, 'test_foo')
+
+  def test_cli_test_selection_with_regex_fail_by_convention(self):
+    class MockBaseTest(base_test.BaseTestClass):
+
+      def __init__(self, controllers):
+        super().__init__(controllers)
+        self.tests = ('test_never',)
+
+      def test_something(self):
+        pass
+
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    expected_msg = (
+        r'not_a_test_something does not match with any valid test case in '
+        r'MockBaseTest, abort!'
+    )
+    with self.assertRaisesRegex(base_test.Error, expected_msg):
+      bt_cls.run(test_names=['re:not_a_test_something'])
+    self.assertEqual(len(bt_cls.results.passed), 0)
+
   def test_cli_test_selection_fail_by_convention(self):
     class MockBaseTest(base_test.BaseTestClass):
 
@@ -2038,63 +2128,6 @@
     self.assertEqual(class_record.test_name, 'pre_run')
     self.assertEqual(bt_cls.results.skipped, [])
 
-  # TODO(angli): remove after the full deprecation of `setup_generated_tests`.
-  def test_setup_generated_tests(self):
-    class MockBaseTest(base_test.BaseTestClass):
-
-      def setup_generated_tests(self):
-        self.generate_tests(
-            test_logic=self.logic,
-            name_func=self.name_gen,
-            arg_sets=[(1, 2), (3, 4)],
-        )
-
-      def name_gen(self, a, b):
-        return 'test_%s_%s' % (a, b)
-
-      def logic(self, a, b):
-        pass
-
-    bt_cls = MockBaseTest(self.mock_test_cls_configs)
-    bt_cls.run()
-    self.assertEqual(len(bt_cls.results.requested), 2)
-    self.assertEqual(len(bt_cls.results.passed), 2)
-    self.assertIsNone(bt_cls.results.passed[0].uid)
-    self.assertIsNone(bt_cls.results.passed[1].uid)
-    self.assertEqual(bt_cls.results.passed[0].test_name, 'test_1_2')
-    self.assertEqual(bt_cls.results.passed[1].test_name, 'test_3_4')
-
-  # TODO(angli): remove after the full deprecation of `setup_generated_tests`.
-  def test_setup_generated_tests_failure(self):
-    """Test code path for setup_generated_tests failure.
-
-    When setup_generated_tests fails, pre-execution calculation is
-    incomplete and the number of tests requested is unknown. This is a
-    fatal issue that blocks any test execution in a class.
-
-    A class level error record is generated.
-    Unlike `setup_class` failure, no test is considered "skipped" in this
-    case as execution stage never started.
-    """
-
-    class MockBaseTest(base_test.BaseTestClass):
-
-      def setup_generated_tests(self):
-        raise Exception(MSG_EXPECTED_EXCEPTION)
-
-      def logic(self, a, b):
-        pass
-
-      def test_foo(self):
-        pass
-
-    bt_cls = MockBaseTest(self.mock_test_cls_configs)
-    bt_cls.run()
-    self.assertEqual(len(bt_cls.results.requested), 0)
-    class_record = bt_cls.results.error[0]
-    self.assertEqual(class_record.test_name, 'pre_run')
-    self.assertEqual(bt_cls.results.skipped, [])
-
   def test_generate_tests_run(self):
     class MockBaseTest(base_test.BaseTestClass):
 
@@ -2218,7 +2251,7 @@
     self.assertEqual(
         actual_record.details,
         "'generate_tests' cannot be called outside of the followin"
-        "g functions: ['pre_run', 'setup_generated_tests'].",
+        "g functions: ['pre_run'].",
     )
     expected_summary = (
         'Error 1, Executed 1, Failed 0, Passed 0, Requested 1, Skipped 0'
diff --git a/tests/mobly/controllers/android_device_lib/fastboot_test.py b/tests/mobly/controllers/android_device_lib/fastboot_test.py
index 86b697a..10bc953 100644
--- a/tests/mobly/controllers/android_device_lib/fastboot_test.py
+++ b/tests/mobly/controllers/android_device_lib/fastboot_test.py
@@ -21,6 +21,9 @@
 class FastbootTest(unittest.TestCase):
   """Unit tests for mobly.controllers.android_device_lib.adb."""
 
+  def setUp(self):
+    fastboot.FASTBOOT = 'fastboot'
+
   @mock.patch('mobly.controllers.android_device_lib.fastboot.Popen')
   @mock.patch('logging.debug')
   def test_fastboot_commands_and_results_are_logged_to_debug_log(
@@ -43,6 +46,82 @@
         123,
     )
 
+  @mock.patch('mobly.controllers.android_device_lib.fastboot.Popen')
+  def test_fastboot_without_serial(self, mock_popen):
+    expected_stdout = 'stdout'
+    expected_stderr = b'stderr'
+    mock_popen.return_value.communicate = mock.Mock(
+        return_value=(expected_stdout, expected_stderr)
+    )
+    mock_popen.return_value.returncode = 123
+
+    fastboot.FastbootProxy().fake_command('extra', 'flags')
+
+    mock_popen.assert_called_with(
+        'fastboot fake-command extra flags',
+        stdout=mock.ANY,
+        stderr=mock.ANY,
+        shell=True,
+    )
+
+  @mock.patch('mobly.controllers.android_device_lib.fastboot.Popen')
+  def test_fastboot_with_serial(self, mock_popen):
+    expected_stdout = 'stdout'
+    expected_stderr = b'stderr'
+    mock_popen.return_value.communicate = mock.Mock(
+        return_value=(expected_stdout, expected_stderr)
+    )
+    mock_popen.return_value.returncode = 123
+
+    fastboot.FastbootProxy('ABC').fake_command('extra', 'flags')
+
+    mock_popen.assert_called_with(
+        'fastboot -s ABC fake-command extra flags',
+        stdout=mock.ANY,
+        stderr=mock.ANY,
+        shell=True,
+    )
+
+  @mock.patch('mobly.controllers.android_device_lib.fastboot.Popen')
+  def test_fastboot_update_serial(self, mock_popen):
+    expected_stdout = 'stdout'
+    expected_stderr = b'stderr'
+    mock_popen.return_value.communicate = mock.Mock(
+        return_value=(expected_stdout, expected_stderr)
+    )
+    mock_popen.return_value.returncode = 123
+
+    fut = fastboot.FastbootProxy('ABC')
+    fut.fake_command('extra', 'flags')
+    fut.serial = 'XYZ'
+    fut.fake_command('extra', 'flags')
+
+    mock_popen.assert_called_with(
+        'fastboot -s XYZ fake-command extra flags',
+        stdout=mock.ANY,
+        stderr=mock.ANY,
+        shell=True,
+    )
+
+  @mock.patch('mobly.controllers.android_device_lib.fastboot.Popen')
+  def test_fastboot_use_customized_fastboot(self, mock_popen):
+    expected_stdout = 'stdout'
+    expected_stderr = b'stderr'
+    mock_popen.return_value.communicate = mock.Mock(
+        return_value=(expected_stdout, expected_stderr)
+    )
+    mock_popen.return_value.returncode = 123
+    fastboot.FASTBOOT = 'my_fastboot'
+
+    fastboot.FastbootProxy('ABC').fake_command('extra', 'flags')
+
+    mock_popen.assert_called_with(
+        'my_fastboot -s ABC fake-command extra flags',
+        stdout=mock.ANY,
+        stderr=mock.ANY,
+        shell=True,
+    )
+
 
 if __name__ == '__main__':
   unittest.main()
diff --git a/tests/mobly/controllers/android_device_lib/service_manager_test.py b/tests/mobly/controllers/android_device_lib/service_manager_test.py
index 471c83f..2d608d3 100755
--- a/tests/mobly/controllers/android_device_lib/service_manager_test.py
+++ b/tests/mobly/controllers/android_device_lib/service_manager_test.py
@@ -469,6 +469,14 @@
     with self.assertRaisesRegex(service_manager.Error, msg):
       manager.resume_services(['mock_service'])
 
+  def test_get_alias_by_class(self):
+    manager = service_manager.ServiceManager(mock.MagicMock())
+    manager.register('mock_service1', MockService, start_service=False)
+    manager.register('mock_service2', MockService, start_service=False)
+    manager.start_services(['mock_service2'])
+    aliases = manager.get_service_alias_by_class(MockService)
+    self.assertEqual(aliases, ['mock_service1', 'mock_service2'])
+
 
 if __name__ == '__main__':
   unittest.main()
diff --git a/tests/mobly/controllers/android_device_lib/services/sl4a_service_test.py b/tests/mobly/controllers/android_device_lib/services/sl4a_service_test.py
deleted file mode 100755
index b6de71b..0000000
--- a/tests/mobly/controllers/android_device_lib/services/sl4a_service_test.py
+++ /dev/null
@@ -1,69 +0,0 @@
-# Copyright 2018 Google Inc.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import unittest
-from unittest import mock
-
-from mobly.controllers.android_device_lib import service_manager
-from mobly.controllers.android_device_lib.services import sl4a_service
-
-
[email protected]('mobly.controllers.android_device_lib.sl4a_client.Sl4aClient')
-class Sl4aServiceTest(unittest.TestCase):
-  """Tests for the sl4a service."""
-
-  def test_instantiation(self, _):
-    service = sl4a_service.Sl4aService(mock.MagicMock())
-    self.assertFalse(service.is_alive)
-
-  def test_start(self, mock_sl4a_client_class):
-    mock_client = mock_sl4a_client_class.return_value
-    service = sl4a_service.Sl4aService(mock.MagicMock())
-    service.start()
-    mock_client.start_app_and_connect.assert_called_once_with()
-    self.assertTrue(service.is_alive)
-
-  def test_stop(self, mock_sl4a_client_class):
-    mock_client = mock_sl4a_client_class.return_value
-    service = sl4a_service.Sl4aService(mock.MagicMock())
-    service.start()
-    service.stop()
-    mock_client.stop_app.assert_called_once_with()
-    self.assertFalse(service.is_alive)
-
-  def test_pause(self, mock_sl4a_client_class):
-    mock_client = mock_sl4a_client_class.return_value
-    service = sl4a_service.Sl4aService(mock.MagicMock())
-    service.start()
-    service.pause()
-    mock_client.stop_event_dispatcher.assert_called_once_with()
-    mock_client.clear_host_port.assert_called_once_with()
-
-  def test_resume(self, mock_sl4a_client_class):
-    mock_client = mock_sl4a_client_class.return_value
-    service = sl4a_service.Sl4aService(mock.MagicMock())
-    service.start()
-    service.pause()
-    service.resume()
-    mock_client.restore_app_connection.assert_called_once_with()
-
-  def test_register_with_service_manager(self, _):
-    mock_device = mock.MagicMock()
-    manager = service_manager.ServiceManager(mock_device)
-    manager.register('sl4a', sl4a_service.Sl4aService)
-    self.assertTrue(manager.sl4a)
-
-
-if __name__ == '__main__':
-  unittest.main()
diff --git a/tests/mobly/controllers/android_device_lib/sl4a_client_test.py b/tests/mobly/controllers/android_device_lib/sl4a_client_test.py
deleted file mode 100755
index bf11b07..0000000
--- a/tests/mobly/controllers/android_device_lib/sl4a_client_test.py
+++ /dev/null
@@ -1,95 +0,0 @@
-# Copyright 2017 Google Inc.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import unittest
-from unittest import mock
-
-from mobly.controllers.android_device_lib import jsonrpc_client_base
-from mobly.controllers.android_device_lib import sl4a_client
-from tests.lib import jsonrpc_client_test_base
-from tests.lib import mock_android_device
-
-
-class Sl4aClientTest(jsonrpc_client_test_base.JsonRpcClientTestBase):
-  """Unit tests for mobly.controllers.android_device_lib.sl4a_client."""
-
-  @mock.patch('socket.create_connection')
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.start_standing_subprocess'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  def test_start_app_and_connect(
-      self,
-      mock_get_port,
-      mock_start_standing_subprocess,
-      mock_create_connection,
-  ):
-    self.setup_mock_socket_file(mock_create_connection)
-    self._setup_mock_instrumentation_cmd(
-        mock_start_standing_subprocess, resp_lines=[b'\n']
-    )
-    client = self._make_client()
-    client.start_app_and_connect()
-    self.assertEqual(8080, client.device_port)
-
-  @mock.patch('socket.create_connection')
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.start_standing_subprocess'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  def test_app_not_installed(
-      self,
-      mock_get_port,
-      mock_start_standing_subprocess,
-      mock_create_connection,
-  ):
-    self.setup_mock_socket_file(mock_create_connection)
-    self._setup_mock_instrumentation_cmd(
-        mock_start_standing_subprocess, resp_lines=[b'\n']
-    )
-    client = self._make_client(adb_proxy=mock_android_device.MockAdbProxy())
-    with self.assertRaisesRegex(
-        jsonrpc_client_base.AppStartError, '.* SL4A is not installed on .*'
-    ):
-      client.start_app_and_connect()
-
-  def _make_client(self, adb_proxy=None):
-    adb_proxy = adb_proxy or mock_android_device.MockAdbProxy(
-        installed_packages=['com.googlecode.android_scripting']
-    )
-    ad = mock.Mock()
-    ad.adb = adb_proxy
-    ad.build_info = {
-        'build_version_codename': ad.adb.getprop('ro.build.version.codename'),
-        'build_version_sdk': ad.adb.getprop('ro.build.version.sdk'),
-    }
-    return sl4a_client.Sl4aClient(ad=ad)
-
-  def _setup_mock_instrumentation_cmd(
-      self, mock_start_standing_subprocess, resp_lines
-  ):
-    mock_proc = mock_start_standing_subprocess()
-    mock_proc.stdout.readline.side_effect = resp_lines
-
-
-if __name__ == '__main__':
-  unittest.main()
diff --git a/tests/mobly/suite_runner_test.py b/tests/mobly/suite_runner_test.py
index 08072d6..0a716ed 100755
--- a/tests/mobly/suite_runner_test.py
+++ b/tests/mobly/suite_runner_test.py
@@ -23,13 +23,18 @@
 from mobly import base_suite
 from mobly import base_test
 from mobly import suite_runner
+from mobly import test_runner
 from tests.lib import integration2_test
 from tests.lib import integration_test
+from tests.lib import integration_test_suite
 
 
 class FakeTest1(base_test.BaseTestClass):
   pass
 
+  def test_a(self):
+    pass
+
 
 class SuiteRunnerTest(unittest.TestCase):
 
@@ -136,12 +141,17 @@
     mock_exit.assert_called_once_with(1)
 
   @mock.patch('sys.exit')
-  @mock.patch.object(suite_runner, '_find_suite_class', autospec=True)
-  def test_run_suite_class(self, mock_find_suite_class, mock_exit):
+  def test_run_suite_class(self, mock_exit):
+    tmp_file_path = self._gen_tmp_config_file()
+    mock_cli_args = ['test_binary', f'--config={tmp_file_path}']
     mock_called = mock.MagicMock()
 
     class FakeTestSuite(base_suite.BaseSuite):
 
+      def set_test_selector(self, test_selector):
+        mock_called.set_test_selector(test_selector)
+        super().set_test_selector(test_selector)
+
       def setup_suite(self, config):
         mock_called.setup_suite()
         super().setup_suite(config)
@@ -151,30 +161,194 @@
         mock_called.teardown_suite()
         super().teardown_suite()
 
+    sys.modules['__main__'].__dict__[FakeTestSuite.__name__] = FakeTestSuite
+
+    with mock.patch.object(sys, 'argv', new=mock_cli_args):
+      try:
+        suite_runner.run_suite_class()
+      finally:
+        del sys.modules['__main__'].__dict__[FakeTestSuite.__name__]
+
+    mock_called.setup_suite.assert_called_once_with()
+    mock_called.teardown_suite.assert_called_once_with()
+    mock_exit.assert_not_called()
+    mock_called.set_test_selector.assert_called_once_with(None)
+
+  @mock.patch('sys.exit')
+  @mock.patch.object(suite_runner, '_find_suite_class', autospec=True)
+  @mock.patch.object(test_runner, 'TestRunner')
+  def test_run_suite_class_with_test_selection_by_class(
+      self, mock_test_runner_class, mock_find_suite_class, mock_exit
+  ):
+    mock_test_runner = mock_test_runner_class.return_value
+    mock_test_runner.results.is_all_pass = True
+    tmp_file_path = self._gen_tmp_config_file()
+    mock_cli_args = [
+        'test_binary',
+        f'--config={tmp_file_path}',
+        '--tests',
+        'FakeTest1',
+        'FakeTest1_A',
+    ]
+    mock_called = mock.MagicMock()
+
+    class FakeTestSuite(base_suite.BaseSuite):
+
+      def set_test_selector(self, test_selector):
+        mock_called.set_test_selector(test_selector)
+        super().set_test_selector(test_selector)
+
+      def setup_suite(self, config):
+        self.add_test_class(FakeTest1)
+        self.add_test_class(FakeTest1, name_suffix='A')
+        self.add_test_class(FakeTest1, name_suffix='B')
+
     mock_find_suite_class.return_value = FakeTestSuite
 
-    tmp_file_path = os.path.join(self.tmp_dir, 'config.yml')
-    with io.open(tmp_file_path, 'w', encoding='utf-8') as f:
-      f.write(
-          """
-        TestBeds:
-          # A test bed where adb will find Android devices.
-          - Name: SampleTestBed
-            Controllers:
-              MagicDevice: '*'
-      """
-      )
-
-    mock_cli_args = ['test_binary', f'--config={tmp_file_path}']
-
     with mock.patch.object(sys, 'argv', new=mock_cli_args):
       suite_runner.run_suite_class()
 
-    mock_find_suite_class.assert_called_once()
-    mock_called.setup_suite.assert_called_once_with()
-    mock_called.teardown_suite.assert_called_once_with()
+    mock_called.set_test_selector.assert_called_once_with(
+        {('FakeTest1', None): None, ('FakeTest1', 'A'): None},
+    )
+
+  @mock.patch('sys.exit')
+  @mock.patch.object(suite_runner, '_find_suite_class', autospec=True)
+  @mock.patch.object(test_runner, 'TestRunner')
+  def test_run_suite_class_with_test_selection_by_method(
+      self, mock_test_runner_class, mock_find_suite_class, mock_exit
+  ):
+    mock_test_runner = mock_test_runner_class.return_value
+    mock_test_runner.results.is_all_pass = True
+    tmp_file_path = self._gen_tmp_config_file()
+    mock_cli_args = [
+        'test_binary',
+        f'--config={tmp_file_path}',
+        '--tests',
+        'FakeTest1.test_a',
+        'FakeTest1_B.test_a',
+    ]
+    mock_called = mock.MagicMock()
+
+    class FakeTestSuite(base_suite.BaseSuite):
+
+      def set_test_selector(self, test_selector):
+        mock_called.set_test_selector(test_selector)
+        super().set_test_selector(test_selector)
+
+      def setup_suite(self, config):
+        self.add_test_class(FakeTest1)
+        self.add_test_class(FakeTest1, name_suffix='B')
+        self.add_test_class(FakeTest1, name_suffix='C')
+
+    mock_find_suite_class.return_value = FakeTestSuite
+
+    with mock.patch.object(sys, 'argv', new=mock_cli_args):
+      suite_runner.run_suite_class()
+
+    mock_called.set_test_selector.assert_called_once_with(
+        {('FakeTest1', None): ['test_a'], ('FakeTest1', 'B'): ['test_a']},
+    )
+
+  @mock.patch.object(sys, 'exit')
+  @mock.patch.object(suite_runner, '_find_suite_class', autospec=True)
+  def test_run_suite_class_with_combined_test_selection(
+      self, mock_find_suite_class, mock_exit
+  ):
+    mock_called = mock.MagicMock()
+
+    class FakeTest2(base_test.BaseTestClass):
+
+      def __init__(self, config):
+        mock_called.suffix(config.test_class_name_suffix)
+        super().__init__(config)
+
+      def run(self, tests):
+        mock_called.run(tests)
+        return super().run(tests)
+
+      def test_a(self):
+        pass
+
+      def test_b(self):
+        pass
+
+    class FakeTestSuite(base_suite.BaseSuite):
+
+      def setup_suite(self, config):
+        self.add_test_class(FakeTest2, name_suffix='A')
+        self.add_test_class(FakeTest2, name_suffix='B')
+        self.add_test_class(FakeTest2, name_suffix='C', tests=['test_a'])
+        self.add_test_class(FakeTest2, name_suffix='D')
+        self.add_test_class(FakeTest2)
+
+    tmp_file_path = self._gen_tmp_config_file()
+    mock_cli_args = [
+        'test_binary',
+        f'--config={tmp_file_path}',
+        '--tests',
+        'FakeTest2_A',
+        'FakeTest2_B',
+        'FakeTest2_C.test_a',
+        'FakeTest2',
+    ]
+
+    mock_find_suite_class.return_value = FakeTestSuite
+    with mock.patch.object(sys, 'argv', new=mock_cli_args):
+      suite_runner.run_suite_class()
+
+    mock_called.suffix.assert_has_calls(
+        [mock.call('A'), mock.call('B'), mock.call('C'), mock.call(None)]
+    )
+    mock_called.run.assert_has_calls(
+        [
+            mock.call(None),
+            mock.call(None),
+            mock.call(['test_a']),
+            mock.call(None),
+        ]
+    )
     mock_exit.assert_not_called()
 
+  @mock.patch('sys.exit')
+  @mock.patch.object(test_runner, 'TestRunner')
+  @mock.patch.object(
+      integration_test_suite.IntegrationTestSuite, 'setup_suite', autospec=True
+  )
+  def test_run_suite_class_finds_suite_class_when_not_in_main_module(
+      self, mock_setup_suite, mock_test_runner_class, mock_exit
+  ):
+    mock_test_runner = mock_test_runner_class.return_value
+    mock_test_runner.results.is_all_pass = True
+    tmp_file_path = self._gen_tmp_config_file()
+    mock_cli_args = ['test_binary', f'--config={tmp_file_path}']
+
+    with mock.patch.object(sys, 'argv', new=mock_cli_args):
+      integration_test_suite.main()
+
+    mock_setup_suite.assert_called_once()
+
+  @mock.patch('builtins.print')
+  def test_print_test_names_for_suites(self, mock_print):
+    class FakeTestSuite(base_suite.BaseSuite):
+
+      def setup_suite(self, config):
+        self.add_test_class(FakeTest1, name_suffix='A')
+        self.add_test_class(FakeTest1, name_suffix='B')
+        self.add_test_class(FakeTest1, name_suffix='C', tests=['test_a'])
+        self.add_test_class(FakeTest1, name_suffix='D', tests=[])
+
+    suite_runner._print_test_names_for_suite(FakeTestSuite)
+    calls = [
+        mock.call('==========> FakeTest1_A <=========='),
+        mock.call('FakeTest1_A.test_a'),
+        mock.call('==========> FakeTest1_B <=========='),
+        mock.call('FakeTest1_B.test_a'),
+        mock.call('==========> FakeTest1_C <=========='),
+        mock.call('FakeTest1_C.test_a'),
+    ]
+    mock_print.assert_has_calls(calls)
+
   def test_print_test_names(self):
     mock_test_class = mock.MagicMock()
     mock_cls_instance = mock.MagicMock()
@@ -191,6 +365,20 @@
     mock_cls_instance._pre_run.side_effect = Exception('Something went wrong.')
     mock_cls_instance._clean_up.assert_called_once()
 
+  def _gen_tmp_config_file(self):
+    tmp_file_path = os.path.join(self.tmp_dir, 'config.yml')
+    with io.open(tmp_file_path, 'w', encoding='utf-8') as f:
+      f.write(
+          """
+        TestBeds:
+          # A test bed where adb will find Android devices.
+          - Name: SampleTestBed
+            Controllers:
+              MagicDevice: '*'
+      """
+      )
+    return tmp_file_path
+
 
 if __name__ == '__main__':
   unittest.main()
diff --git a/tests/mobly/test_runner_test.py b/tests/mobly/test_runner_test.py
index db88c21..0bdf512 100755
--- a/tests/mobly/test_runner_test.py
+++ b/tests/mobly/test_runner_test.py
@@ -393,6 +393,54 @@
       with mock.patch.dict('sys.modules', __main__=multiple_subclasses_module):
         test_class = test_runner._find_test_class()
 
+  def test_get_full_test_names(self):
+    """Verifies that calling get_test_names works properly."""
+    config = self.base_mock_test_config.copy()
+    tr = test_runner.TestRunner(self.log_dir, self.testbed_name)
+    with tr.mobly_logger():
+      tr.add_test_class(
+          config, integration_test.IntegrationTest, name_suffix='A'
+      )
+      tr.add_test_class(
+          config, integration_test.IntegrationTest, name_suffix='B'
+      )
+      tr.add_test_class(
+          config, integration2_test.Integration2Test, name_suffix='A'
+      )
+      tr.add_test_class(
+          config, integration2_test.Integration2Test, name_suffix='B'
+      )
+
+    results = tr.get_full_test_names()
+    self.assertIn('IntegrationTest_A.test_hello_world', results)
+    self.assertIn('IntegrationTest_B.test_hello_world', results)
+    self.assertIn('Integration2Test_A.test_hello_world', results)
+    self.assertIn('Integration2Test_B.test_hello_world', results)
+    self.assertEqual(len(results), 4)
+
+  def test_get_full_test_names_test_list(self):
+    """Verifies that calling get_test_names with test list works properly."""
+    config = self.base_mock_test_config.copy()
+    tr = test_runner.TestRunner(self.log_dir, self.testbed_name)
+    with tr.mobly_logger():
+      tr.add_test_class(
+          config, integration_test.IntegrationTest, tests=['test_hello_world']
+      )
+
+    results = tr.get_full_test_names()
+    self.assertIn('IntegrationTest.test_hello_world', results)
+    self.assertEqual(len(results), 1)
+
+  def test_get_full_test_names_test_list_empty(self):
+    """Verifies that calling get_test_names with empty test list works properly."""
+    config = self.base_mock_test_config.copy()
+    tr = test_runner.TestRunner(self.log_dir, self.testbed_name)
+    with tr.mobly_logger():
+      tr.add_test_class(config, integration_test.IntegrationTest, tests=[])
+
+    results = tr.get_full_test_names()
+    self.assertEqual(len(results), 0)
+
   def test_print_test_names(self):
     mock_test_class = mock.MagicMock()
     mock_cls_instance = mock.MagicMock()
diff --git a/tools/sl4a_shell.py b/tools/sl4a_shell.py
deleted file mode 100755
index 6ad656e..0000000
--- a/tools/sl4a_shell.py
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/usr/bin/env python3.4
-#
-# Copyright 2016 Google Inc.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-"""Tool to interactively call sl4a methods.
-
-SL4A (Scripting Layer for Android) is an RPC service exposing API calls on
-Android.
-
-Original version: https://github.com/damonkohler/sl4a
-
-Fork in AOSP (can make direct system privileged calls):
-https://android.googlesource.com/platform/external/sl4a/
-
-Also allows access to Event Dispatcher, which allows waiting for asynchronous
-actions. For more information see the Mobly codelab:
-https://github.com/google/mobly#event-dispatcher
-
-Usage:
-$ sl4a_shell
->>> s.getBuildID()
-u'N2F52'
-"""
-
-import argparse
-import logging
-
-from mobly.controllers.android_device_lib import jsonrpc_shell_base
-from mobly.controllers.android_device_lib.services import sl4a_service
-
-
-class Sl4aShell(jsonrpc_shell_base.JsonRpcShellBase):
-
-  def _start_services(self, console_env):
-    """Overrides superclass."""
-    self._ad.services.register('sl4a', sl4a_service.Sl4aService)
-    console_env['s'] = self._ad.services.sl4a
-    console_env['sl4a'] = self._ad.sl4a
-    console_env['ed'] = self._ad.ed
-
-  def _get_banner(self, serial):
-    lines = [
-        'Connected to %s.' % serial,
-        'Call methods against:',
-        '    ad (android_device.AndroidDevice)',
-        '    sl4a or s (SL4A)',
-        '    ed (EventDispatcher)',
-    ]
-    return '\n'.join(lines)
-
-
-if __name__ == '__main__':
-  parser = argparse.ArgumentParser(description='Interactive client for sl4a.')
-  parser.add_argument(
-      '-s',
-      '--serial',
-      help='Device serial to connect to (if more than one device is connected)',
-  )
-  args = parser.parse_args()
-  logging.basicConfig(level=logging.INFO)
-  Sl4aShell().main(args.serial)