Let unittest frameworks deal with async functions

Instead of trying to handle unittest-async functions in pytest_pyfunc_call,
let the unittest framework handle them instead.

This lets us remove the hack in pytest_pyfunc_call, with the upside that
we should support any unittest-async based framework.

Also included 'asynctest' as test dependency for py37-twisted, and renamed
'twisted' to 'unittestextras' to better reflect that we install 'twisted' and
'asynctest' now.

This also fixes the problem of cleanUp functions not being properly called
for async functions.

Fix #7110
Fix #6924
This commit is contained in:
Bruno Oliveira 2020-05-01 12:56:06 -03:00
parent be68496440
commit fd2f172258
9 changed files with 70 additions and 36 deletions

View File

@ -70,7 +70,7 @@ jobs:
- name: "windows-py38" - name: "windows-py38"
python: "3.8" python: "3.8"
os: windows-latest os: windows-latest
tox_env: "py38-twisted" tox_env: "py38-unittestextras"
use_coverage: true use_coverage: true
- name: "ubuntu-py35" - name: "ubuntu-py35"

View File

@ -0,0 +1 @@
Fixed regression: ``asyncbase.TestCase`` tests are executed correctly again.

View File

@ -93,6 +93,13 @@ def iscoroutinefunction(func: object) -> bool:
return inspect.iscoroutinefunction(func) or getattr(func, "_is_coroutine", False) return inspect.iscoroutinefunction(func) or getattr(func, "_is_coroutine", False)
def is_async_function(func: object) -> bool:
"""Return True if the given function seems to be an async function or async generator"""
return iscoroutinefunction(func) or (
sys.version_info >= (3, 6) and inspect.isasyncgenfunction(func)
)
def getlocation(function, curdir=None) -> str: def getlocation(function, curdir=None) -> str:
function = get_real_func(function) function = get_real_func(function)
fn = py.path.local(inspect.getfile(function)) fn = py.path.local(inspect.getfile(function))

View File

@ -34,8 +34,8 @@ from _pytest.compat import get_default_arg_names
from _pytest.compat import get_real_func from _pytest.compat import get_real_func
from _pytest.compat import getimfunc from _pytest.compat import getimfunc
from _pytest.compat import getlocation from _pytest.compat import getlocation
from _pytest.compat import is_async_function
from _pytest.compat import is_generator from _pytest.compat import is_generator
from _pytest.compat import iscoroutinefunction
from _pytest.compat import NOTSET from _pytest.compat import NOTSET
from _pytest.compat import REGEX_TYPE from _pytest.compat import REGEX_TYPE
from _pytest.compat import safe_getattr from _pytest.compat import safe_getattr
@ -159,7 +159,7 @@ def pytest_configure(config):
) )
def async_warn(nodeid: str) -> None: def async_warn_and_skip(nodeid: str) -> None:
msg = "async def functions are not natively supported and have been skipped.\n" msg = "async def functions are not natively supported and have been skipped.\n"
msg += ( msg += (
"You need to install a suitable plugin for your async framework, for example:\n" "You need to install a suitable plugin for your async framework, for example:\n"
@ -175,33 +175,13 @@ def async_warn(nodeid: str) -> None:
@hookimpl(trylast=True) @hookimpl(trylast=True)
def pytest_pyfunc_call(pyfuncitem: "Function"): def pytest_pyfunc_call(pyfuncitem: "Function"):
testfunction = pyfuncitem.obj testfunction = pyfuncitem.obj
if is_async_function(testfunction):
try: async_warn_and_skip(pyfuncitem.nodeid)
# ignoring type as the import is invalid in py37 and mypy thinks its a error
from unittest import IsolatedAsyncioTestCase # type: ignore
except ImportError:
async_ok_in_stdlib = False
else:
async_ok_in_stdlib = isinstance(
getattr(testfunction, "__self__", None), IsolatedAsyncioTestCase
)
if (
iscoroutinefunction(testfunction)
or (sys.version_info >= (3, 6) and inspect.isasyncgenfunction(testfunction))
) and not async_ok_in_stdlib:
async_warn(pyfuncitem.nodeid)
funcargs = pyfuncitem.funcargs funcargs = pyfuncitem.funcargs
testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames}
result = testfunction(**testargs) result = testfunction(**testargs)
if hasattr(result, "__await__") or hasattr(result, "__aiter__"): if hasattr(result, "__await__") or hasattr(result, "__aiter__"):
if async_ok_in_stdlib: async_warn_and_skip(pyfuncitem.nodeid)
# todo: investigate moving this to the unittest plugin
# by a test call result hook
testcase = testfunction.__self__
testcase._callMaybeAsync(lambda: result)
else:
async_warn(pyfuncitem.nodeid)
return True return True

View File

@ -6,6 +6,7 @@ import traceback
import _pytest._code import _pytest._code
import pytest import pytest
from _pytest.compat import getimfunc from _pytest.compat import getimfunc
from _pytest.compat import is_async_function
from _pytest.config import hookimpl from _pytest.config import hookimpl
from _pytest.outcomes import exit from _pytest.outcomes import exit
from _pytest.outcomes import fail from _pytest.outcomes import fail
@ -227,6 +228,10 @@ class TestCaseFunction(Function):
self._needs_explicit_tearDown = True self._needs_explicit_tearDown = True
raise _GetOutOf_testPartExecutor(exc) raise _GetOutOf_testPartExecutor(exc)
# let the unittest framework handle async functions
if is_async_function(self.obj):
self._testcase(self)
else:
setattr(self._testcase, self._testcase._testMethodName, wrapped_testMethod) setattr(self._testcase, self._testcase._testMethodName, wrapped_testMethod)
try: try:
self._testcase(result=self) self._testcase(result=self)

View File

@ -1,7 +1,13 @@
from unittest import IsolatedAsyncioTestCase # type: ignore from unittest import IsolatedAsyncioTestCase # type: ignore
teardowns = []
class AsyncArguments(IsolatedAsyncioTestCase): class AsyncArguments(IsolatedAsyncioTestCase):
async def asyncTearDown(self):
teardowns.append(None)
async def test_something_async(self): async def test_something_async(self):
async def addition(x, y): async def addition(x, y):
return x + y return x + y
@ -13,3 +19,6 @@ class AsyncArguments(IsolatedAsyncioTestCase):
return x + y return x + y
self.assertEqual(await addition(2, 2), 3) self.assertEqual(await addition(2, 2), 3)
def test_teardowns(self):
assert len(teardowns) == 2

View File

@ -0,0 +1,22 @@
"""Issue #7110"""
import asyncio
import asynctest
teardowns = []
class Test(asynctest.TestCase):
async def tearDown(self):
teardowns.append(None)
async def test_error(self):
await asyncio.sleep(0)
self.fail("failing on purpose")
async def test_ok(self):
await asyncio.sleep(0)
def test_teardowns(self):
assert len(teardowns) == 2

View File

@ -1136,4 +1136,13 @@ def test_async_support(testdir):
testdir.copy_example("unittest/test_unittest_asyncio.py") testdir.copy_example("unittest/test_unittest_asyncio.py")
reprec = testdir.inline_run() reprec = testdir.inline_run()
reprec.assertoutcome(failed=1, passed=1) reprec.assertoutcome(failed=1, passed=2)
def test_asynctest_support(testdir):
"""Check asynctest support (#7110)"""
pytest.importorskip("asynctest")
testdir.copy_example("unittest/test_unittest_asynctest.py")
reprec = testdir.inline_run()
reprec.assertoutcome(failed=1, passed=2)

View File

@ -10,7 +10,7 @@ envlist =
py37 py37
py38 py38
pypy3 pypy3
py37-{pexpect,xdist,twisted,numpy,pluggymaster} py37-{pexpect,xdist,unittestextras,numpy,pluggymaster}
doctesting doctesting
py37-freeze py37-freeze
docs docs
@ -49,7 +49,8 @@ deps =
pexpect: pexpect pexpect: pexpect
pluggymaster: git+https://github.com/pytest-dev/pluggy.git@master pluggymaster: git+https://github.com/pytest-dev/pluggy.git@master
pygments pygments
twisted: twisted unittestextras: twisted
unittestextras: asynctest
xdist: pytest-xdist>=1.13 xdist: pytest-xdist>=1.13
{env:_PYTEST_TOX_EXTRA_DEP:} {env:_PYTEST_TOX_EXTRA_DEP:}