From 50fa5305ce229345ee0568e2c9f1ae070fe43ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Fri, 19 Dec 2025 13:07:34 +0000 Subject: [PATCH] Support async setUp/tearDown validation with Deferreds Fix issue where setUp/tearDown validation failed when using AsynchronousDeferredRunTest with Deferred-based async upcalls. The validation logic now detects when setUp() or tearDown() returns a Deferred-like object (duck-typing via addBoth method) and defers the validation check until after the Deferred resolves. This allows patterns where super().setUp() or super().tearDown() is called asynchronously via callback chains. For synchronous cases, validation continues to happen immediately as before. Fixes #547 --- tests/twistedsupport/test_runtest.py | 78 ++++++++++++++++++++++++++++ testtools/testcase.py | 67 ++++++++++++++++++------ 2 files changed, 130 insertions(+), 15 deletions(-) diff --git a/tests/twistedsupport/test_runtest.py b/tests/twistedsupport/test_runtest.py index ab92a691..e9062947 100644 --- a/tests/twistedsupport/test_runtest.py +++ b/tests/twistedsupport/test_runtest.py @@ -1121,6 +1121,84 @@ def test_something(self): ) +class TestAsyncSetUpTearDownValidation(NeedsTwistedTestCase): + """Tests for async setUp/tearDown validation with Deferreds. + + This tests the fix for GitHub issue #547. + """ + + def test_async_setup_with_deferred_upcall(self): + # setUp that calls parent asynchronously via Deferred callback + # should work correctly with AsynchronousDeferredRunTest. + from twisted.internet import reactor + + class AsyncSetUpTest(TestCase): + run_tests_with = AsynchronousDeferredRunTest + + def setUp(self): + d = defer.Deferred() + d.addCallback(lambda ignored: super(AsyncSetUpTest, self).setUp()) + reactor.callLater(0.0, d.callback, None) + return d + + def test_something(self): + pass + + test = AsyncSetUpTest("test_something") + result = TestResult() + test.run(result) + # The test should pass - the async setUp should be validated correctly + self.assertTrue(result.wasSuccessful()) + + def test_async_teardown_with_deferred_upcall(self): + # tearDown that calls parent asynchronously via Deferred callback + # should work correctly with AsynchronousDeferredRunTest. + from twisted.internet import reactor + + class AsyncTearDownTest(TestCase): + run_tests_with = AsynchronousDeferredRunTest + + def tearDown(self): + d = defer.Deferred() + d.addCallback(lambda ignored: super(AsyncTearDownTest, self).tearDown()) + reactor.callLater(0.0, d.callback, None) + return d + + def test_something(self): + pass + + test = AsyncTearDownTest("test_something") + result = TestResult() + test.run(result) + # The test should pass - the async tearDown should be validated correctly + self.assertTrue(result.wasSuccessful()) + + def test_async_setup_missing_upcall_fails(self): + # setUp that returns a Deferred but doesn't call parent should fail. + from twisted.internet import reactor + + class BadAsyncSetUpTest(TestCase): + run_tests_with = AsynchronousDeferredRunTest + + def setUp(self): + # Returns a Deferred but doesn't call super().setUp() + d = defer.Deferred() + reactor.callLater(0.0, d.callback, None) + return d + + def test_something(self): + pass + + test = BadAsyncSetUpTest("test_something") + result = TestResult() + test.run(result) + # The test should fail with a validation error + self.assertFalse(result.wasSuccessful()) + self.assertEqual(len(result.errors), 1) + error_text = str(result.errors[0][1]) + self.assertIn("TestCase.setUp was not called", error_text) + + def test_suite(): from unittest import TestLoader diff --git a/testtools/testcase.py b/testtools/testcase.py index 64724e7a..be335a71 100644 --- a/testtools/testcase.py +++ b/testtools/testcase.py @@ -694,13 +694,32 @@ def _run_setup(self, result): ValueError is raised. """ ret = self.setUp() - if not self.__setup_called: - raise ValueError( - f"In File: {sys.modules[self.__class__.__module__].__file__}\n" - "TestCase.setUp was not called. Have you upcalled all the " - "way up the hierarchy from your setUp? e.g. Call " - f"super({self.__class__.__name__}, self).setUp() from your setUp()." - ) + + # Check if the return value is a Deferred (duck-typing to avoid hard dependency) + if hasattr(ret, "addBoth") and callable(getattr(ret, "addBoth")): + # Deferred-like object: validate asynchronously after it resolves + def _validate_setup_called(result): + if not self.__setup_called: + raise ValueError( + f"In File: {sys.modules[self.__class__.__module__].__file__}\n" + "TestCase.setUp was not called. Have you upcalled all the " + "way up the hierarchy from your setUp? e.g. Call " + f"super({self.__class__.__name__}, self).setUp() " + "from your setUp()." + ) + return result + + ret.addBoth(_validate_setup_called) + else: + # Synchronous: validate immediately + if not self.__setup_called: + raise ValueError( + f"In File: {sys.modules[self.__class__.__module__].__file__}\n" + "TestCase.setUp was not called. Have you upcalled all the " + "way up the hierarchy from your setUp? e.g. Call " + f"super({self.__class__.__name__}, self).setUp() " + "from your setUp()." + ) return ret def _run_teardown(self, result): @@ -711,14 +730,32 @@ def _run_teardown(self, result): ValueError is raised. """ ret = self.tearDown() - if not self.__teardown_called: - raise ValueError( - f"In File: {sys.modules[self.__class__.__module__].__file__}\n" - "TestCase.tearDown was not called. Have you upcalled all the " - "way up the hierarchy from your tearDown? e.g. Call " - f"super({self.__class__.__name__}, self).tearDown() " - "from your tearDown()." - ) + + # Check if the return value is a Deferred (duck-typing to avoid hard dependency) + if hasattr(ret, "addBoth") and callable(getattr(ret, "addBoth")): + # Deferred-like object: validate asynchronously after it resolves + def _validate_teardown_called(result): + if not self.__teardown_called: + raise ValueError( + f"In File: {sys.modules[self.__class__.__module__].__file__}\n" + "TestCase.tearDown was not called. Have you upcalled all the " + "way up the hierarchy from your tearDown? e.g. Call " + f"super({self.__class__.__name__}, self).tearDown() " + "from your tearDown()." + ) + return result + + ret.addBoth(_validate_teardown_called) + else: + # Synchronous: validate immediately + if not self.__teardown_called: + raise ValueError( + f"In File: {sys.modules[self.__class__.__module__].__file__}\n" + "TestCase.tearDown was not called. Have you upcalled all the " + "way up the hierarchy from your tearDown? e.g. Call " + f"super({self.__class__.__name__}, self).tearDown() " + "from your tearDown()." + ) return ret def _get_test_method(self):