Repository: google/mobly

Issue:
Add timing information for instrumentation tests



Discussion:

Resolving patch:
diff --git a/mobly/controllers/android_device_lib/adb.py b/mobly/controllers/android_device_lib/adb.py
--- a/mobly/controllers/android_device_lib/adb.py
+++ b/mobly/controllers/android_device_lib/adb.py
@@ -181,6 +181,47 @@ def _exec_cmd(self, args, shell, timeout, stderr):
         else:
             raise AdbError(cmd=args, stdout=out, stderr=err, ret_code=ret)
 
+    def _execute_and_process_stdout(self, args, shell, handler):
+        """Executes adb commands and processes the stdout with a handler.
+
+        Args:
+            args: string or list of strings, program arguments.
+                See subprocess.Popen() documentation.
+            shell: bool, True to run this command through the system shell,
+                False to invoke it directly. See subprocess.Popen() docs.
+            handler: func, a function to handle adb stdout line by line.
+
+        Returns:
+            The stderr of the adb command run if exit code is 0.
+
+        Raises:
+            AdbError: The adb command exit code is not 0.
+        """
+        proc = subprocess.Popen(
+            args,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            shell=shell,
+            bufsize=1)
+        try:
+            while proc.poll() is None:
+                line = proc.stdout.readline()
+                if line:
+                    handler(line)
+                else:
+                    break
+        finally:
+            (_, err) = proc.communicate()
+        ret = proc.returncode
+        if ret == 0:
+            return err
+        else:
+            raise AdbError(
+                cmd=args,
+                stdout='[elided, processed via handler]',
+                stderr=err,
+                ret_code=ret)
+
     def _construct_adb_cmd(self, raw_name, args, shell):
         """Constructs an adb command with arguments for a subprocess call.
 
@@ -225,6 +266,12 @@ def _exec_adb_cmd(self, name, args, shell, timeout, stderr):
             adb_cmd, shell=shell, timeout=timeout, stderr=stderr)
         return out
 
+    def _execute_adb_and_process_stdout(self, name, args, shell, handler):
+        adb_cmd = self._construct_adb_cmd(name, args, shell=shell)
+        out = self._execute_and_process_stdout(
+            adb_cmd, shell=shell, handler=handler)
+        return out
+
     def getprop(self, prop_name):
         """Get a property of the device.
 
@@ -262,7 +309,7 @@ def forward(self, args=None, shell=False):
             return self._exec_adb_cmd(
                 'forward', args, shell, timeout=None, stderr=None)
 
-    def instrument(self, package, options=None, runner=None):
+    def instrument(self, package, options=None, runner=None, handler=None):
         """Runs an instrumentation command on the device.
 
         This is a convenience wrapper to avoid parameter formatting.
@@ -284,9 +331,14 @@ def instrument(self, package, options=None, runner=None):
                 class.
             runner: string, the test runner name, which defaults to
                 DEFAULT_INSTRUMENTATION_RUNNER.
+            handler: optional func, when specified the function is used to parse
+                the instrumentation stdout line by line as the output is
+                generated; otherwise, the stdout is simply returned once the
+                instrumentation is finished.
 
         Returns:
-            The output of instrumentation command.
+            The stdout of instrumentation command or the stderr if the handler
+                is set.
         """
         if runner is None:
             runner = DEFAULT_INSTRUMENTATION_RUNNER
@@ -302,7 +354,17 @@ def instrument(self, package, options=None, runner=None):
             options_string, package, runner)
         logging.info('AndroidDevice|%s: Executing adb shell %s', self.serial,
                      instrumentation_command)
-        return self.shell(instrumentation_command)
+        if handler is None:
+            # Flow kept for backwards-compatibility reasons
+            self._exec_adb_cmd(
+                'shell',
+                instrumentation_command,
+                shell=False,
+                timeout=None,
+                stderr=None)
+        else:
+            return self._execute_adb_and_process_stdout(
+                'shell', instrumentation_command, shell=False, handler=handler)
 
     def __getattr__(self, name):
         def adb_call(args=None, shell=False, timeout=None, stderr=None):


Test patch:
diff --git a/mobly/base_instrumentation_test.py b/mobly/base_instrumentation_test.py
--- a/mobly/base_instrumentation_test.py
+++ b/mobly/base_instrumentation_test.py
@@ -19,6 +19,7 @@
 from mobly import base_test
 from mobly import records
 from mobly import signals
+from mobly import utils
 
 
 class _InstrumentationStructurePrefixes(object):
@@ -280,6 +281,8 @@ class _InstrumentationBlock(object):
     needs to be done for those.
 
     Attributes:
+        begin_time: string, optional timestamp for when the test corresponding
+            to the instrumentation block began.
         current_key: string, the current key that is being parsed, default to
             _InstrumentationKnownStatusKeys.STREAM.
         error_message: string, an error message indicating that something
@@ -302,6 +305,10 @@ def __init__(self,
         self.state = state
         self.prefix = prefix
         self.previous_instrumentation_block = previous_instrumentation_block
+        if previous_instrumentation_block:
+            # The parser never needs lookback for two previous blocks,
+            # so unset to allow previous blocks to get garbage collected.
+            previous_instrumentation_block.previous_instrumentation_block = None
 
         self._empty = True
         self.error_message = ''
@@ -319,6 +326,8 @@ def __init__(self,
         }
         self.unknown_keys = defaultdict(list)
 
+        self.begin_time = None
+
     @property
     def is_empty(self):
         """Deteremines whether or not anything has been parsed with this
@@ -371,6 +380,8 @@ def set_status_code(self, status_code_line):
             _InstrumentationStructurePrefixes.STATUS_CODE,
             status_code_line,
         )
+        if self.status_code == _InstrumentationStatusCodes.START:
+            self.begin_time = utils.get_current_epoch_time()
 
     def set_key(self, structure_prefix, key_line):
         """Sets the current key for the instrumentation block.
@@ -440,11 +451,14 @@ def transition_state(self, new_state):
             self.state = new_state
             return self
         else:
-            return _InstrumentationBlock(
+            next_block = _InstrumentationBlock(
                 state=new_state,
                 prefix=self.prefix,
                 previous_instrumentation_block=self,
             )
+            if self.status_code in _InstrumentationStatusCodeCategories.TIMING:
+                next_block.begin_time = self.begin_time
+            return next_block
 
 
 class _InstrumentationBlockFormatter(object):
@@ -466,6 +480,7 @@ def __init__(self, instrumentation_block):
         for key, value in instrumentation_block.unknown_keys.items():
             self._unknown_keys[key] = '\n'.join(
                 instrumentation_block.unknown_keys[key]).rstrip()
+        self._begin_time = instrumentation_block.begin_time
 
     def _get_name(self):
         """Gets the method name of the test method for the instrumentation
@@ -589,6 +604,9 @@ def create_test_record(self, mobly_test_class):
             t_name=self._get_full_name(),
             t_class=mobly_test_class,
         )
+        if self._begin_time:
+            tr_record.begin_time = self._begin_time
+
         if self._is_failed():
             tr_record.test_fail(
                 e=signals.TestFailure(details=details, extras=extras))
@@ -923,17 +941,20 @@ def run_instrumentation_test(self,
             TestError if the instrumentation run crashed or if parsing the
                 output failed.
         """
-        instrumentation_output = device.adb.instrument(
+        # Dictionary hack to allow overwriting the instrumentation_block in the
+        # parse_instrumentation closure
+        instrumentation_block = [_InstrumentationBlock(prefix=prefix)]
+
+        def parse_instrumentation(raw_line):
+            line = raw_line.rstrip().decode('utf-8')
+            logging.info(line)
+            instrumentation_block[0] = self._parse_line(
+                instrumentation_block[0], line)
+
+        stderr = device.adb.instrument(
             package=package,
             options=options,
             runner=runner,
-        ).decode('utf-8')
-        logging.info('Outputting instrumentation test log...')
-        logging.info(instrumentation_output)
-
-        # TODO(winterfrosts): Implement online output generation and parsing.
-        instrumentation_block = _InstrumentationBlock(prefix=prefix)
-        for line in instrumentation_output.splitlines():
-            instrumentation_block = self._parse_line(instrumentation_block,
-                                                     line)
-        return self._finish_parsing(instrumentation_block)
+            handler=parse_instrumentation)
+
+        return self._finish_parsing(instrumentation_block[0])
diff --git a/tests/mobly/base_instrumentation_test_test.py b/tests/mobly/base_instrumentation_test_test.py
--- a/tests/mobly/base_instrumentation_test_test.py
+++ b/tests/mobly/base_instrumentation_test_test.py
@@ -26,6 +26,8 @@
 from mobly.base_instrumentation_test import BaseInstrumentationTestClass
 from mobly import config_parser
 from mobly import signals
+from mobly.controllers import android_device
+from mobly.controllers.android_device_lib import adb
 
 # A mock test package for instrumentation.
 MOCK_TEST_PACKAGE = 'com.my.package.test'
@@ -56,10 +58,14 @@ def __init__(self, tmp_dir, user_params={}):
         super(MockInstrumentationTest, self).__init__(mock_test_run_configs)
 
     def run_mock_instrumentation_test(self, instrumentation_output, prefix):
-        mock_device = mock.Mock()
-        mock_device.adb = mock.Mock()
-        mock_device.adb.instrument = mock.MagicMock(
-            return_value=instrumentation_output)
+        def fake_instrument(package, options=None, runner=None, handler=None):
+            for line in instrumentation_output.splitlines():
+                handler(line)
+            return instrumentation_output
+
+        mock_device = mock.Mock(spec=android_device.AndroidDevice)
+        mock_device.adb = mock.Mock(spec=adb.AdbProxy)
+        mock_device.adb.instrument = fake_instrument
         return self.run_instrumentation_test(
             mock_device, MOCK_TEST_PACKAGE, prefix=prefix)
 
@@ -161,7 +167,8 @@ def assert_run_instrumentation_test(self,
                                         expected_skipped=[],
                                         expected_completed_and_passed=False,
                                         expected_has_error=False,
-                                        prefix=None):
+                                        prefix=None,
+                                        expected_executed_times=[]):
         result = self.run_instrumentation_test(
             self.convert_to_raw_output(instrumentation_output), prefix=prefix)
         if expected_has_error:
@@ -178,6 +185,12 @@ def assert_run_instrumentation_test(self,
         for actual_test, expected_test in zip(result.skipped,
                                               expected_skipped):
             self.assert_equal_test(actual_test, expected_test)
+        if expected_executed_times:
+            for actual_test, expected_time in zip(result.executed,
+                                                  expected_executed_times):
+                (expected_begin_time, expected_end_time) = expected_time
+                self.assertEquals(actual_test.begin_time, expected_begin_time)
+                self.assertEquals(actual_test.end_time, expected_end_time)
 
     def test_run_instrumentation_test_with_invalid_syntax(self):
         instrumentation_output = """\
@@ -256,7 +269,8 @@ def test_run_instrumentation_test_logs_correctly(self, mock_info_logger):
             logged_format = mock_call[1][0]
             self.assertIsInstance(logged_format, str)
 
-    def test_run_instrumentation_test_with_passing_test(self):
+    @mock.patch('mobly.utils.get_current_epoch_time')
+    def test_run_instrumentation_test_with_passing_test(self, mock_get_time):
         instrumentation_output = """\
 INSTRUMENTATION_STATUS: numtests=1
 INSTRUMENTATION_STATUS: stream=
@@ -285,10 +299,12 @@ def test_run_instrumentation_test_with_passing_test(self):
         expected_executed = [
             ('com.my.package.test.BasicTest#basicTest', signals.TestPass),
         ]
+        mock_get_time.side_effect = [13, 51]
         self.assert_run_instrumentation_test(
             instrumentation_output,
             expected_executed=expected_executed,
-            expected_completed_and_passed=True)
+            expected_completed_and_passed=True,
+            expected_executed_times=[(13, 51)])
 
     def test_run_instrumentation_test_with_random_whitespace(self):
         instrumentation_output = """\
@@ -690,7 +706,8 @@ def test_run_instrumentation_test_with_ignored_test(self):
             expected_skipped=expected_skipped,
             expected_completed_and_passed=True)
 
-    def test_run_instrumentation_test_with_crashed_test(self):
+    @mock.patch('mobly.utils.get_current_epoch_time')
+    def test_run_instrumentation_test_with_crashed_test(self, mock_get_time):
         instrumentation_output = """\
 INSTRUMENTATION_STATUS: class=com.my.package.test.BasicTest
 INSTRUMENTATION_STATUS: current=1
@@ -705,12 +722,15 @@ def test_run_instrumentation_test_with_crashed_test(self):
         expected_executed = [
             ('com.my.package.test.BasicTest#crashTest', signals.TestError),
         ]
+        mock_get_time.side_effect = [67, 942]
         self.assert_run_instrumentation_test(
             instrumentation_output,
             expected_executed=expected_executed,
-            expected_has_error=True)
+            expected_has_error=True,
+            expected_executed_times=[(67, 942)])
 
-    def test_run_instrumentation_test_with_crashing_test(self):
+    @mock.patch('mobly.utils.get_current_epoch_time')
+    def test_run_instrumentation_test_with_crashing_test(self, mock_get_time):
         instrumentation_output = """\
 INSTRUMENTATION_STATUS: class=com.my.package.test.BasicTest
 INSTRUMENTATION_STATUS: current=1
@@ -742,10 +762,13 @@ def test_run_instrumentation_test_with_crashing_test(self):
             ('com.my.package.test.BasicTest#crashAndRecover2Test',
              signals.TestError),
         ]
+        mock_get_time.side_effect = [16, 412, 4143, 6547]
+        # TODO(winterfrosts): Fix this issue with overlapping timing
         self.assert_run_instrumentation_test(
             instrumentation_output,
             expected_executed=expected_executed,
-            expected_completed_and_passed=True)
+            expected_completed_and_passed=True,
+            expected_executed_times=[(16, 4143), (412, 6547)])
 
     def test_run_instrumentation_test_with_runner_setup_crash(self):
         instrumentation_output = """\
@@ -782,7 +805,8 @@ def test_run_instrumentation_test_with_runner_teardown_crash(self):
             expected_executed=expected_executed,
             expected_has_error=True)
 
-    def test_run_instrumentation_test_with_multiple_tests(self):
+    @mock.patch('mobly.utils.get_current_epoch_time')
+    def test_run_instrumentation_test_with_multiple_tests(self, mock_get_time):
         instrumentation_output = """\
 INSTRUMENTATION_STATUS: class=com.my.package.test.BasicTest
 INSTRUMENTATION_STATUS: current=1
@@ -1066,10 +1090,12 @@ def test_run_instrumentation_test_with_multiple_tests(self):
              signals.TestSkip),
             ('com.my.package.test.BasicTest#ignoredTest', signals.TestSkip),
         ]
+        mock_get_time.side_effect = [54, 64, -1, -1, -1, -1, 89, 94]
         self.assert_run_instrumentation_test(
             instrumentation_output,
             expected_executed=expected_executed,
-            expected_skipped=expected_skipped)
+            expected_skipped=expected_skipped,
+            expected_executed_times=[(54, 64), (89, 94)])
 
     def test__Instrumentation_block_set_key_on_multiple_equals_sign(self):
         value = "blah=blah, blah2=blah2, blah=2=1=2"
diff --git a/tests/mobly/controllers/android_device_lib/adb_test.py b/tests/mobly/controllers/android_device_lib/adb_test.py
--- a/tests/mobly/controllers/android_device_lib/adb_test.py
+++ b/tests/mobly/controllers/android_device_lib/adb_test.py
@@ -67,6 +67,20 @@ def _mock_process(self, mock_psutil_process, mock_popen):
         mock_proc.returncode = 0
         return (mock_psutil_process, mock_popen)
 
+    def _mock_execute_and_process_stdout_process(self, mock_popen):
+        # the created proc object in adb._execute_and_process_stdout()
+        mock_proc = mock.Mock()
+        mock_popen.return_value = mock_proc
+
+        mock_popen.return_value.poll.return_value = None
+        mock_popen.return_value.stdout.readline.side_effect = ['']
+
+        mock_proc.communicate = mock.Mock(
+            return_value=(MOCK_DEFAULT_STDOUT.encode('utf-8'),
+                          MOCK_DEFAULT_STDERR.encode('utf-8')))
+        mock_proc.returncode = 0
+        return mock_popen
+
     @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
     @mock.patch('mobly.controllers.android_device_lib.adb.psutil.Process')
     def test_exec_cmd_no_timeout_success(self, mock_psutil_process,
@@ -124,6 +138,73 @@ def test_exec_cmd_with_negative_timeout_value(self, mock_psutil_process,
             adb.AdbProxy()._exec_cmd(
                 ['fake_cmd'], shell=False, timeout=-1, stderr=None)
 
+    @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
+    def test_execute_and_process_stdout_reads_stdout(self, mock_popen):
+        self._mock_execute_and_process_stdout_process(mock_popen)
+        mock_popen.return_value.stdout.readline.side_effect = ['1', '2', '']
+        mock_handler = mock.MagicMock()
+
+        err = adb.AdbProxy()._execute_and_process_stdout(
+            ['fake_cmd'], shell=False, handler=mock_handler)
+        self.assertEqual(mock_handler.call_count, 2)
+        mock_handler.assert_any_call('1')
+        mock_handler.assert_any_call('2')
+
+    @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
+    def test_execute_and_process_stdout_when_cmd_exits(self, mock_popen):
+        self._mock_execute_and_process_stdout_process(mock_popen)
+        mock_popen.return_value.poll.side_effect = [None, None, None, 0]
+        mock_popen.return_value.stdout.readline.return_value = '123'
+        mock_handler = mock.MagicMock()
+
+        err = adb.AdbProxy()._execute_and_process_stdout(
+            ['fake_cmd'], shell=False, handler=mock_handler)
+
+    @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
+    def test_execute_and_process_stdout_when_cmd_eof(self, mock_popen):
+        self._mock_execute_and_process_stdout_process(mock_popen)
+        mock_popen.return_value.poll.return_value = None
+        mock_popen.return_value.stdout.readline.side_effect = [
+            '1', '2', '3', ''
+        ]
+        mock_handler = mock.MagicMock()
+
+        err = adb.AdbProxy()._execute_and_process_stdout(
+            ['fake_cmd'], shell=False, handler=mock_handler)
+
+    @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
+    def test_execute_and_process_stdout_returns_stderr(self, mock_popen):
+        self._mock_execute_and_process_stdout_process(mock_popen)
+
+        err = adb.AdbProxy()._execute_and_process_stdout(
+            ['fake_cmd'], shell=False, handler=mock.MagicMock())
+        self.assertEqual(MOCK_DEFAULT_STDERR, err.decode('utf-8'))
+
+    @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
+    def test_execute_and_process_stdout_raises_adb_error(self, mock_popen):
+        self._mock_execute_and_process_stdout_process(mock_popen)
+        mock_popen.return_value.returncode = 1
+
+        with self.assertRaisesRegex(adb.AdbError,
+                                    'Error executing adb cmd .*'):
+            err = adb.AdbProxy()._execute_and_process_stdout(
+                ['fake_cmd'], shell=False, handler=mock.MagicMock())
+
+    @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
+    def test_execute_and_process_stdout_when_handler_crash(self, mock_popen):
+        self._mock_execute_and_process_stdout_process(mock_popen)
+        mock_popen.return_value.stdout.readline.side_effect = [
+            '1', '2', '3', ''
+        ]
+        mock_handler = mock.MagicMock()
+        mock_handler.side_effect = ['', TypeError('fake crash'), '', '']
+
+        with self.assertRaisesRegex(TypeError, 'fake crash'):
+            err = adb.AdbProxy()._execute_and_process_stdout(
+                ['fake_cmd'], shell=False, handler=mock_handler)
+
+        mock_popen.return_value.communicate.assert_called_once_with()
+
     def test_construct_adb_cmd(self):
         adb_cmd = adb.AdbProxy()._construct_adb_cmd(
             'shell', 'arg1', shell=False)
@@ -255,6 +336,24 @@ def test_exec_adb_cmd_formats_command_with_shell_true(self):
                 mock_exec_cmd.assert_called_once_with(
                     mock_adb_cmd, shell=True, timeout=None, stderr=None)
 
+    def test_execute_adb_and_process_stdout_formats_command(self):
+        with mock.patch.object(adb.AdbProxy, '_execute_and_process_stdout'
+                               ) as mock_execute_and_process_stdout:
+            with mock.patch.object(
+                    adb.AdbProxy,
+                    '_construct_adb_cmd') as mock_construct_adb_cmd:
+                mock_adb_cmd = mock.MagicMock()
+                mock_adb_args = mock.MagicMock()
+                mock_handler = mock.MagicMock()
+                mock_construct_adb_cmd.return_value = mock_adb_cmd
+
+                adb.AdbProxy()._execute_adb_and_process_stdout(
+                    'shell', mock_adb_args, shell=False, handler=mock_handler)
+                mock_construct_adb_cmd.assert_called_once_with(
+                    'shell', mock_adb_args, shell=False)
+                mock_execute_and_process_stdout.assert_called_once_with(
+                    mock_adb_cmd, shell=False, handler=mock_handler)
+
     @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
     @mock.patch('mobly.controllers.android_device_lib.adb.psutil.Process')
     def test_exec_adb_cmd_with_stderr_pipe(self, mock_psutil_process,
@@ -276,7 +375,6 @@ def test_instrument_without_parameters(self):
         the basic case.
         """
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
-            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy().instrument(MOCK_INSTRUMENTATION_PACKAGE)
             mock_exec_cmd.assert_called_once_with(
                 ['adb', 'shell', MOCK_BASIC_INSTRUMENTATION_COMMAND],
@@ -289,7 +387,6 @@ def test_instrument_with_runner(self):
         with a runner specified.
         """
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
-            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy().instrument(
                 MOCK_INSTRUMENTATION_PACKAGE,
                 runner=MOCK_INSTRUMENTATION_RUNNER)
@@ -304,7 +401,6 @@ def test_instrument_with_options(self):
         with options.
         """
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
-            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy().instrument(
                 MOCK_INSTRUMENTATION_PACKAGE,
                 options=MOCK_INSTRUMENTATION_OPTIONS)
@@ -314,6 +410,61 @@ def test_instrument_with_options(self):
                 timeout=None,
                 stderr=None)
 
+    def test_instrument_with_handler(self):
+        """Verifies the AndroidDevice object's instrument command is correct
+        with a handler passed in.
+        """
+
+        def mock_handler(raw_line):
+            pass
+
+        with mock.patch.object(adb.AdbProxy, '_execute_and_process_stdout'
+                               ) as mock_execute_and_process_stdout:
+            adb.AdbProxy().instrument(
+                MOCK_INSTRUMENTATION_PACKAGE, handler=mock_handler)
+            mock_execute_and_process_stdout.assert_called_once_with(
+                ['adb', 'shell', MOCK_BASIC_INSTRUMENTATION_COMMAND],
+                shell=False,
+                handler=mock_handler)
+
+    def test_instrument_with_handler_with_runner(self):
+        """Verifies the AndroidDevice object's instrument command is correct
+        with a handler passed in and a runner specified.
+        """
+
+        def mock_handler(raw_line):
+            pass
+
+        with mock.patch.object(adb.AdbProxy, '_execute_and_process_stdout'
+                               ) as mock_execute_and_process_stdout:
+            adb.AdbProxy().instrument(
+                MOCK_INSTRUMENTATION_PACKAGE,
+                runner=MOCK_INSTRUMENTATION_RUNNER,
+                handler=mock_handler)
+            mock_execute_and_process_stdout.assert_called_once_with(
+                ['adb', 'shell', MOCK_RUNNER_INSTRUMENTATION_COMMAND],
+                shell=False,
+                handler=mock_handler)
+
+    def test_instrument_with_handler_with_options(self):
+        """Verifies the AndroidDevice object's instrument command is correct
+        with a handler passed in and options.
+        """
+
+        def mock_handler(raw_line):
+            pass
+
+        with mock.patch.object(adb.AdbProxy, '_execute_and_process_stdout'
+                               ) as mock_execute_and_process_stdout:
+            adb.AdbProxy().instrument(
+                MOCK_INSTRUMENTATION_PACKAGE,
+                options=MOCK_INSTRUMENTATION_OPTIONS,
+                handler=mock_handler)
+            mock_execute_and_process_stdout.assert_called_once_with(
+                ['adb', 'shell', MOCK_OPTIONS_INSTRUMENTATION_COMMAND],
+                shell=False,
+                handler=mock_handler)
+
     def test_cli_cmd_to_string(self):
         cmd = ['"adb"', 'a b', 'c//']
         self.assertEqual(adb.cli_cmd_to_string(cmd), '\'"adb"\' \'a b\' c//')