Repository: google/mobly

Issue:
Errors in `teardown_class` are not properly recorded
If Mobly encounters an error in the cleanup stage of `teardown_class`, the error would not be recorded anywhere except the cli output, which makes debugging difficult.

This points to a bigger problem: the errors occurred in base test and runner themselves are not clearly handled.


Discussion:
Errors like this would bubble up to `test_runner` and cause mobly to exit without properly recording everything.

```
  File ".../third_party/py/mobly/test_runner.py", line 348, in run
    tests=test_run_info.tests)
  File ".../third_party/py/mobly/test_runner.py", line 315, in _run_test_class
    cls_result = test_instance.run(tests)
  File ".../third_party/py/mobly/base_test.py", line 816, in run
    self._teardown_class()
  File ".../third_party/py/mobly/base_test.py", line 332, in _teardown_class
    self._record_controller_info()
  File ".../third_party/py/mobly/base_test.py", line 252, in _record_controller_info
    for record in self._controller_manager.get_controller_info_records():
  File ".../third_party/py/mobly/controller_manager.py", line 208, in get_controller_info_records
    controller_module_name)
  File ".../third_party/py/mobly/controller_manager.py", line 179, in _create_controller_info_record
    copy.copy(self._controller_objects[controller_module_name]))
  File ".../third_party/py/mobly/controllers/android_device.py", line 124, in get_info
    return [ad.device_info for ad in ads]
  File ".../third_party/py/mobly/controllers/android_device.py", line 478, in device_info
    'model': self.model,
  File ".../third_party/py/mobly/controllers/android_device.py", line 789, in model
    return self.adb.getprop('ro.product.name').lower()
  File ".../third_party/py/mobly/controllers/android_device_lib/adb.py", line 294, in getprop
    return self.shell('getprop %s' % prop_name).decode('utf-8').strip()
  File ".../third_party/py/mobly/controllers/android_device_lib/adb.py", line 394, in adb_call
    name, args, shell=shell, timeout=timeout, stderr=stderr)
  File ".../third_party/py/mobly/controllers/android_device_lib/adb.py", line 273, in _exec_adb_cmd
    adb_cmd, shell=shell, timeout=timeout, stderr=stderr)
  File ".../third_party/py/mobly/controllers/android_device_lib/adb.py", line 182, in _exec_cmd
    raise AdbError(cmd=args, stdout=out, stderr=err, ret_code=ret)
AdbError: Error executing adb cmd "adb -s 84B7N16B02001515 shell 'getprop ro.product.name'". ret: 1, stdout: , stderr: error: protocol fault (couldn't read status): Connection reset by peer
```

Resolving patch:
diff --git a/mobly/controller_manager.py b/mobly/controller_manager.py
--- a/mobly/controller_manager.py
+++ b/mobly/controller_manager.py
@@ -17,6 +17,7 @@
 import logging
 import yaml
 
+from mobly import expects
 from mobly import records
 from mobly import signals
 
@@ -152,10 +153,9 @@ def unregister_controllers(self):
         # logging them.
         for name, module in self._controller_modules.items():
             logging.debug('Destroying %s.', name)
-            try:
+            with expects.expect_no_raises(
+                    'Exception occurred destroying %s.' % name):
                 module.destroy(self._controller_objects[name])
-            except:
-                logging.exception('Exception occurred destroying %s.', name)
         self._controller_objects = collections.OrderedDict()
         self._controller_modules = {}
 
@@ -204,8 +204,11 @@ def get_controller_info_records(self):
         """
         info_records = []
         for controller_module_name in self._controller_objects.keys():
-            record = self._create_controller_info_record(
-                controller_module_name)
-            if record:
-                info_records.append(record)
+            with expects.expect_no_raises(
+                    'Failed to collect controller info from %s' %
+                    controller_module_name):
+                record = self._create_controller_info_record(
+                    controller_module_name)
+                if record:
+                    info_records.append(record)
         return info_records


Test patch:
diff --git a/mobly/base_test.py b/mobly/base_test.py
--- a/mobly/base_test.py
+++ b/mobly/base_test.py
@@ -41,6 +41,7 @@
 STAGE_NAME_SETUP_TEST = 'setup_test'
 STAGE_NAME_TEARDOWN_TEST = 'teardown_test'
 STAGE_NAME_TEARDOWN_CLASS = 'teardown_class'
+STAGE_NAME_CLEAN_UP = 'clean_up'
 
 
 class Error(Exception):
@@ -374,9 +375,7 @@ def _teardown_class(self):
                 self.summary_writer.dump(record.to_dict(),
                                          records.TestSummaryEntryType.RECORD)
         finally:
-            # Write controller info and summary to summary file.
-            self._record_controller_info()
-            self._controller_manager.unregister_controllers()
+            self._clean_up()
 
     def teardown_class(self):
         """Teardown function that will be called after all the selected tests in
@@ -846,10 +845,36 @@ def run(self, test_names=None):
             logging.info('Summary for test class %s: %s', self.TAG,
                          self.results.summary_str())
 
+    def _clean_up(self):
+        """The final stage of a test class execution."""
+        stage_name = STAGE_NAME_CLEAN_UP
+        record = records.TestResultRecord(stage_name, self.TAG)
+        record.test_begin()
+        self.current_test_info = runtime_test_info.RuntimeTestInfo(
+            stage_name, self.log_path, record)
+        expects.recorder.reset_internal_states(record)
+        with self._log_test_stage(stage_name):
+            # Write controller info and summary to summary file.
+            self._record_controller_info()
+            self._controller_manager.unregister_controllers()
+            if expects.recorder.has_error:
+                record.test_error()
+                record.update_record()
+                self.results.add_class_error(record)
+                self.summary_writer.dump(record.to_dict(),
+                                         records.TestSummaryEntryType.RECORD)
+
     def clean_up(self):
-        """A function that is executed upon completion of all tests selected in
+        """.. deprecated:: 1.8.1
+
+        Use `teardown_class` instead.
+
+        A function that is executed upon completion of all tests selected in
         the test class.
 
         This function should clean up objects initialized in the constructor by
         user.
+
+        Generally this should not be used as nothing should be instantiated
+        from the constructor of a test class.
         """
diff --git a/tests/mobly/base_test_test.py b/tests/mobly/base_test_test.py
--- a/tests/mobly/base_test_test.py
+++ b/tests/mobly/base_test_test.py
@@ -2001,6 +2001,53 @@ def test_func(self):
             }
         }])
 
+    def test_record_controller_info_fail(self):
+        mock_test_config = self.mock_test_cls_configs.copy()
+        mock_ctrlr_config_name = mock_controller.MOBLY_CONTROLLER_CONFIG_NAME
+        mock_ctrlr_2_config_name = mock_second_controller.MOBLY_CONTROLLER_CONFIG_NAME
+        my_config = [{'serial': 'xxxx', 'magic': 'Magic'}]
+        mock_test_config.controller_configs[mock_ctrlr_config_name] = my_config
+        mock_test_config.controller_configs[
+            mock_ctrlr_2_config_name] = copy.copy(my_config)
+
+        class ControllerInfoTest(base_test.BaseTestClass):
+            """Registers two different controller types and modifies controller
+            info at runtime.
+            """
+
+            def setup_class(self):
+                device = self.register_controller(mock_controller)[0]
+                device.who_am_i = mock.MagicMock()
+                device.who_am_i.side_effect = Exception('Some failure')
+                second_controller = self.register_controller(
+                    mock_second_controller)[0]
+                # This should appear in recorded controller info.
+                second_controller.set_magic('haha')
+
+            def test_func(self):
+                pass
+
+        bt_cls = ControllerInfoTest(mock_test_config)
+        bt_cls.run()
+        info = bt_cls.results.controller_info[0]
+        self.assertEqual(len(bt_cls.results.controller_info), 1)
+        self.assertEqual(info.test_class, 'ControllerInfoTest')
+        self.assertEqual(info.controller_name, 'AnotherMagicDevice')
+        self.assertEqual(info.controller_info, [{
+            'MyOtherMagic': {
+                'magic': 'Magic',
+                'extra_magic': 'haha'
+            }
+        }])
+        record = bt_cls.results.error[0]
+        print(record.to_dict())
+        self.assertEqual(record.test_name, 'clean_up')
+        self.assertIsNotNone(record.begin_time)
+        self.assertIsNotNone(record.end_time)
+        expected_msg = ('Failed to collect controller info from '
+                        'mock_controller: Some failure')
+        self.assertEqual(record.details, expected_msg)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/mobly/controller_manager_test.py b/tests/mobly/controller_manager_test.py
--- a/tests/mobly/controller_manager_test.py
+++ b/tests/mobly/controller_manager_test.py
@@ -150,6 +150,16 @@ def test_get_controller_info_records_empty(self, mock_get_info_func):
         self.assertEqual(record.test_class, 'SomeClass')
         self.assertEqual(record.controller_name, 'MagicDevice')
 
+    @mock.patch('tests.lib.mock_controller.get_info')
+    def test_get_controller_info_records_error(self, mock_get_info_func):
+        mock_get_info_func.side_effect = Exception('Record info failed.')
+        mock_ctrlr_config_name = mock_controller.MOBLY_CONTROLLER_CONFIG_NAME
+        controller_configs = {mock_ctrlr_config_name: ['magic1', 'magic2']}
+        c_manager = controller_manager.ControllerManager(
+            'SomeClass', controller_configs)
+        c_manager.register_controller(mock_controller)
+        self.assertFalse(c_manager.get_controller_info_records())
+
     def test_get_controller_info_records(self):
         mock_ctrlr_config_name = mock_controller.MOBLY_CONTROLLER_CONFIG_NAME
         controller_configs = {mock_ctrlr_config_name: ['magic1', 'magic2']}
@@ -189,6 +199,18 @@ def test_unregister_controller(self, mock_destroy_func):
         self.assertFalse(c_manager._controller_objects)
         self.assertFalse(c_manager._controller_modules)
 
+    @mock.patch('tests.lib.mock_controller.destroy')
+    def test_unregister_controller_error(self, mock_destroy_func):
+        mock_ctrlr_config_name = mock_controller.MOBLY_CONTROLLER_CONFIG_NAME
+        controller_configs = {mock_ctrlr_config_name: ['magic1', 'magic2']}
+        c_manager = controller_manager.ControllerManager(
+            'SomeClass', controller_configs)
+        c_manager.register_controller(mock_controller)
+        mock_destroy_func.side_effect = Exception('Failed in destroy.')
+        c_manager.unregister_controllers()
+        self.assertFalse(c_manager._controller_objects)
+        self.assertFalse(c_manager._controller_modules)
+
     @mock.patch('tests.lib.mock_controller.destroy')
     def test_unregister_controller_without_registration(
             self, mock_destroy_func):