Description
In hardware/device end-to-end testing, when a test case (test_*) fails, for example, because the device under test crashed, disconnected, or became unresponsive, subsequent cleanup operations in teardown_test() (or teardown_class()) frequently fail when attempting to communicate with the unhealthy device.
Currently, BaseTestClass.exec_one_test in mobly/base_test.py has several issues in how teardown_test() interacts with an already-failed test:
-
self.current_test_info.record does not reflect the test failure during teardown_test():
In exec_one_test(), self._teardown_test(test_name) is executed inside an inner finally: block before the outer except blocks catch the exception raised by test_method() and update tr_record:
try:
try:
self._setup_test(test_name)
test_method()
...
finally:
# tr_record.result and tr_record.termination_signal are still None here!
self._teardown_test(test_name)
except (signals.TestFailure, AssertionError) as e:
tr_record.test_fail(e)
Because tr_record is only populated after _teardown_test() finishes, teardown_test() implementations cannot inspect self.current_test_info.record to determine if the test has already failed (forcing users to rely on fragile workarounds like inspecting sys.exc_info() and expects.recorder.has_error).
-
mobly.expects failures are overridden as ERROR instead of FAIL if teardown_test() fails:
At the end of exec_one_test():
else:
if expects.recorder.has_error and not teardown_test_failed:
tr_record.test_fail()
elif not teardown_test_failed:
tr_record.test_pass()
If test_method() records a failure via expects.expect_true(False, ...) and teardown_test() subsequently raises an exception (or records another expect failure), teardown_test_failed is True. As a result, tr_record.test_fail() is skipped and the test is marked as ERROR rather than FAIL.
-
Raising TestAbortSignal (TestAbortClass / TestAbortAll) in teardown_test() overwrites the original test failure:
Inside the finally: block of exec_one_test():
try:
self._teardown_test(test_name)
except signals.TestAbortSignal:
raise
If test_method() raises AssertionError / TestFailure (or another exception) and teardown_test() detects that the device is dead and raises TestAbortClass or TestAbortAll, re-raising TestAbortSignal inside finally: replaces the active exception from test_method(). The outer except signals.TestAbortSignal as e: then records the teardown abort signal as tr_record.termination_signal, masking the root-cause failure from test_method().
Steps to Reproduce
from mobly import asserts
from mobly import base_test
from mobly import expects
from mobly import signals
from mobly import test_runner
class TeardownOverrideTest(base_test.BaseTestClass):
def teardown_test(self):
# Note: self.current_test_info.record.result is None here even if the test failed.
# If teardown detects a broken device and aborts the class, or fails after an `expect`:
raise signals.TestAbortClass("Device unreachable during teardown_test")
def test_assertion_failure_masked_by_teardown_abort(self):
asserts.assert_true(False, "Root cause: test assertion failed")
def test_expect_failure_overridden_by_teardown_error(self):
expects.expect_true(False, "Root cause: soft expectation failed")
if __name__ == "__main__":
test_runner.main()
Expected Behavior
- Record test failure before running
teardown_test():
Catch and record the exception from setup_test() / test_method() (and any expects.recorder errors) onto tr_record before invoking self._teardown_test(test_name), so that self.current_test_info.record accurately reflects the test's status and termination_signal during teardown_test().
- Never overwrite a test's primary failure with a teardown failure:
- If
test_method() already failed (via exception or mobly.expects), any exception or TestAbortSignal raised in teardown_test() should be recorded in tr_record.extra_errors['teardown_test'] without overwriting tr_record.termination_signal or changing a FAIL result to ERROR.
- If
teardown_test() raises TestAbortClass or TestAbortAll after test_method() already failed, the class/run should still abort, but the failed test's termination_signal should remain the original exception from test_method().
Description
In hardware/device end-to-end testing, when a test case (
test_*) fails, for example, because the device under test crashed, disconnected, or became unresponsive, subsequent cleanup operations inteardown_test()(orteardown_class()) frequently fail when attempting to communicate with the unhealthy device.Currently,
BaseTestClass.exec_one_testinmobly/base_test.pyhas several issues in howteardown_test()interacts with an already-failed test:self.current_test_info.recorddoes not reflect the test failure duringteardown_test():In
exec_one_test(),self._teardown_test(test_name)is executed inside an innerfinally:block before the outerexceptblocks catch the exception raised bytest_method()and updatetr_record:Because
tr_recordis only populated after_teardown_test()finishes,teardown_test()implementations cannot inspectself.current_test_info.recordto determine if the test has already failed (forcing users to rely on fragile workarounds like inspectingsys.exc_info()andexpects.recorder.has_error).mobly.expectsfailures are overridden asERRORinstead ofFAILifteardown_test()fails:At the end of
exec_one_test():If
test_method()records a failure viaexpects.expect_true(False, ...)andteardown_test()subsequently raises an exception (or records anotherexpectfailure),teardown_test_failedisTrue. As a result,tr_record.test_fail()is skipped and the test is marked asERRORrather thanFAIL.Raising
TestAbortSignal(TestAbortClass/TestAbortAll) inteardown_test()overwrites the original test failure:Inside the
finally:block ofexec_one_test():If
test_method()raisesAssertionError/TestFailure(or another exception) andteardown_test()detects that the device is dead and raisesTestAbortClassorTestAbortAll, re-raisingTestAbortSignalinsidefinally:replaces the active exception fromtest_method(). The outerexcept signals.TestAbortSignal as e:then records the teardown abort signal astr_record.termination_signal, masking the root-cause failure fromtest_method().Steps to Reproduce
Expected Behavior
teardown_test():Catch and record the exception from
setup_test()/test_method()(and anyexpects.recordererrors) ontotr_recordbefore invokingself._teardown_test(test_name), so thatself.current_test_info.recordaccurately reflects the test's status andtermination_signalduringteardown_test().test_method()already failed (via exception ormobly.expects), any exception orTestAbortSignalraised inteardown_test()should be recorded intr_record.extra_errors['teardown_test']without overwritingtr_record.termination_signalor changing aFAILresult toERROR.teardown_test()raisesTestAbortClassorTestAbortAllaftertest_method()already failed, the class/run should still abort, but the failed test'stermination_signalshould remain the original exception fromtest_method().