From e9abb3eff113e0dde516f19b7c68bbdfdd0f4ddf Mon Sep 17 00:00:00 2001 From: deathaxe Date: Thu, 28 May 2026 16:32:12 +0200 Subject: [PATCH 1/4] Add support for async test coroutines --- README.md | 61 ++++++++++++++--- dependencies.json | 6 +- tests/_Asyncio/tests/test_coroutine.py | 27 +++++--- tests/_Asyncio/unittesting.json | 2 +- unittesting/__init__.py | 6 +- unittesting/core/__init__.py | 8 +-- unittesting/core/py313/case.py | 89 ++++++++++++++----------- unittesting/core/py33/case.py | 12 ++-- unittesting/core/py38/case.py | 90 +++++++++++++++----------- unittesting/helpers/__init__.py | 2 + unittesting/helpers/view_test_case.py | 37 +++++++++++ 11 files changed, 233 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 5739e843..2ed14a02 100644 --- a/README.md +++ b/README.md @@ -562,30 +562,73 @@ see also [tests/test_defer.py](https://github.com/randy3k/UnitTesting-example/bl ### Asyncio testing -Tests for `asyncio` are written using `IsolatedAsyncioTestCase` class. +Tests for `asyncio` use `AsyncTestCase` or `AsyncViewTestCase` class. + +It auto-detects type of `setUp()`, `tearDown()`, and `test_..()` methods. +Those can be synchronous methods or async coroutine functions. + +Asynchronous coroutine functions are executed in default event loop, +provided by [sublime_aio][]. ```py import asyncio +import sublime + +from unittesting import AsyncTestCase + + +async def async_coroutine(view): + + def run_in_mainthread(): + view.run_command("select_all") + view.run_command("right_delete") + view.run_command("insert", {"characters": "Modified Content"}) -from unittesting import IsolatedAsyncioTestCase + sublime.set_timeout(run_in_mainthread, 10) + await asyncio.sleep(2.0) -async def a_coro(): - return 1 + 1 -class MyAsyncTestCase(IsolatedAsyncioTestCase): - async def test_something(self): - result = await a_coro() - await asyncio.sleep(1) - self.assertEqual(result, 2) +class MyAsyncTestCase(AsyncTestCase): + + async def setUp(self): + self.view = sublime.active_window().new_file() + self.view.set_scratch(True) + self.view.run_command("insert", {"characters": "Initial Content"}) + + async def tearDown(self): + self.view.close() + + async def test_setup_completed(self): + self.assertEqual( + self.view.substr(sublime.Region(0, self.view.size())), + "Initial Content" + ) + + async def test_coroutine(self): + await async_coroutine(self.view) + self.assertEqual( + self.view.substr(sublime.Region(0, self.view.size())), + "Modified Content" + ) ``` +> [!WARNING] +> +> Do not use `unittest.IsolatedAsyncioTestCase` class, +> as it spins up a blocking event loop in main thread, +> which prevents any synchronous command from being executed +> by Sublime Text. + +[sublime_aio]: https://github.com/packagecontrol/sublime_aio + ## Helper TestCases UnitTesting provides some helper test case classes, which perform common tasks such as overriding preferences, setting up views, etc. +- AsyncViewTestCase - DeferrableViewTestCase - OverridePreferencesTestCase - TempDirectoryTestCase diff --git a/dependencies.json b/dependencies.json index f3e5331b..fa0af0be 100644 --- a/dependencies.json +++ b/dependencies.json @@ -1,7 +1,11 @@ { "*": { - ">3000": [ + "3000 - 3999": [ "coverage" + ], + ">4000": [ + "coverage", + "sublime_aio" ] } } diff --git a/tests/_Asyncio/tests/test_coroutine.py b/tests/_Asyncio/tests/test_coroutine.py index e26d2b16..e1b17b1b 100644 --- a/tests/_Asyncio/tests/test_coroutine.py +++ b/tests/_Asyncio/tests/test_coroutine.py @@ -1,15 +1,24 @@ import asyncio -from unittest import skipIf -from unittesting import IsolatedAsyncioTestCase +from unittesting import AsyncViewTestCase -async def a_coro(): - return 1 + 1 +async def a_coro(test: MyAsyncTestCase): + await asyncio.sleep(1.0) + test.setText("Modified Content") -class MyAsyncTestCase(IsolatedAsyncioTestCase): - async def test_something(self): - result = await a_coro() - await asyncio.sleep(1) - self.assertEqual(result, 2) +class MyAsyncTestCase(AsyncViewTestCase): + + async def setUp(self): + self.setText("Initial Content") + + async def tearDown(self): + self.setText("") + + async def test_setup_completed(self): + self.assertViewContentsEqual("Initial Content") + + async def test_coroutine(self): + await a_coro(self) + self.assertViewContentsEqual("Modified Content") diff --git a/tests/_Asyncio/unittesting.json b/tests/_Asyncio/unittesting.json index 8968d3ef..5fcf8eb9 100644 --- a/tests/_Asyncio/unittesting.json +++ b/tests/_Asyncio/unittesting.json @@ -1,3 +1,3 @@ { - "deferred": false + "deferred": true } diff --git a/unittesting/__init__.py b/unittesting/__init__.py index dfd33448..f0959e1d 100644 --- a/unittesting/__init__.py +++ b/unittesting/__init__.py @@ -1,9 +1,10 @@ from .core import AWAIT_WORKER +from .core import AsyncTestCase from .core import DeferrableMethod from .core import DeferrableTestCase -from .core import IsolatedAsyncioTestCase from .core import TestCase from .core import expectedFailure +from .helpers import AsyncViewTestCase from .helpers import DeferrableViewTestCase from .helpers import OverridePreferencesTestCase from .helpers import TempDirectoryTestCase @@ -12,12 +13,13 @@ __all__ = [ + "AsyncTestCase", + "AsyncViewTestCase", "AWAIT_WORKER", "DeferrableMethod", "DeferrableTestCase", "DeferrableViewTestCase", "expectedFailure", - "IsolatedAsyncioTestCase", "OverridePreferencesTestCase", "run_scheduler", "TempDirectoryTestCase", diff --git a/unittesting/core/__init__.py b/unittesting/core/__init__.py index f54d72a1..6dedbafc 100644 --- a/unittesting/core/__init__.py +++ b/unittesting/core/__init__.py @@ -1,9 +1,9 @@ import sys if sys.version_info >= (3, 13): + from .py313.case import AsyncTestCase from .py313.case import DeferrableMethod from .py313.case import DeferrableTestCase - from .py313.case import IsolatedAsyncioTestCase from .py313.case import TestCase from .py313.case import expectedFailure from .py313.loader import DeferrableTestLoader @@ -11,9 +11,9 @@ from .py313.runner import DeferringTextTestRunner from .py313.suite import DeferrableTestSuite elif sys.version_info >= (3, 8): + from .py38.case import AsyncTestCase from .py38.case import DeferrableMethod from .py38.case import DeferrableTestCase - from .py38.case import IsolatedAsyncioTestCase from .py38.case import TestCase from .py38.case import expectedFailure from .py38.loader import DeferrableTestLoader @@ -21,9 +21,9 @@ from .py38.runner import DeferringTextTestRunner from .py38.suite import DeferrableTestSuite elif sys.version_info >= (3, 3): + from .py33.case import AsyncTestCase from .py33.case import DeferrableMethod from .py33.case import DeferrableTestCase - from .py33.case import IsolatedAsyncioTestCase from .py33.case import TestCase from .py33.case import expectedFailure from .py33.loader import DeferrableTestLoader @@ -34,13 +34,13 @@ raise ImportError("Unsupported python runtime!") __all__ = [ + "AsyncTestCase", "AWAIT_WORKER", "DeferrableMethod", "DeferrableTestCase", "DeferrableTestLoader", "DeferrableTestSuite", "DeferringTextTestRunner", - "IsolatedAsyncioTestCase", "TestCase", "expectedFailure", ] diff --git a/unittesting/core/py313/case.py b/unittesting/core/py313/case.py index 1d3dfc1d..926861e5 100644 --- a/unittesting/core/py313/case.py +++ b/unittesting/core/py313/case.py @@ -1,8 +1,10 @@ +import inspect import time import sys +import sublime_aio + from collections.abc import Generator as DeferrableMethod -from unittest import IsolatedAsyncioTestCase from unittest import TestCase from unittest.case import _addSkip from unittest.case import _Outcome @@ -11,9 +13,9 @@ from .runner import defer __all__ = [ + "AsyncTestCase", "DeferrableMethod", "DeferrableTestCase", - "IsolatedAsyncioTestCase", "TestCase", "expectedFailure", ] @@ -22,24 +24,26 @@ class DeferrableTestCase(TestCase): def _callSetUp(self): - deferred = self.setUp() - if isinstance(deferred, DeferrableMethod): - yield from deferred + return self._callMaybeCoro(self.setUp) def _callTestMethod(self, method): - deferred = method() - if isinstance(deferred, DeferrableMethod): - yield from deferred + return self._callMaybeCoro(method) def _callTearDown(self): - deferred = self.tearDown() - if isinstance(deferred, DeferrableMethod): - yield from deferred + return self._callMaybeCoro(self.tearDown) def _callCleanup(self, function, *args, **kwargs): - deferred = function(*args, **kwargs) - if isinstance(deferred, DeferrableMethod): - yield from deferred + return self._callMaybeCoro(function, *args, **kwargs) + + def _callMaybeCoro(self, func, /, *args, **kwargs): + coro = func(*args, **kwargs) + if isinstance(coro, DeferrableMethod): + yield from coro + elif inspect.iscoroutine(coro): + raise TypeError( + f"Async coroutine function {self.__class__.__name__}.{func.__name__}() " + "is not supported by DeferrableTestCase! Use AsyncTestCase instead!" + ) @staticmethod def defer(delay, callback, *args, **kwargs): @@ -76,24 +80,15 @@ def run(self, result=None): self._outcome = outcome with outcome.testPartExecutor(self): - deferred = self._callSetUp() - if isinstance(deferred, DeferrableMethod): - yield from deferred + yield from self._callSetUp() if outcome.success: outcome.expecting_failure = expecting_failure with outcome.testPartExecutor(self): - deferred = self._callTestMethod(testMethod) - if isinstance(deferred, DeferrableMethod): - yield from deferred + yield from self._callTestMethod(testMethod) outcome.expecting_failure = False with outcome.testPartExecutor(self): - deferred = self._callTearDown() - if isinstance(deferred, DeferrableMethod): - yield from deferred - deferred = self.doCleanups() - if isinstance(deferred, DeferrableMethod): - yield from deferred - + yield from self._callTearDown() + yield from self.doCleanups() self._addDuration(result, (time.perf_counter() - start_time)) if outcome.success: @@ -125,9 +120,7 @@ def doCleanups(self): while self._cleanups: function, args, kwargs = self._cleanups.pop() with outcome.testPartExecutor(self): - deferred = self._callCleanup(function, *args, **kwargs) - if isinstance(deferred, DeferrableMethod): - yield from deferred + yield from self._callCleanup(function, *args, **kwargs) # return this for backwards compatibility # even though we no longer use it internally @@ -140,15 +133,35 @@ def doClassCleanups(cls): while cls._class_cleanups: function, args, kwargs = cls._class_cleanups.pop() try: - deferred = function(*args, **kwargs) - if isinstance(deferred, DeferrableMethod): - yield from deferred + yield from cls._callMaybeCoro(function, *args, **kwargs) except Exception: cls.tearDown_exceptions.append(sys.exc_info()) def __call__(self, *args, **kwds): - deferred = self.run(*args, **kwds) - if isinstance(deferred, DeferrableMethod): - yield from deferred - else: - return deferred + yield from self.run(*args, **kwds) + + +class AsyncTestCase(DeferrableTestCase): + + async def setUp(self): + pass + + async def tearDown(self): + pass + + def _callMaybeCoro(self, func, /, *args, **kwargs): + coro = func(*args, **kwargs) + if isinstance(coro, DeferrableMethod): + yield from coro + elif inspect.iscoroutine(coro): + fut = sublime_aio.run_coroutine(coro) + + def wait_until_complete(): + if not fut.done() and not fut.cancelled(): + return False + exception = fut.exception() + if exception is not None: + raise exception from None + return True + + yield wait_until_complete diff --git a/unittesting/core/py33/case.py b/unittesting/core/py33/case.py index f55555cd..f273df9b 100644 --- a/unittesting/core/py33/case.py +++ b/unittesting/core/py33/case.py @@ -13,9 +13,9 @@ from .runner import defer __all__ = [ + "AsyncTestCase", "DeferrableMethod", "DeferrableTestCase", - "IsolatedAsyncioTestCase", "TestCase", "expectedFailure", ] @@ -35,11 +35,6 @@ def wrapper(*args, **kwargs): return wrapper -class IsolatedAsyncioTestCase: - def __init__(self, *args, **kwargs): - raise RuntimeError("Asyncio not supported by python 3.3!") - - class DeferrableTestCase(TestCase): def _executeTestPart(self, function, outcome, isTest=False): @@ -162,3 +157,8 @@ def doCleanups(self): # return this for backwards compatibility # even though we no longer us it internally return outcome.success + + +class AsyncTestCase(DeferrableTestCase): + def __init__(self, *args, **kwargs): + raise RuntimeError("Asyncio not supported by python 3.3!") diff --git a/unittesting/core/py38/case.py b/unittesting/core/py38/case.py index 47c02577..3ebca530 100644 --- a/unittesting/core/py38/case.py +++ b/unittesting/core/py38/case.py @@ -1,17 +1,20 @@ +import inspect +import time import sys +import sublime_aio + from collections.abc import Generator as DeferrableMethod from unittest import TestCase -from unittest import IsolatedAsyncioTestCase from unittest.case import _Outcome from unittest.case import expectedFailure from .runner import defer __all__ = [ + "AsyncTestCase", "DeferrableMethod", "DeferrableTestCase", - "IsolatedAsyncioTestCase", "TestCase", "expectedFailure", ] @@ -20,24 +23,26 @@ class DeferrableTestCase(TestCase): def _callSetUp(self): - deferred = self.setUp() - if isinstance(deferred, DeferrableMethod): - yield from deferred + return self._callMaybeCoro(self.setUp) def _callTestMethod(self, method): - deferred = method() - if isinstance(deferred, DeferrableMethod): - yield from deferred + return self._callMaybeCoro(method) def _callTearDown(self): - deferred = self.tearDown() - if isinstance(deferred, DeferrableMethod): - yield from deferred + return self._callMaybeCoro(self.tearDown) def _callCleanup(self, function, *args, **kwargs): - deferred = function(*args, **kwargs) - if isinstance(deferred, DeferrableMethod): - yield from deferred + return self._callMaybeCoro(function, *args, **kwargs) + + def _callMaybeCoro(self, func, /, *args, **kwargs): + coro = func(*args, **kwargs) + if isinstance(coro, DeferrableMethod): + yield from coro + elif inspect.iscoroutine(coro): + raise TypeError( + f"Async coroutine function {self.__class__.__name__}.{func.__name__}() " + "is not supported by DeferrableTestCase! Use AsyncTestCase instead!" + ) @staticmethod def defer(delay, callback, *args, **kwargs): @@ -76,27 +81,20 @@ def run(self, result=None): self._outcome = outcome with outcome.testPartExecutor(self): - deferred = self._callSetUp() - if isinstance(deferred, DeferrableMethod): - yield from deferred + yield from self._callSetUp() if outcome.success: outcome.expecting_failure = expecting_failure with outcome.testPartExecutor(self, isTest=True): - deferred = self._callTestMethod(testMethod) - if isinstance(deferred, DeferrableMethod): - yield from deferred + yield from self._callTestMethod(testMethod) outcome.expecting_failure = False with outcome.testPartExecutor(self): - deferred = self._callTearDown() - if isinstance(deferred, DeferrableMethod): - yield from deferred + yield from self._callTearDown() + yield from self.doCleanups() - deferred = self.doCleanups() - if isinstance(deferred, DeferrableMethod): - yield from deferred for test, reason in outcome.skipped: self._addSkip(result, test, reason) self._feedErrorsToResult(result, outcome.errors) + if outcome.success: if expecting_failure: if outcome.expectedFailure: @@ -128,9 +126,7 @@ def doCleanups(self): while self._cleanups: function, args, kwargs = self._cleanups.pop() with outcome.testPartExecutor(self): - deferred = self._callCleanup(function, *args, **kwargs) - if isinstance(deferred, DeferrableMethod): - yield from deferred + yield from self._callCleanup(function, *args, **kwargs) # return this for backwards compatibility # even though we no longer use it internally @@ -143,15 +139,35 @@ def doClassCleanups(cls): while cls._class_cleanups: function, args, kwargs = cls._class_cleanups.pop() try: - deferred = function(*args, **kwargs) - if isinstance(deferred, DeferrableMethod): - yield from deferred + yield from cls._callMaybeCoro(function, *args, **kwargs) except Exception: cls.tearDown_exceptions.append(sys.exc_info()) def __call__(self, *args, **kwds): - deferred = self.run(*args, **kwds) - if isinstance(deferred, DeferrableMethod): - yield from deferred - else: - return deferred + yield from self.run(*args, **kwds) + + +class AsyncTestCase(DeferrableTestCase): + + async def setUp(self): + pass + + async def tearDown(self): + pass + + def _callMaybeCoro(self, func, /, *args, **kwargs): + coro = func(*args, **kwargs) + if isinstance(coro, DeferrableMethod): + yield from coro + elif inspect.iscoroutine(coro): + fut = sublime_aio.run_coroutine(coro) + + def wait_until_complete(): + if not fut.done() and not fut.cancelled(): + return False + exception = fut.exception() + if exception is not None: + raise exception from None + return True + + yield wait_until_complete diff --git a/unittesting/helpers/__init__.py b/unittesting/helpers/__init__.py index fcc85e84..3d2c2974 100644 --- a/unittesting/helpers/__init__.py +++ b/unittesting/helpers/__init__.py @@ -1,10 +1,12 @@ # noqa: F401 from .override_preferences_test_case import OverridePreferencesTestCase from .temp_directory_test_case import TempDirectoryTestCase +from .view_test_case import AsyncViewTestCase from .view_test_case import DeferrableViewTestCase from .view_test_case import ViewTestCase __all__ = [ + "AsyncViewTestCase", "DeferrableViewTestCase", "OverridePreferencesTestCase", "TempDirectoryTestCase", diff --git a/unittesting/helpers/view_test_case.py b/unittesting/helpers/view_test_case.py index 413c0953..f4056c14 100644 --- a/unittesting/helpers/view_test_case.py +++ b/unittesting/helpers/view_test_case.py @@ -2,9 +2,11 @@ import sys from unittest import TestCase +from ..core import AsyncTestCase from ..core import DeferrableTestCase __all__ = [ + "AsyncViewTestCase", "DeferrableViewTestCase", "ViewTestCase", ] @@ -252,3 +254,38 @@ def test_editing(self): """ pass + + +class AsyncViewTestCase(ViewTestCaseMixin, AsyncTestCase): + """ + This class describes an asynchronous view test case. + + This class provides infrastructure to run unit tests on dedicated ``sublime.View()`` objects, + which includes catching asynchronous events. + + A new ``view`` object is created within the active ``window`` for each ``ViewTestCase``. + + The view is accessible via ``self.view`` from within each test method. + + The owning window can be accessed via ``self.window``. + + ```py + class MyTestCase(DeferrableViewTestCase): + # settings to apply to the created view + view_settings = { + "detect_indentation": False, + "tab_size": 4, + "translate_tabs_to_spaces": False, + "word_wrap": False, + } + + async def test_editing(self): + self.setText("foo") + self.setCaretTo(0, 0) + self.defer(100, self.insertText, "foo") + await asyncio.sleep(0.2) + self.assertRowContentsEqual(0, "foofoo") + ``` + """ + + pass From 48dbfcab1a79dc6e3a069a575f5b89831e59f732 Mon Sep 17 00:00:00 2001 From: deathaxe Date: Thu, 28 May 2026 22:43:51 +0200 Subject: [PATCH 2/4] Sort ViewTestCase implementations --- unittesting/helpers/view_test_case.py | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/unittesting/helpers/view_test_case.py b/unittesting/helpers/view_test_case.py index f4056c14..d0dc9ad0 100644 --- a/unittesting/helpers/view_test_case.py +++ b/unittesting/helpers/view_test_case.py @@ -188,20 +188,21 @@ def assertViewContentsEqual(self, text): self.assertEqual(self.getText(), text) -class ViewTestCase(ViewTestCaseMixin, TestCase): +class AsyncViewTestCase(ViewTestCaseMixin, AsyncTestCase): """ - This class describes a view test case. + This class describes an asynchronous view test case. - This class provides infrastructure to run unit tests on dedicated ``sublime.View()`` objects. + This class provides infrastructure to run unit tests on dedicated ``sublime.View()`` objects, + which includes catching asynchronous events. - A new ``view`` object is created within the active ``window`` for each ``ViewTestCase``. + A new ``view`` object is created within the active ``window`` for each ``AsyncViewTestCase``. The view is accessible via ``self.view`` from within each test method. The owning window can be accessed via ``self.window``. ```py - class MyTestCase(ViewTestCase): + class MyTestCase(AsyncViewTestCase): # settings to apply to the created view view_settings = { "detect_indentation": False, @@ -210,10 +211,11 @@ class MyTestCase(ViewTestCase): "word_wrap": False, } - def test_editing(self): + async def test_editing(self): self.setText("foo") self.setCaretTo(0, 0) - self.setText("foo") + self.defer(100, self.insertText, "foo") + await asyncio.sleep(0.2) self.assertRowContentsEqual(0, "foofoo") ``` """ @@ -228,7 +230,7 @@ class DeferrableViewTestCase(ViewTestCaseMixin, DeferrableTestCase): This class provides infrastructure to run unit tests on dedicated ``sublime.View()`` objects, which includes catching asynchronous events. - A new ``view`` object is created within the active ``window`` for each ``ViewTestCase``. + A new ``view`` object is created within the active ``window`` for each ``DeferrableViewTestCase``. The view is accessible via ``self.view`` from within each test method. @@ -256,12 +258,11 @@ def test_editing(self): pass -class AsyncViewTestCase(ViewTestCaseMixin, AsyncTestCase): +class ViewTestCase(ViewTestCaseMixin, TestCase): """ - This class describes an asynchronous view test case. + This class describes a view test case. - This class provides infrastructure to run unit tests on dedicated ``sublime.View()`` objects, - which includes catching asynchronous events. + This class provides infrastructure to run unit tests on dedicated ``sublime.View()`` objects. A new ``view`` object is created within the active ``window`` for each ``ViewTestCase``. @@ -270,7 +271,7 @@ class AsyncViewTestCase(ViewTestCaseMixin, AsyncTestCase): The owning window can be accessed via ``self.window``. ```py - class MyTestCase(DeferrableViewTestCase): + class MyTestCase(ViewTestCase): # settings to apply to the created view view_settings = { "detect_indentation": False, @@ -279,11 +280,10 @@ class MyTestCase(DeferrableViewTestCase): "word_wrap": False, } - async def test_editing(self): + def test_editing(self): self.setText("foo") self.setCaretTo(0, 0) - self.defer(100, self.insertText, "foo") - await asyncio.sleep(0.2) + self.setText("foo") self.assertRowContentsEqual(0, "foofoo") ``` """ From 3aefb798fb99074e56936a4e3f59498eb75af412 Mon Sep 17 00:00:00 2001 From: deathaxe Date: Thu, 28 May 2026 22:49:44 +0200 Subject: [PATCH 3/4] Remove typing --- tests/_Asyncio/tests/test_coroutine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/_Asyncio/tests/test_coroutine.py b/tests/_Asyncio/tests/test_coroutine.py index e1b17b1b..f78d7cbf 100644 --- a/tests/_Asyncio/tests/test_coroutine.py +++ b/tests/_Asyncio/tests/test_coroutine.py @@ -3,7 +3,7 @@ from unittesting import AsyncViewTestCase -async def a_coro(test: MyAsyncTestCase): +async def a_coro(test): await asyncio.sleep(1.0) test.setText("Modified Content") From acbd9e66e115aeafa5b5a6d378b71c6189c42243 Mon Sep 17 00:00:00 2001 From: deathaxe Date: Sun, 31 May 2026 17:55:26 +0200 Subject: [PATCH 4/4] Add support for class-level setUpClass() and tearDownClass() coroutines --- README.md | 23 ++++++++++ tests/_Asyncio/tests/test_coroutine.py | 22 +++++++++- tests/_Deferred/tests/test.py | 10 +++++ unittesting/core/py313/case.py | 60 ++++++++++++++++---------- unittesting/core/py313/suite.py | 8 +++- unittesting/core/py38/case.py | 60 ++++++++++++++++---------- unittesting/core/py38/suite.py | 8 +++- 7 files changed, 142 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 2ed14a02..a0a08e89 100644 --- a/README.md +++ b/README.md @@ -591,6 +591,14 @@ async def async_coroutine(view): class MyAsyncTestCase(AsyncTestCase): + @classmethod + async def setUpClass(cls): + pass + + @classmethod + async def tearDownClass(cls): + pass + async def setUp(self): self.view = sublime.active_window().new_file() self.view.set_scratch(True) @@ -613,6 +621,21 @@ class MyAsyncTestCase(AsyncTestCase): ) ``` +To run coroutines in a custom event loop, override static `run_override()` method. + +```py +import sublime_aio +from unittesting import AsyncTestCase + + +class MyAsyncTestCase(AsyncTestCase): + + def run_coroutine(coro: abc.meta.Coroutine) -> cuncurrent.futures.Future: + return sublime_aio.run_coroutine(coro) +``` + +Note, asyncio event loops must not block Sublime Text's main thread. + > [!WARNING] > > Do not use `unittest.IsolatedAsyncioTestCase` class, diff --git a/tests/_Asyncio/tests/test_coroutine.py b/tests/_Asyncio/tests/test_coroutine.py index f78d7cbf..dff200f3 100644 --- a/tests/_Asyncio/tests/test_coroutine.py +++ b/tests/_Asyncio/tests/test_coroutine.py @@ -8,7 +8,18 @@ async def a_coro(test): test.setText("Modified Content") -class MyAsyncTestCase(AsyncViewTestCase): +class MyAsyncTestCaseA(AsyncViewTestCase): + + test_class_initiated = 0 + + @classmethod + async def setUpClass(cls): + assert cls.test_class_initiated == 0 + cls.test_class_initiated = 1 + + @classmethod + async def tearDownClass(cls): + cls.test_class_initiated = 2 async def setUp(self): self.setText("Initial Content") @@ -16,9 +27,18 @@ async def setUp(self): async def tearDown(self): self.setText("") + async def test_class_setup_completed(self): + self.assertEqual(self.test_class_initiated, 1) + async def test_setup_completed(self): self.assertViewContentsEqual("Initial Content") async def test_coroutine(self): await a_coro(self) self.assertViewContentsEqual("Modified Content") + + +class MyAsyncTestCaseB(AsyncViewTestCase): + + async def test_class_setup_completed(self): + self.assertEqual(MyAsyncTestCaseA.test_class_initiated, 2) diff --git a/tests/_Deferred/tests/test.py b/tests/_Deferred/tests/test.py index 2bc45dae..03d8e527 100644 --- a/tests/_Deferred/tests/test.py +++ b/tests/_Deferred/tests/test.py @@ -1,9 +1,19 @@ +from unittesting import TestCase from unittesting import DeferrableViewTestCase from unittesting import expectedFailure +class TestDefaultTestCase(TestCase): + + def test_simple_assert(self): + self.assertTrue(True) + + class TestDeferrable(DeferrableViewTestCase): + def test_simple_assert(self): + self.assertTrue(True) + def test_defer(self): self.setText("foo") self.setCaretTo(0, 0) diff --git a/unittesting/core/py313/case.py b/unittesting/core/py313/case.py index 926861e5..cee605a3 100644 --- a/unittesting/core/py313/case.py +++ b/unittesting/core/py313/case.py @@ -32,18 +32,39 @@ def _callTestMethod(self, method): def _callTearDown(self): return self._callMaybeCoro(self.tearDown) - def _callCleanup(self, function, *args, **kwargs): - return self._callMaybeCoro(function, *args, **kwargs) + @classmethod + def _callSetUpClass(cls): + return cls._callMaybeCoro(cls.setUpClass) + + @classmethod + def _callTearDownClass(cls): + return cls._callMaybeCoro(cls.tearDownClass) - def _callMaybeCoro(self, func, /, *args, **kwargs): + @classmethod + def _callMaybeCoro(cls, func, /, *args, **kwargs): coro = func(*args, **kwargs) if isinstance(coro, DeferrableMethod): yield from coro elif inspect.iscoroutine(coro): - raise TypeError( - f"Async coroutine function {self.__class__.__name__}.{func.__name__}() " - "is not supported by DeferrableTestCase! Use AsyncTestCase instead!" - ) + fut = cls.run_coroutine(coro) + + def wait_until_complete(): + if not fut.done() and not fut.cancelled(): + return False + exception = fut.exception() + if exception is not None: + raise exception from None + return True + + yield wait_until_complete + + @staticmethod + def run_coroutine(coro): + """Run an asyncio coroutine and return a `Future` to wait for.""" + raise TypeError( + f"Async coroutine {coro!r} is not supported by DeferrableTestCase!" + " Use AsyncTestCase instead!" + ) @staticmethod def defer(delay, callback, *args, **kwargs): @@ -120,7 +141,7 @@ def doCleanups(self): while self._cleanups: function, args, kwargs = self._cleanups.pop() with outcome.testPartExecutor(self): - yield from self._callCleanup(function, *args, **kwargs) + yield from self._callMaybeCoro(function, *args, **kwargs) # return this for backwards compatibility # even though we no longer use it internally @@ -149,19 +170,14 @@ async def setUp(self): async def tearDown(self): pass - def _callMaybeCoro(self, func, /, *args, **kwargs): - coro = func(*args, **kwargs) - if isinstance(coro, DeferrableMethod): - yield from coro - elif inspect.iscoroutine(coro): - fut = sublime_aio.run_coroutine(coro) + @classmethod + async def setUpClass(cls): + pass - def wait_until_complete(): - if not fut.done() and not fut.cancelled(): - return False - exception = fut.exception() - if exception is not None: - raise exception from None - return True + @classmethod + async def tearDownClass(cls): + pass - yield wait_until_complete + @staticmethod + def run_coroutine(coro): + return sublime_aio.run_coroutine(coro) diff --git a/unittesting/core/py313/suite.py b/unittesting/core/py313/suite.py index 690d8ae0..afd3b692 100644 --- a/unittesting/core/py313/suite.py +++ b/unittesting/core/py313/suite.py @@ -76,7 +76,9 @@ def _handleClassSetUp(self, test, result): # so its class will be a builtin-type pass - setUpClass = getattr(currentClass, "setUpClass", None) + setUpClass = getattr(currentClass, "_callSetUpClass", None) + if setUpClass is None: + setUpClass = getattr(currentClass, "setUpClass", None) if setUpClass is not None: _call_if_exists(result, "_setupStdout") try: @@ -115,7 +117,9 @@ def _tearDownPreviousClass(self, test, result): if getattr(previousClass, "__unittest_skip__", False): return - tearDownClass = getattr(previousClass, "tearDownClass", None) + tearDownClass = getattr(previousClass, "_callTearDownClass", None) + if tearDownClass is None: + tearDownClass = getattr(previousClass, "tearDownClass", None) if tearDownClass is not None: _call_if_exists(result, "_setupStdout") try: diff --git a/unittesting/core/py38/case.py b/unittesting/core/py38/case.py index 3ebca530..e004f943 100644 --- a/unittesting/core/py38/case.py +++ b/unittesting/core/py38/case.py @@ -31,18 +31,39 @@ def _callTestMethod(self, method): def _callTearDown(self): return self._callMaybeCoro(self.tearDown) - def _callCleanup(self, function, *args, **kwargs): - return self._callMaybeCoro(function, *args, **kwargs) + @classmethod + def _callSetUpClass(cls): + return cls._callMaybeCoro(cls.setUpClass) + + @classmethod + def _callTearDownClass(cls): + return cls._callMaybeCoro(cls.tearDownClass) - def _callMaybeCoro(self, func, /, *args, **kwargs): + @classmethod + def _callMaybeCoro(cls, func, /, *args, **kwargs): coro = func(*args, **kwargs) if isinstance(coro, DeferrableMethod): yield from coro elif inspect.iscoroutine(coro): - raise TypeError( - f"Async coroutine function {self.__class__.__name__}.{func.__name__}() " - "is not supported by DeferrableTestCase! Use AsyncTestCase instead!" - ) + fut = cls.run_coroutine(coro) + + def wait_until_complete(): + if not fut.done() and not fut.cancelled(): + return False + exception = fut.exception() + if exception is not None: + raise exception from None + return True + + yield wait_until_complete + + @staticmethod + def run_coroutine(coro): + """Run an asyncio coroutine and return a `Future` to wait for.""" + raise TypeError( + f"Async coroutine {coro!r} is not supported by DeferrableTestCase!" + " Use AsyncTestCase instead!" + ) @staticmethod def defer(delay, callback, *args, **kwargs): @@ -126,7 +147,7 @@ def doCleanups(self): while self._cleanups: function, args, kwargs = self._cleanups.pop() with outcome.testPartExecutor(self): - yield from self._callCleanup(function, *args, **kwargs) + yield from self._callMaybeCoro(function, *args, **kwargs) # return this for backwards compatibility # even though we no longer use it internally @@ -155,19 +176,14 @@ async def setUp(self): async def tearDown(self): pass - def _callMaybeCoro(self, func, /, *args, **kwargs): - coro = func(*args, **kwargs) - if isinstance(coro, DeferrableMethod): - yield from coro - elif inspect.iscoroutine(coro): - fut = sublime_aio.run_coroutine(coro) + @classmethod + async def setUpClass(cls): + pass - def wait_until_complete(): - if not fut.done() and not fut.cancelled(): - return False - exception = fut.exception() - if exception is not None: - raise exception from None - return True + @classmethod + async def tearDownClass(cls): + pass - yield wait_until_complete + @staticmethod + def run_coroutine(coro): + return sublime_aio.run_coroutine(coro) diff --git a/unittesting/core/py38/suite.py b/unittesting/core/py38/suite.py index 690d8ae0..afd3b692 100644 --- a/unittesting/core/py38/suite.py +++ b/unittesting/core/py38/suite.py @@ -76,7 +76,9 @@ def _handleClassSetUp(self, test, result): # so its class will be a builtin-type pass - setUpClass = getattr(currentClass, "setUpClass", None) + setUpClass = getattr(currentClass, "_callSetUpClass", None) + if setUpClass is None: + setUpClass = getattr(currentClass, "setUpClass", None) if setUpClass is not None: _call_if_exists(result, "_setupStdout") try: @@ -115,7 +117,9 @@ def _tearDownPreviousClass(self, test, result): if getattr(previousClass, "__unittest_skip__", False): return - tearDownClass = getattr(previousClass, "tearDownClass", None) + tearDownClass = getattr(previousClass, "_callTearDownClass", None) + if tearDownClass is None: + tearDownClass = getattr(previousClass, "tearDownClass", None) if tearDownClass is not None: _call_if_exists(result, "_setupStdout") try: