aboutsummaryrefslogtreecommitdiff
path: root/mobly/base_test.py
diff options
context:
space:
mode:
Diffstat (limited to 'mobly/base_test.py')
-rw-r--r--mobly/base_test.py134
1 files changed, 130 insertions, 4 deletions
diff --git a/mobly/base_test.py b/mobly/base_test.py
index f1a28bf..ebcc2b8 100644
--- a/mobly/base_test.py
+++ b/mobly/base_test.py
@@ -43,11 +43,86 @@ STAGE_NAME_TEARDOWN_TEST = 'teardown_test'
STAGE_NAME_TEARDOWN_CLASS = 'teardown_class'
STAGE_NAME_CLEAN_UP = 'clean_up'
+# Attribute names
+ATTR_REPEAT_CNT = '_repeat_count'
+ATTR_MAX_RETRY_CNT = '_max_count'
+
class Error(Exception):
"""Raised for exceptions that occurred in BaseTestClass."""
+def repeat(count):
+ """Decorator for repeating a test case multiple times.
+
+ The BaseTestClass will execute the test cases annotated with this decorator
+ the specified number of time.
+
+ This decorator only stores the information needed for the repeat. It does not
+ execute the repeat.
+
+ Args:
+ count: int, the total number of times to execute the decorated test case.
+
+ Returns:
+ The wrapped test function.
+
+ Raises:
+ ValueError, if the user input is invalid.
+ """
+ if count <= 1:
+ raise ValueError(
+ f'The `count` for `repeat` must be larger than 1, got "{count}".')
+
+ def _outer_decorator(func):
+ setattr(func, ATTR_REPEAT_CNT, count)
+
+ @functools.wraps(func)
+ def _wrapper(*args):
+ func(*args)
+
+ return _wrapper
+
+ return _outer_decorator
+
+
+def retry(max_count):
+ """Decorator for retrying a test case until it passes.
+
+ The BaseTestClass will keep executing the test cases annotated with this
+ decorator until the test passes, or the maxinum number of iterations have
+ been met.
+
+ This decorator only stores the information needed for the retry. It does not
+ execute the retry.
+
+ Args:
+ max_count: int, the maximum number of times to execute the decorated test
+ case.
+
+ Returns:
+ The wrapped test function.
+
+ Raises:
+ ValueError, if the user input is invalid.
+ """
+ if max_count <= 1:
+ raise ValueError(
+ f'The `max_count` for `retry` must be larger than 1, got "{max_count}".'
+ )
+
+ def _outer_decorator(func):
+ setattr(func, ATTR_MAX_RETRY_CNT, max_count)
+
+ @functools.wraps(func)
+ def _wrapper(*args):
+ func(*args)
+
+ return _wrapper
+
+ return _outer_decorator
+
+
class BaseTestClass:
"""Base class for all test classes to inherit from.
@@ -559,7 +634,38 @@ class BaseTestClass:
content['timestamp'] = utils.get_current_epoch_time()
self.summary_writer.dump(content, records.TestSummaryEntryType.USER_DATA)
- def exec_one_test(self, test_name, test_method):
+ def _exec_one_test_with_retry(self, test_name, test_method, max_count):
+ """Executes one test and retry the test if needed.
+
+ Repeatedly execute a test case until it passes or the maximum count of
+ iteration has been reached.
+
+ Args:
+ test_name: string, Name of the test.
+ test_method: function, The test method to execute.
+ max_count: int, the maximum number of iterations to execute the test for.
+ """
+
+ def should_retry(record):
+ return record.result in [
+ records.TestResultEnums.TEST_RESULT_FAIL,
+ records.TestResultEnums.TEST_RESULT_ERROR
+ ]
+
+ previous_record = self.exec_one_test(test_name, test_method)
+
+ if not should_retry(previous_record):
+ return previous_record
+
+ for i in range(max_count - 1):
+ retry_name = f'{test_name}_retry_{i+1}'
+ new_record = records.TestResultRecord(retry_name, self.TAG)
+ new_record.retry_parent = previous_record.signature
+ previous_record = self.exec_one_test(retry_name, test_method, new_record)
+ if not should_retry(previous_record):
+ break
+
+ def exec_one_test(self, test_name, test_method, record=None):
"""Executes one test and update test results.
Executes setup_test, the test method, and teardown_test; then creates a
@@ -569,8 +675,18 @@ class BaseTestClass:
Args:
test_name: string, Name of the test.
test_method: function, The test method to execute.
+ record: records.TestResultRecord, optional arg for injecting a record
+ object to use for this test execution. If not set, a new one is created
+ created. This is meant for passing infomation between consecutive test
+ case execution for retry purposes. Do NOT abuse this for "magical"
+ features.
+
+ Returns:
+ TestResultRecord, the test result record object of the test execution.
+ This object is strictly for read-only purposes. Modifying this record
+ will not change what is reported in the test run's summary yaml file.
"""
- tr_record = records.TestResultRecord(test_name, self.TAG)
+ tr_record = record or 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(
@@ -650,6 +766,7 @@ class BaseTestClass:
self.summary_writer.dump(tr_record.to_dict(),
records.TestSummaryEntryType.RECORD)
self.current_test_info = None
+ return tr_record
def _assert_function_name_in_stack(self, expected_func_name):
"""Asserts that the current stack contains the given function name."""
@@ -767,7 +884,12 @@ class BaseTestClass:
test_method = self._generated_test_table[test_name]
else:
raise Error('%s does not have test method %s.' % (self.TAG, test_name))
- test_methods.append((test_name, test_method))
+ repeat_count = getattr(test_method, ATTR_REPEAT_CNT, 0)
+ if repeat_count:
+ for i in range(repeat_count):
+ test_methods.append((f'{test_name}_{i}', test_method))
+ else:
+ test_methods.append((test_name, test_method))
return test_methods
def _skip_remaining_tests(self, exception):
@@ -831,7 +953,11 @@ class BaseTestClass:
return setup_class_result
# Run tests in order.
for test_name, test_method in tests:
- self.exec_one_test(test_name, test_method)
+ max_count = getattr(test_method, ATTR_MAX_RETRY_CNT, 0)
+ if max_count:
+ self._exec_one_test_with_retry(test_name, test_method, max_count)
+ else:
+ self.exec_one_test(test_name, test_method)
return self.results
except signals.TestAbortClass as e:
e.details = 'Test class aborted due to: %s' % e.details