Skip to content
This repository has been archived by the owner on Mar 28, 2020. It is now read-only.

florimondmanca/pytest-unasync

Repository files navigation

pytest-unasync

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.

Installation

Upload to PyPI may happen soon. In the meantime, install from git:

pip install "git+https://github.com/florimondmanca/pytest-unasync.git"

Motivation

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.

Usage

Async-to-sync test transformation

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.

Generating 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? 🤷‍♂️

License

MIT