diff --git a/README.md b/README.md index 5739e843..a0a08e89 100644 --- a/README.md +++ b/README.md @@ -562,30 +562,96 @@ 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"}) + + sublime.set_timeout(run_in_mainthread, 10) + await asyncio.sleep(2.0) + + +class MyAsyncTestCase(AsyncTestCase): + + @classmethod + async def setUpClass(cls): + pass + + @classmethod + async def tearDownClass(cls): + pass -from unittesting import IsolatedAsyncioTestCase + 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 a_coro(): - return 1 + 1 + async def tearDown(self): + self.view.close() -class MyAsyncTestCase(IsolatedAsyncioTestCase): - async def test_something(self): - result = await a_coro() - await asyncio.sleep(1) - self.assertEqual(result, 2) + 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" + ) +``` + +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, +> 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..dff200f3 100644 --- a/tests/_Asyncio/tests/test_coroutine.py +++ b/tests/_Asyncio/tests/test_coroutine.py @@ -1,15 +1,44 @@ 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): + 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 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") + + 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/_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/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/__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..cee605a3 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,47 @@ 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) + + @classmethod + def _callSetUpClass(cls): + return cls._callMaybeCoro(cls.setUpClass) + + @classmethod + def _callTearDownClass(cls): + return cls._callMaybeCoro(cls.tearDownClass) - def _callCleanup(self, function, *args, **kwargs): - deferred = function(*args, **kwargs) - if isinstance(deferred, DeferrableMethod): - yield from deferred + @classmethod + def _callMaybeCoro(cls, func, /, *args, **kwargs): + coro = func(*args, **kwargs) + if isinstance(coro, DeferrableMethod): + yield from coro + elif inspect.iscoroutine(coro): + 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): @@ -76,24 +101,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 +141,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._callMaybeCoro(function, *args, **kwargs) # return this for backwards compatibility # even though we no longer use it internally @@ -140,15 +154,30 @@ 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 + + @classmethod + async def setUpClass(cls): + pass + + @classmethod + async def tearDownClass(cls): + pass + + @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/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..e004f943 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,47 @@ 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) + + @classmethod + def _callSetUpClass(cls): + return cls._callMaybeCoro(cls.setUpClass) + + @classmethod + def _callTearDownClass(cls): + return cls._callMaybeCoro(cls.tearDownClass) - def _callCleanup(self, function, *args, **kwargs): - deferred = function(*args, **kwargs) - if isinstance(deferred, DeferrableMethod): - yield from deferred + @classmethod + def _callMaybeCoro(cls, func, /, *args, **kwargs): + coro = func(*args, **kwargs) + if isinstance(coro, DeferrableMethod): + yield from coro + elif inspect.iscoroutine(coro): + 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): @@ -76,27 +102,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 +147,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._callMaybeCoro(function, *args, **kwargs) # return this for backwards compatibility # even though we no longer use it internally @@ -143,15 +160,30 @@ 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 + + @classmethod + async def setUpClass(cls): + pass + + @classmethod + async def tearDownClass(cls): + pass + + @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: 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..d0dc9ad0 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", ] @@ -186,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, @@ -208,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") ``` """ @@ -226,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. @@ -252,3 +256,36 @@ def test_editing(self): """ pass + + +class ViewTestCase(ViewTestCaseMixin, TestCase): + """ + This class describes a view test case. + + 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``. + + 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): + # settings to apply to the created view + view_settings = { + "detect_indentation": False, + "tab_size": 4, + "translate_tabs_to_spaces": False, + "word_wrap": False, + } + + def test_editing(self): + self.setText("foo") + self.setCaretTo(0, 0) + self.setText("foo") + self.assertRowContentsEqual(0, "foofoo") + ``` + """ + + pass