A pytest plugin that generates sync tests from async tests at runtime using unasync
.
Note: this plugin is highly experimental. Please, don't use it. This can go unmaintained anytime.
Upload to PyPI may happen soon. In the meantime, install from git:
pip install "git+https://github.com/florimondmanca/pytest-unasync.git"
Suppose you want to test a package that provides both an async and a sync API using unasync
. You want to test that both facets of the package behave correctly. You already have setup unasync
to generate the sync part of the code for you. As a result, you'd like to not have to rewrite your async tests in a sync flavor.
pytest-unasync
aims at solving this problem. It lets you use async test cases as the single source of truth, and generates their sync counterpart on-the-fly during test collection.
pytest-unasync
provides an unasync
marker whose role is to transform an async test function into a sync one just before the test actually runs.
The marker is to be used like so:
# tests/test_hello.py
import pytest
@pytest.mark.unasync
async def test_hello():
async def hello():
return "Hello, world"
assert await hello() == "Hello, world"
If you run this test suite, the test should pass. But you won't get any warnings about async functions not being supported by pytest
core, as you normally would when declaring async test functions without marking them with @pytest.mark.asyncio
, or another supported async pytest plugin.
This is because what got run is a generated sync variant of test_hello()
.
In fact, if you run the test again with the PYTEST_UNASYNC_DEBUG
environment variable set, you'll see what the resulting sync function looks like:
$ PYTEST_UNASYNC_DEBUG=1 pytest
tests/test_hello.py Transformed function 'test_hello' into:
def test_hello_sync() -> None:
def hello():
return "Hello, world"
assert hello() == "Hello, world"
But you'll also notice that the original async test_hello
function was discarded. In general this won't be what you want: you'd probably like to keep the original async test cases, while generating and running the sync test cases.
Let's use the test_hello
example again, except now we'll assume that we were originally testing it with pytest-asyncio
, like so...
# tests/test_hello.py
import pytest
@pytest.mark.asyncio
async def test_hello():
async def hello():
return "Hello, world"
assert await hello() == "Hello, world"
What we want to achieve is to have pytest execute both this async test running on asyncio, and a sync test generated via pytest-unasync
.
To do this, we'll need to refactor things a bit so that the asyncio
marker is applied via a parametrized fixture, e.g.:
# tests/conftest.py
import pytest
@pytest.fixture(params=[
pytest.param("asyncio", marks=pytest.mark.asyncio),
])
def concurrency_environment(request):
return request.param
Now, we can add another parameter to the list, and mark it with pytest.mark.unasync
:
@pytest.fixture(params=[
pytest.param("asyncio", marks=pytest.mark.asyncio),
pytest.param("sync", marks=pytest.mark.unasync),
])
# ...
Test functions that use the concurrency_environment
fixtures will now be collected in two copies:
- A first copy that is the original async test function, ran by
pytest-asyncio
. - A second copy that is a sync version of the test, generated by
pytest-unasync
.
To make sure this actually works, let's remove the @pytest.mark.asyncio
decorator from test_hello
, and make it use the concurrency_environment
fixture we've just defined, like so...
# tests/test_hello.py
import pytest
@pytest.mark.usefixtures("concurrency_environment")
async def test_hello():
async def hello():
return "Hello, world"
assert await hello() == "Hello, world"
Now, if you run the test suite, you'll notice that two tests are run. If you enable PYTEST_UNASYNC_DEBUG
again, and run the test suite with pytest -s
, you should see the logs indicating that a sync version of the test was generated.
The sync test functions are obtained by feeding the source code of their async counterpart through unasync
, then executing the resulting source code. (This is, indeed, an example of code generation and metaprogramming.)
Virtually, the test suite that ran was equivalent to the following, except we only had to write the async test:
# tests/test_hello.py
import pytest
async def test_hello():
async def hello():
return "Hello, world"
assert await hello() == "Hello, world"
def test_hello_sync():
def hello():
return "Hello, world"
assert hello() == "Hello, world"
Has your mind been shattered yet? 🤷♂️
MIT