Add support for specifying UID for test cases (#571)

diff --git a/mobly/base_test.py b/mobly/base_test.py
index 88e227b..91a0a4f 100644
--- a/mobly/base_test.py
+++ b/mobly/base_test.py
@@ -575,6 +575,7 @@
             test_method: function, The test method to execute.
         """
         tr_record = records.TestResultRecord(test_name, self.TAG)
+        tr_record.uid = getattr(test_method, 'uid', None)
         tr_record.test_begin()
         self.current_test_info = runtime_test_info.RuntimeTestInfo(
             test_name, self.log_path, tr_record)
@@ -664,7 +665,7 @@
         raise Error('"%s" cannot be called outside of %s' %
                     (caller_frames[1][3], expected_func_name))
 
-    def generate_tests(self, test_logic, name_func, arg_sets):
+    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
@@ -674,6 +675,9 @@
         parameter sets. This way we reduce code repetition and improve test
         scalability.
 
+        Users can provide an optional function to specify the UID of each test.
+        Not all generated tests are required to have UID.
+
         Args:
             test_logic: function, the common logic shared by all the generated
                 tests.
@@ -682,15 +686,26 @@
                 the test logic function.
             arg_sets: a list of tuples, each tuple is a set of arguments to be
                 passed to the test logic function and name function.
+            uid_func: function, an optional function that takes the same
+                arguments as the test logic function and returns a string that
+                is the corresponding UID.
         """
         self._assert_function_name_in_stack(STAGE_NAME_SETUP_GENERATED_TESTS)
+        root_msg = 'During test generation of "%s":' % test_logic.__name__
         for args in arg_sets:
             test_name = name_func(*args)
             if test_name in self.get_existing_test_names():
                 raise Error(
-                    'Test name "%s" already exists, cannot be duplicated!' %
-                    test_name)
+                    '%s Test name "%s" already exists, cannot be duplicated!' %
+                    (root_msg, test_name))
             test_func = functools.partial(test_logic, *args)
+            if uid_func is not None:
+                uid = uid_func(*args)
+                if uid is None:
+                    logging.warning('%s UID for arg set %s is None.', root_msg,
+                                    args)
+                else:
+                    setattr(test_func, 'uid', uid)
             self._generated_test_table[test_name] = test_func
 
     def _safe_exec_func(self, func, *args):
diff --git a/mobly/records.py b/mobly/records.py
index 1dec134..4048554 100644
--- a/mobly/records.py
+++ b/mobly/records.py
@@ -17,6 +17,7 @@
 import collections
 import copy
 import enum
+import functools
 import io
 import logging
 import sys
@@ -34,6 +35,38 @@
 OUTPUT_FILE_SUMMARY = 'test_summary.yaml'
 
 
+class Error(Exception):
+    """Raised for errors in record module members."""
+
+
+def uid(uid):
+    """Decorator specifying the unique identifier (UID) of a test case.
+
+    The UID will be recorded in the test's record when executed by Mobly.
+
+    If you use any other decorator for the test method, you may want to use
+    this as the outer-most one.
+
+    Note a common UID system is the Universal Unitque Identifier (UUID), but
+    we are not limiting people to use UUID, hence the more generic name `UID`.
+
+    Args:
+        uid: string, the uid for the decorated test function.
+    """
+    if uid is None:
+        raise ValueError('UID cannot be None.')
+
+    def decorate(test_func):
+        @functools.wraps(test_func)
+        def wrapper(*args, **kwargs):
+            return test_func(*args, **kwargs)
+
+        setattr(wrapper, 'uid', uid)
+        return wrapper
+
+    return decorate
+
+
 class TestSummaryEntryType(enum.Enum):
     """Constants used to identify the type of entries in test summary file.
 
diff --git a/tests/mobly/base_test_test.py b/tests/mobly/base_test_test.py
index 8d77017..43951f4 100755
--- a/tests/mobly/base_test_test.py
+++ b/tests/mobly/base_test_test.py
@@ -1855,9 +1855,59 @@
         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')
 
+    def test_generate_tests_with_uid(self):
+        class MockBaseTest(base_test.BaseTestClass):
+            def setup_generated_tests(self):
+                self.generate_tests(
+                    test_logic=self.logic,
+                    name_func=self.name_gen,
+                    uid_func=self.uid_logic,
+                    arg_sets=[(1, 2), (3, 4)])
+
+            def name_gen(self, a, b):
+                return 'test_%s_%s' % (a, b)
+
+            def uid_logic(self, a, b):
+                return 'uid-%s-%s' % (a, b)
+
+            def logic(self, a, b):
+                pass
+
+        bt_cls = MockBaseTest(self.mock_test_cls_configs)
+        bt_cls.run()
+        self.assertEqual(bt_cls.results.passed[0].uid, 'uid-1-2')
+        self.assertEqual(bt_cls.results.passed[1].uid, 'uid-3-4')
+
+    def test_generate_tests_with_none_uid(self):
+        class MockBaseTest(base_test.BaseTestClass):
+            def setup_generated_tests(self):
+                self.generate_tests(
+                    test_logic=self.logic,
+                    name_func=self.name_gen,
+                    uid_func=self.uid_logic,
+                    arg_sets=[(1, 2), (3, 4)])
+
+            def name_gen(self, a, b):
+                return 'test_%s_%s' % (a, b)
+
+            def uid_logic(self, a, b):
+                if a == 1:
+                    return None
+                return 'uid-3-4'
+
+            def logic(self, a, b):
+                pass
+
+        bt_cls = MockBaseTest(self.mock_test_cls_configs)
+        bt_cls.run()
+        self.assertIsNone(bt_cls.results.passed[0].uid)
+        self.assertEqual(bt_cls.results.passed[1].uid, 'uid-3-4')
+
     def test_generate_tests_selected_run(self):
         class MockBaseTest(base_test.BaseTestClass):
             def setup_generated_tests(self):
@@ -1925,7 +1975,8 @@
         self.assertEqual(actual_record.test_name, "setup_generated_tests")
         self.assertEqual(
             actual_record.details,
-            'Test name "ha" already exists, cannot be duplicated!')
+            'During test generation of "logic": Test name "ha" already exists'
+            ', cannot be duplicated!')
         expected_summary = ("Error 1, Executed 0, Failed 0, Passed 0, "
                             "Requested 0, Skipped 0")
         self.assertEqual(bt_cls.results.summary_str(), expected_summary)
@@ -2048,6 +2099,35 @@
                         'mock_controller: Some failure')
         self.assertEqual(record.details, expected_msg)
 
+    def test_uid(self):
+        class MockBaseTest(base_test.BaseTestClass):
+            @records.uid('some-uid')
+            def test_func(self):
+                pass
+
+        bt_cls = MockBaseTest(self.mock_test_cls_configs)
+        bt_cls.run()
+        actual_record = bt_cls.results.passed[0]
+        self.assertEqual(actual_record.uid, 'some-uid')
+
+    def test_uid_not_specified(self):
+        class MockBaseTest(base_test.BaseTestClass):
+            def test_func(self):
+                pass
+
+        bt_cls = MockBaseTest(self.mock_test_cls_configs)
+        bt_cls.run()
+        actual_record = bt_cls.results.passed[0]
+        self.assertIsNone(actual_record.uid)
+
+    def test_uid_is_none(self):
+        with self.assertRaisesRegex(ValueError, 'UID cannot be None.'):
+
+            class MockBaseTest(base_test.BaseTestClass):
+                @records.uid(None)
+                def not_a_test(self):
+                    pass
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/mobly/records_test.py b/tests/mobly/records_test.py
index 9648637..ba25a69 100755
--- a/tests/mobly/records_test.py
+++ b/tests/mobly/records_test.py
@@ -427,6 +427,13 @@
         self.assertEqual(tr.controller_info[0].controller_info,
                          ['magicA', 'magicB'])
 
+    def test_uid(self):
+        @records.uid('some-uuid')
+        def test_uid_helper():
+            """Dummy test used by `test_uid` for testing the uid decorator."""
+
+        self.assertEqual(test_uid_helper.uid, 'some-uuid')
+
 
 if __name__ == '__main__':
     unittest.main()