Improve boundary clarity of execution stages in logs (#478)

* Clarify each execution stage in DEBUG log.
* Fix behavior for `setup_generated_test` failure and add a unit test for it.
diff --git a/mobly/base_test.py b/mobly/base_test.py
index 9eed6af..e64c437 100644
--- a/mobly/base_test.py
+++ b/mobly/base_test.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 import collections
+import contextlib
 import copy
 import functools
 import inspect
@@ -26,10 +27,20 @@
 from mobly import signals
 from mobly import utils
 
-# Macro strings for test result reporting
+# Macro strings for test result reporting.
 TEST_CASE_TOKEN = '[Test]'
 RESULT_LINE_TEMPLATE = TEST_CASE_TOKEN + ' %s %s'
 
+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_SETUP_GENERATED_TESTS = 'setup_generated_tests'
+STAGE_NAME_SETUP_CLASS = 'setup_class'
+STAGE_NAME_SETUP_TEST = 'setup_test'
+STAGE_NAME_TEARDOWN_TEST = 'teardown_test'
+STAGE_NAME_TEARDOWN_CLASS = 'teardown_class'
+
 
 class Error(Exception):
     """Raised for exceptions that occured in BaseTestClass."""
@@ -160,6 +171,30 @@
                 logging.warning('Missing optional user param "%s" in '
                                 'configuration, continue.', name)
 
+    def _setup_generated_tests(self):
+        """Proxy function to guarantee the base implementation of
+        setup_generated_tests is called.
+
+        Returns:
+            True if setup is successful, False otherwise.
+        """
+        stage_name = STAGE_NAME_SETUP_GENERATED_TESTS
+        record = records.TestResultRecord(stage_name, self.TAG)
+        record.test_begin()
+        self.current_test_info = runtime_test_info.RuntimeTestInfo(
+            stage_name, self.log_path, record)
+        try:
+            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)
+            record.test_error(e)
+            self.results.add_class_error(record)
+            self.summary_writer.dump(record.to_dict(),
+                                     records.TestSummaryEntryType.RECORD)
+            return False
+
     def setup_generated_tests(self):
         """Preprocesses that need to be done before setup_class.
 
@@ -175,7 +210,8 @@
         """Proxy function to guarantee the base implementation of setup_class
         is called.
         """
-        self.setup_class()
+        with self._log_test_stage(STAGE_NAME_SETUP_CLASS):
+            self.setup_class()
 
     def setup_class(self):
         """Setup function that will be called before executing any test in the
@@ -190,15 +226,19 @@
         """Proxy function to guarantee the base implementation of
         teardown_class is called.
         """
-        record = records.TestResultRecord('teardown_class', self.TAG)
+        stage_name = STAGE_NAME_TEARDOWN_CLASS
+        record = records.TestResultRecord(stage_name, self.TAG)
         record.test_begin()
+        self.current_test_info = runtime_test_info.RuntimeTestInfo(
+            stage_name, self.log_path, record)
         try:
-            self.teardown_class()
+            with self._log_test_stage(stage_name):
+                self.teardown_class()
         except signals.TestAbortAll as e:
             setattr(e, 'results', self.results)
             raise
         except Exception as e:
-            logging.exception('Error encountered in teardown_class.')
+            logging.exception('Error encountered in %s.', stage_name)
             record.test_error(e)
             record.update_record()
             self.results.add_class_error(record)
@@ -212,12 +252,37 @@
         Implementation is optional.
         """
 
+    @contextlib.contextmanager
+    def _log_test_stage(self, stage_name):
+        """Logs the begin and end of a test stage.
+
+        This context adds two log lines meant for clarifying the boundary of
+        each execution stage in Mobly log.
+
+        Args:
+            stage_name: string, name of the stage to log.
+        """
+        parent_token = self.current_test_info.name
+        # If the name of the stage is the same as the test name, in which case
+        # the stage is class-level instead of test-level, use the class's
+        # reference tag as the parent token instead.
+        if parent_token == stage_name:
+            parent_token = self.TAG
+        logging.debug(
+            TEST_STAGE_BEGIN_LOG_TEMPLATE.format(
+                parent_token=parent_token, child_token=stage_name))
+        yield
+        logging.debug(
+            TEST_STAGE_END_LOG_TEMPLATE.format(
+                parent_token=parent_token, child_token=stage_name))
+
     def _setup_test(self, test_name):
         """Proxy function to guarantee the base implementation of setup_test is
         called.
         """
         self.current_test_name = test_name
-        self.setup_test()
+        with self._log_test_stage(STAGE_NAME_SETUP_TEST):
+            self.setup_test()
 
     def setup_test(self):
         """Setup function that will be called every time before executing each
@@ -232,7 +297,8 @@
         """Proxy function to guarantee the base implementation of teardown_test
         is called.
         """
-        self.teardown_test()
+        with self._log_test_stage(STAGE_NAME_TEARDOWN_TEST):
+            self.teardown_test()
 
     def teardown_test(self):
         """Teardown function that will be called every time a test method has
@@ -250,7 +316,6 @@
                     this test, containing all information of the test execution
                     including exception objects.
         """
-        logging.info(RESULT_LINE_TEMPLATE, record.test_name, record.result)
         self.on_fail(record)
 
     def on_fail(self, record):
@@ -276,7 +341,6 @@
         msg = record.details
         if msg:
             logging.info(msg)
-        logging.info(RESULT_LINE_TEMPLATE, record.test_name, record.result)
         self.on_pass(record)
 
     def on_pass(self, record):
@@ -330,19 +394,20 @@
             tr_record: The TestResultRecord object associated with the test
                 executed.
         """
-        try:
-            # Pass a copy of the record instead of the actual object so that it
-            # will not be modified.
-            func(copy.deepcopy(tr_record))
-        except signals.TestAbortSignal:
-            raise
-        except Exception as e:
-            func_name = func.__name__
-            if func_name.startswith('_'):
-                func_name = func_name[1:]
-            logging.exception('Exception happened when executing %s for %s.',
-                              func_name, self.current_test_name)
-            tr_record.add_error(func_name, e)
+        func_name = func.__name__
+        procedure_name = func_name[1:] if func_name[0] == '_' else func_name
+        with self._log_test_stage(procedure_name):
+            try:
+                # Pass a copy of the record instead of the actual object so that it
+                # will not be modified.
+                func(copy.deepcopy(tr_record))
+            except signals.TestAbortSignal:
+                raise
+            except Exception as e:
+                logging.exception(
+                    'Exception happened when executing %s for %s.',
+                    procedure_name, self.current_test_name)
+                tr_record.add_error(procedure_name, e)
 
     def record_data(self, content):
         """Record an entry in test summary file.
@@ -411,7 +476,7 @@
                 except Exception as e:
                     logging.exception(e)
                     tr_record.test_error()
-                    tr_record.add_error('teardown_test', e)
+                    tr_record.add_error(STAGE_NAME_TEARDOWN_TEST, e)
                     teardown_test_failed = True
                 else:
                     # Check if anything failed by `expects`.
@@ -452,6 +517,8 @@
                 elif tr_record.result == records.TestResultEnums.TEST_RESULT_SKIP:
                     self._exec_procedure_func(self._on_skip, tr_record)
             finally:
+                logging.info(RESULT_LINE_TEMPLATE, tr_record.test_name,
+                             tr_record.result)
                 self.results.add_record(tr_record)
                 self.summary_writer.dump(tr_record.to_dict(),
                                          records.TestSummaryEntryType.RECORD)
@@ -487,7 +554,7 @@
             arg_sets: a list of tuples, each tuple is a set of arguments to be
                 passed to the test logic function and name function.
         """
-        self._assert_function_name_in_stack('setup_generated_tests')
+        self._assert_function_name_in_stack(STAGE_NAME_SETUP_GENERATED_TESTS)
         for args in arg_sets:
             test_name = name_func(*args)
             if test_name in self.get_existing_test_names():
@@ -605,17 +672,7 @@
             The test results object of this class.
         """
         # Executes pre-setup procedures, like generating test methods.
-        try:
-            self.setup_generated_tests()
-        except Exception as e:
-            logging.exception('Pre-setup processes failed for %s.', self.TAG)
-            class_record = records.TestResultRecord('setup_generated_tests',
-                                                    self.TAG)
-            class_record.test_begin()
-            class_record.test_error(e)
-            self.results.add_class_error(class_record)
-            self.summary_writer.dump(class_record.to_dict(),
-                                     records.TestSummaryEntryType.RECORD)
+        if not self._setup_generated_tests():
             return self.results
         logging.info('==========> %s <==========', self.TAG)
         # Devise the actual test methods to run in the test class.
@@ -632,10 +689,11 @@
         tests = self._get_test_methods(test_names)
         try:
             # Setup for the class.
-            class_record = records.TestResultRecord('setup_class', self.TAG)
+            class_record = records.TestResultRecord(STAGE_NAME_SETUP_CLASS,
+                                                    self.TAG)
             class_record.test_begin()
             self.current_test_info = runtime_test_info.RuntimeTestInfo(
-                'setup_class', self.log_path, class_record)
+                STAGE_NAME_SETUP_CLASS, self.log_path, class_record)
             try:
                 self._setup_class()
             except signals.TestAbortSignal:
@@ -644,7 +702,7 @@
             except Exception as e:
                 # Setup class failed for unknown reasons.
                 # Fail the class and skip all tests.
-                logging.exception('Error in setup_class %s.', self.TAG)
+                logging.exception('Error in %s#setup_class.', self.TAG)
                 class_record.test_error(e)
                 self.results.add_class_error(class_record)
                 self.summary_writer.dump(class_record.to_dict(),
diff --git a/tests/mobly/base_test_test.py b/tests/mobly/base_test_test.py
index 593ff0b..d029219 100755
--- a/tests/mobly/base_test_test.py
+++ b/tests/mobly/base_test_test.py
@@ -1630,6 +1630,35 @@
         bc.unpack_userparams(arg1="haha")
         self.assertEqual(bc.arg1, "haha")
 
+    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, 'setup_generated_tests')
+        self.assertEqual(bt_cls.results.skipped, [])
+
     def test_generate_tests_run(self):
         class MockBaseTest(base_test.BaseTestClass):
             def setup_generated_tests(self):