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_DEBUG_LOG = 'test_log.DEBUG'
 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.
 

Support marking `UID` for tests
We have long had a `UID` field in our record object.
Let's provide a way for test writer to utilize this field.
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 @@ class BaseTestClass(object):
             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 @@ class BaseTestClass(object):
         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 @@ class BaseTestClass(object):
         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 @@ class BaseTestClass(object):
                 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/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 @@ class BaseTestTest(unittest.TestCase):
         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 @@ class BaseTestTest(unittest.TestCase):
         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 @@ class BaseTestTest(unittest.TestCase):
                         '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 @@ class RecordsTest(unittest.TestCase):
         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()

pip install -e .
parse_log_pytest
pytest
mock pytest pytz
apt-get update apt-get install -y adb
3.5
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs @ file:///opt/conda/conda-bld/attrs_1642510447205/work
certifi==2021.5.30
future==1.0.0
importlib-metadata @ file:///tmp/build/80754af9/importlib-metadata_1631916693255/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
-e git+https://github.com/google/mobly.git@7ce7a10fc5efdd17760519d0efacd7e2e6370b45#egg=mobly
mock==5.2.0
more-itertools @ file:///tmp/build/80754af9/more-itertools_1637733554872/work
packaging @ file:///tmp/build/80754af9/packaging_1637314298585/work
pluggy @ file:///tmp/build/80754af9/pluggy_1615976315926/work
portpicker==1.6.0
psutil==7.0.0
py @ file:///opt/conda/conda-bld/py_1644396412707/work
pyparsing @ file:///tmp/build/80754af9/pyparsing_1635766073266/work
pyserial==3.5
pytest==6.2.4
pytz==2025.2
PyYAML==6.0.1
timeout-decorator==0.5.0
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
typing_extensions @ file:///opt/conda/conda-bld/typing_extensions_1647553014482/work
zipp @ file:///tmp/build/80754af9/zipp_1633618647012/work

name: mobly
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=21.4.0=pyhd3eb1b0_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - importlib-metadata=4.8.1=py36h06a4308_0
  - importlib_metadata=4.8.1=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - more-itertools=8.12.0=pyhd3eb1b0_0
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=21.3=pyhd3eb1b0_0
  - pip=21.2.2=py36h06a4308_0
  - pluggy=0.13.1=py36h06a4308_0
  - py=1.11.0=pyhd3eb1b0_0
  - pyparsing=3.0.4=pyhd3eb1b0_0
  - pytest=6.2.4=py36h06a4308_2
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - typing_extensions=4.1.1=pyh06a4308_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.6.0=pyhd3eb1b0_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - future==1.0.0
      - mock==5.2.0
      - portpicker==1.6.0
      - psutil==7.0.0
      - pyserial==3.5
      - pytz==2025.2
      - pyyaml==6.0.1
      - timeout-decorator==0.5.0
prefix: /opt/conda/envs/mobly

tests/mobly/base_test_test.py::BaseTestTest::test_uid tests/mobly/base_test_test.py::BaseTestTest::test_uid_is_none tests/mobly/records_test.py::RecordsTest::test_uid
tests/mobly/base_test_test.py::BaseTestTest::test_write_user_data tests/mobly/records_test.py::RecordsTest::test_summary_user_data tests/mobly/records_test.py::RecordsTest::test_summary_write_dump tests/mobly/records_test.py::RecordsTest::test_summary_write_dump_with_unicode
tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_on_fail tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_on_fail_from_setup_class tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_setup_class tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_setup_test tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_teardown_class tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_test tests/mobly/base_test_test.py::BaseTestTest::test_abort_class_in_on_fail tests/mobly/base_test_test.py::BaseTestTest::test_abort_class_in_setup_test tests/mobly/base_test_test.py::BaseTestTest::test_abort_class_in_test tests/mobly/base_test_test.py::BaseTestTest::test_abort_class_setup_class tests/mobly/base_test_test.py::BaseTestTest::test_assert_equal_fail tests/mobly/base_test_test.py::BaseTestTest::test_assert_equal_fail_with_msg tests/mobly/base_test_test.py::BaseTestTest::test_assert_equal_pass tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_fail_with_noop tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_fail_with_wrong_error tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_fail_with_wrong_regex tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_pass tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_regex_fail_with_noop tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_regex_fail_with_wrong_error tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_regex_pass tests/mobly/base_test_test.py::BaseTestTest::test_assert_true tests/mobly/base_test_test.py::BaseTestTest::test_both_teardown_and_test_body_raise_exceptions tests/mobly/base_test_test.py::BaseTestTest::test_cli_test_selection_fail_by_convention tests/mobly/base_test_test.py::BaseTestTest::test_cli_test_selection_override_self_tests_list tests/mobly/base_test_test.py::BaseTestTest::test_current_test_info tests/mobly/base_test_test.py::BaseTestTest::test_current_test_info_in_setup_class tests/mobly/base_test_test.py::BaseTestTest::test_current_test_name tests/mobly/base_test_test.py::BaseTestTest::test_default_execution_of_all_tests tests/mobly/base_test_test.py::BaseTestTest::test_exception_objects_in_record tests/mobly/base_test_test.py::BaseTestTest::test_expect_equal tests/mobly/base_test_test.py::BaseTestTest::test_expect_false tests/mobly/base_test_test.py::BaseTestTest::test_expect_in_setup_class tests/mobly/base_test_test.py::BaseTestTest::test_expect_in_setup_class_and_on_fail tests/mobly/base_test_test.py::BaseTestTest::test_expect_in_setup_test tests/mobly/base_test_test.py::BaseTestTest::test_expect_in_teardown_class tests/mobly/base_test_test.py::BaseTestTest::test_expect_in_teardown_test tests/mobly/base_test_test.py::BaseTestTest::test_expect_multiple_fails tests/mobly/base_test_test.py::BaseTestTest::test_expect_no_op tests/mobly/base_test_test.py::BaseTestTest::test_expect_no_raises_custom_msg tests/mobly/base_test_test.py::BaseTestTest::test_expect_no_raises_default_msg tests/mobly/base_test_test.py::BaseTestTest::test_expect_true tests/mobly/base_test_test.py::BaseTestTest::test_expect_true_and_assert_true tests/mobly/base_test_test.py::BaseTestTest::test_expect_two_tests tests/mobly/base_test_test.py::BaseTestTest::test_explicit_pass tests/mobly/base_test_test.py::BaseTestTest::test_explicit_pass_but_teardown_test_raises_an_exception tests/mobly/base_test_test.py::BaseTestTest::test_fail tests/mobly/base_test_test.py::BaseTestTest::test_failure_in_procedure_functions_is_recorded tests/mobly/base_test_test.py::BaseTestTest::test_failure_to_call_procedure_function_is_recorded tests/mobly/base_test_test.py::BaseTestTest::test_generate_tests_call_outside_of_setup_generated_tests tests/mobly/base_test_test.py::BaseTestTest::test_generate_tests_dup_test_name tests/mobly/base_test_test.py::BaseTestTest::test_generate_tests_run tests/mobly/base_test_test.py::BaseTestTest::test_generate_tests_selected_run tests/mobly/base_test_test.py::BaseTestTest::test_generate_tests_with_none_uid tests/mobly/base_test_test.py::BaseTestTest::test_generate_tests_with_uid tests/mobly/base_test_test.py::BaseTestTest::test_implicit_pass tests/mobly/base_test_test.py::BaseTestTest::test_missing_requested_test_func tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_cannot_modify_original_record tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_executed_if_both_test_and_teardown_test_fails tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_executed_if_setup_class_fails_by_exception tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_executed_if_setup_test_fails_by_exception tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_executed_if_teardown_test_fails tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_executed_if_test_fails tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_raise_exception tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_triggered_by_setup_class_failure_then_fail_too tests/mobly/base_test_test.py::BaseTestTest::test_on_pass_cannot_modify_original_record tests/mobly/base_test_test.py::BaseTestTest::test_on_pass_raise_exception tests/mobly/base_test_test.py::BaseTestTest::test_procedure_function_gets_correct_record tests/mobly/base_test_test.py::BaseTestTest::test_promote_extra_errors_to_termination_signal tests/mobly/base_test_test.py::BaseTestTest::test_record_controller_info tests/mobly/base_test_test.py::BaseTestTest::test_record_controller_info_fail tests/mobly/base_test_test.py::BaseTestTest::test_self_tests_list tests/mobly/base_test_test.py::BaseTestTest::test_self_tests_list_fail_by_convention tests/mobly/base_test_test.py::BaseTestTest::test_setup_and_teardown_execution_count tests/mobly/base_test_test.py::BaseTestTest::test_setup_class_fail_by_exception tests/mobly/base_test_test.py::BaseTestTest::test_setup_generated_tests_failure tests/mobly/base_test_test.py::BaseTestTest::test_setup_test_fail_by_exception tests/mobly/base_test_test.py::BaseTestTest::test_setup_test_fail_by_test_signal tests/mobly/base_test_test.py::BaseTestTest::test_skip tests/mobly/base_test_test.py::BaseTestTest::test_skip_if tests/mobly/base_test_test.py::BaseTestTest::test_skip_in_setup_test tests/mobly/base_test_test.py::BaseTestTest::test_teardown_class_fail_by_exception tests/mobly/base_test_test.py::BaseTestTest::test_teardown_class_raise_abort_all tests/mobly/base_test_test.py::BaseTestTest::test_teardown_test_assert_fail tests/mobly/base_test_test.py::BaseTestTest::test_teardown_test_executed_if_setup_test_fails tests/mobly/base_test_test.py::BaseTestTest::test_teardown_test_executed_if_test_fails tests/mobly/base_test_test.py::BaseTestTest::test_teardown_test_executed_if_test_pass tests/mobly/base_test_test.py::BaseTestTest::test_teardown_test_raise_exception tests/mobly/base_test_test.py::BaseTestTest::test_uid_not_specified tests/mobly/base_test_test.py::BaseTestTest::test_uncaught_exception tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_basic tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_default_None tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_default_overwrite tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_default_overwrite_by_optional_param_list tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_default_overwrite_by_required_param_list tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_optional tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_optional_missing tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_optional_with_default tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_required tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_required_missing tests/mobly/records_test.py::RecordsTest::test_add_controller_info_record tests/mobly/records_test.py::RecordsTest::test_exception_record_deepcopy tests/mobly/records_test.py::RecordsTest::test_is_all_pass tests/mobly/records_test.py::RecordsTest::test_is_all_pass_negative tests/mobly/records_test.py::RecordsTest::test_is_all_pass_with_add_class_error tests/mobly/records_test.py::RecordsTest::test_is_test_executed tests/mobly/records_test.py::RecordsTest::test_result_add_class_error_with_special_error tests/mobly/records_test.py::RecordsTest::test_result_add_class_error_with_test_signal tests/mobly/records_test.py::RecordsTest::test_result_add_operator_success tests/mobly/records_test.py::RecordsTest::test_result_add_operator_type_mismatch tests/mobly/records_test.py::RecordsTest::test_result_record_fail_none tests/mobly/records_test.py::RecordsTest::test_result_record_fail_stacktrace tests/mobly/records_test.py::RecordsTest::test_result_record_fail_with_float_extra tests/mobly/records_test.py::RecordsTest::test_result_record_fail_with_json_extra tests/mobly/records_test.py::RecordsTest::test_result_record_fail_with_unicode_exception tests/mobly/records_test.py::RecordsTest::test_result_record_fail_with_unicode_test_signal tests/mobly/records_test.py::RecordsTest::test_result_record_pass_none tests/mobly/records_test.py::RecordsTest::test_result_record_pass_with_float_extra tests/mobly/records_test.py::RecordsTest::test_result_record_pass_with_json_extra tests/mobly/records_test.py::RecordsTest::test_result_record_skip_none tests/mobly/records_test.py::RecordsTest::test_result_record_skip_with_float_extra tests/mobly/records_test.py::RecordsTest::test_result_record_skip_with_json_extra
mobly/records.py
mobly/records.py