Support using expects.expect_no_raises as a decorator (#1023)
diff --git a/mobly/expects.py b/mobly/expects.py
index ad7cb8a..c9c4e99 100644
--- a/mobly/expects.py
+++ b/mobly/expects.py
@@ -13,6 +13,7 @@
# limitations under the License.
import contextlib
+import functools
import logging
import time
@@ -135,31 +136,65 @@
recorder.add_error(e)
-@contextlib.contextmanager
-def expect_no_raises(message=None, extras=None):
- """Expects no exception is raised in a context.
+class expect_no_raises(contextlib.ContextDecorator):
+ """Expects no exception is raised in a context or a decorated function.
If the expectation is not met, the test is marked as fail after its
execution finishes.
- A default message is added to the exception `details`.
+ Can be used as a context manager:
+ with expects.expect_no_raises(message='Custom message'):
+ do_something()
+
+ Or as a function decorator:
+ @expects.expect_no_raises(message='Custom message')
+ def helper_function(arg):
+ do_something(arg)
+
+ @expects.expect_no_raises
+ def bare_decorated_function():
+ do_something()
Args:
- message: string, custom message to add to exception's `details`.
+ message: An optional custom message to add to the exception's `details`.
extras: An optional field for extra information to be included in test
result.
"""
- try:
- yield
- except Exception as e:
- e_record = records.ExceptionRecord(e)
- if extras:
- e_record.extras = extras
- msg = message or 'Got an unexpected exception'
- details = '%s: %s' % (msg, e_record.details)
- logging.exception(details)
- e_record.details = details
- recorder.add_error(e_record)
+
+ def __new__(cls, *args, **kwargs):
+ if len(args) == 1 and callable(args[0]) and not kwargs:
+ # Used as bare decorator: @expects.expect_no_raises
+ func = args[0]
+ instance = super().__new__(cls)
+ instance.__init__()
+
+ @functools.wraps(func)
+ def wrapped(*f_args, **f_kwargs):
+ with instance:
+ return func(*f_args, **f_kwargs)
+
+ return wrapped
+ return super().__new__(cls)
+
+ def __init__(self, message=None, extras=None):
+ self._message = message
+ self._extras = extras
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if exc_type is not None and issubclass(exc_type, Exception):
+ e_record = records.ExceptionRecord(exc_val)
+ if self._extras:
+ e_record.extras = self._extras
+ msg = self._message or 'Got an unexpected exception'
+ details = '%s: %s' % (msg, e_record.details)
+ logging.exception(details)
+ e_record.details = details
+ recorder.add_error(e_record)
+ return True
+ return False
recorder = _ExpectErrorRecorder(DEFAULT_TEST_RESULT_RECORD)
diff --git a/tests/mobly/base_test_test.py b/tests/mobly/base_test_test.py
index 92916d8..1b43dca 100755
--- a/tests/mobly/base_test_test.py
+++ b/tests/mobly/base_test_test.py
@@ -2565,6 +2565,38 @@
for i, record in enumerate(bt_cls.results.passed):
self.assertEqual(record.test_name, f'test_something_{i}')
+ def test_expect_no_raises_decorator_on_test_method(self):
+ class MockBaseTest(base_test.BaseTestClass):
+
+ @expects.expect_no_raises(message='Step 1 failed')
+ def test_step(self):
+ raise ValueError('Expected failure in step')
+
+ bt_cls = MockBaseTest(self.mock_test_cls_configs)
+ bt_cls.run()
+ self.assertEqual(1, len(bt_cls.results.failed))
+ self.assertEqual(1, len(bt_cls.results.executed))
+ record = bt_cls.results.failed[0]
+ self.assertIn('Step 1 failed', record.details)
+ self.assertIn('Expected failure in step', record.details)
+
+ def test_expect_no_raises_decorator_on_helper_method(self):
+ class MockBaseTest(base_test.BaseTestClass):
+
+ @expects.expect_no_raises
+ def helper_func(self):
+ raise RuntimeError('Helper failed')
+
+ def test_method(self):
+ self.helper_func()
+
+ bt_cls = MockBaseTest(self.mock_test_cls_configs)
+ bt_cls.run()
+ self.assertEqual(1, len(bt_cls.results.failed))
+ self.assertEqual(1, len(bt_cls.results.executed))
+ record = bt_cls.results.failed[0]
+ self.assertIn('Helper failed', record.details)
+
def test_repeat_with_consec_error_does_not_abort_repeat(self):
repeat_count = 5
max_consec_error = 2
diff --git a/tests/mobly/expects_test.py b/tests/mobly/expects_test.py
new file mode 100644
index 0000000..ba79fad
--- /dev/null
+++ b/tests/mobly/expects_test.py
@@ -0,0 +1,137 @@
+# Copyright 2017 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+from mobly import expects
+from mobly import records
+
+
+class ExpectsTest(unittest.TestCase):
+ """Unit tests for the mobly.expects module."""
+
+ def setUp(self):
+ self.record = records.TestResultRecord('test_foo', 'TestClass')
+ expects.recorder.reset_internal_states(self.record)
+
+ def test_expect_true_pass(self):
+ expects.expect_true(True, 'Should pass')
+ self.assertFalse(expects.recorder.has_error)
+ self.assertEqual(expects.recorder.error_count, 0)
+
+ def test_expect_true_fail(self):
+ expects.expect_true(False, 'Expected true', extras='extra_info')
+ self.assertTrue(expects.recorder.has_error)
+ self.assertEqual(expects.recorder.error_count, 1)
+ err = list(self.record.extra_errors.values())[0]
+ self.assertEqual(err.extras, 'extra_info')
+ self.assertIn('Expected true', err.details)
+
+ def test_expect_false_pass(self):
+ expects.expect_false(False, 'Should pass')
+ self.assertFalse(expects.recorder.has_error)
+ self.assertEqual(expects.recorder.error_count, 0)
+
+ def test_expect_false_fail(self):
+ expects.expect_false(True, 'Expected false', extras='extra_info')
+ self.assertTrue(expects.recorder.has_error)
+ self.assertEqual(expects.recorder.error_count, 1)
+ err = list(self.record.extra_errors.values())[0]
+ self.assertEqual(err.extras, 'extra_info')
+ self.assertIn('Expected false', err.details)
+
+ def test_expect_equal_pass(self):
+ expects.expect_equal(1, 1, 'Should pass')
+ self.assertFalse(expects.recorder.has_error)
+ self.assertEqual(expects.recorder.error_count, 0)
+
+ def test_expect_equal_fail(self):
+ expects.expect_equal(1, 2, 'Values not equal', extras='extra_info')
+ self.assertTrue(expects.recorder.has_error)
+ self.assertEqual(expects.recorder.error_count, 1)
+ err = list(self.record.extra_errors.values())[0]
+ self.assertEqual(err.extras, 'extra_info')
+ self.assertIn('Values not equal', err.details)
+
+ def test_expect_no_raises_context_manager_pass(self):
+ with expects.expect_no_raises():
+ _ = 1 + 1
+ self.assertFalse(expects.recorder.has_error)
+ self.assertEqual(expects.recorder.error_count, 0)
+
+ def test_expect_no_raises_context_manager_fail(self):
+ with expects.expect_no_raises(message='Context error', extras='extra_info'):
+ raise ValueError('something went wrong')
+ self.assertTrue(expects.recorder.has_error)
+ self.assertEqual(expects.recorder.error_count, 1)
+ err = list(self.record.extra_errors.values())[0]
+ self.assertEqual(err.extras, 'extra_info')
+ self.assertIn('Context error', err.details)
+ self.assertIn('something went wrong', err.details)
+
+ def test_expect_no_raises_bare_decorator_on_arbitrary_function(self):
+ """Verifies @expects.expect_no_raises on any arbitrary function."""
+
+ @expects.expect_no_raises
+ def arbitrary_helper(a, b, fail=False):
+ if fail:
+ raise RuntimeError('Helper failed')
+ return a + b
+
+ # Success case on arbitrary function
+ result = arbitrary_helper(3, 4, fail=False)
+ self.assertEqual(result, 7)
+ self.assertFalse(expects.recorder.has_error)
+
+ # Failure case on arbitrary function
+ result = arbitrary_helper(3, 4, fail=True)
+ self.assertIsNone(result)
+ self.assertTrue(expects.recorder.has_error)
+ self.assertEqual(expects.recorder.error_count, 1)
+
+ def test_expect_no_raises_parameterized_decorator_on_arbitrary_function(self):
+ """Verifies @expects.expect_no_raises(...) on any arbitrary function."""
+
+ @expects.expect_no_raises(message='Custom step error', extras={'step': 1})
+ def custom_calculation(x, y):
+ if y == 0:
+ raise ZeroDivisionError('divide by zero')
+ return x / y
+
+ # Success case
+ self.assertEqual(custom_calculation(10, 2), 5)
+ self.assertFalse(expects.recorder.has_error)
+
+ # Failure case
+ self.assertIsNone(custom_calculation(10, 0))
+ self.assertTrue(expects.recorder.has_error)
+ self.assertEqual(expects.recorder.error_count, 1)
+ err = list(self.record.extra_errors.values())[0]
+ self.assertEqual(err.extras, {'step': 1})
+ self.assertIn('Custom step error', err.details)
+
+ def test_expect_no_raises_decorator_preserves_function_metadata(self):
+ """Verifies that docstrings, __name__, and metadata are preserved."""
+
+ @expects.expect_no_raises
+ def sample_func(x):
+ """Sample documentation."""
+ return x
+
+ self.assertEqual(sample_func.__name__, 'sample_func')
+ self.assertEqual(sample_func.__doc__, 'Sample documentation.')
+
+
+if __name__ == '__main__':
+ unittest.main()