Combining py.test and trio/curio

I would to combine pytest and trio (or curio, if that is any easier), i.e. write my test cases as coroutine functions. This is relatively easy to achieve by declaring a custom test runner in conftest.py:

    @pytest.mark.tryfirst
    def pytest_pyfunc_call(pyfuncitem):
        '''If item is a coroutine function, run it under trio'''

        if not inspect.iscoroutinefunction(pyfuncitem.obj):
            return

        kernel = trio.Kernel()
        funcargs = pyfuncitem.funcargs
        testargs = {arg: funcargs[arg]
                    for arg in pyfuncitem._fixtureinfo.argnames}
        try:
            kernel.run(functools.partial(pyfuncitem.obj, **testargs))
        finally:
            kernel.run(shutdown=True)

        return True

This allows me to write test cases like this:

    async def test_something():
        server = MockServer()
        server_task = await trio.run(server.serve)
        try:
             # test the server
        finally:
             server.please_terminate()
             try:
                 with trio.fail_after(30):
                     server_task.join()
             except TooSlowError:
                 server_task.cancel()

But this is a lot of boilerplate. In non-async code, I would factor this out into a fixture:

@pytest.yield_fixture()
def mock_server():
    server = MockServer()
    thread = threading.Thread(server.serve)
    thread.start()

    try:
        yield server
    finally:
        server.please_terminate()
        thread.join()
        server.server_close()

def test_something(mock_server):
   # do the test..

Is there a way to do the same in trio, i.e. implement async fixtures? Ideally, I would just write:

async def test_something(mock_server):
   # do the test..

1 answer

  • answered 2018-01-19 13:22 Nathaniel J. Smith

    Your example pytest_pyfunc_call code doesn't work becaues it's a mix of trio and curio :-). For trio, there's a decorator trio.testing.trio_test that can be used to mark individual tests (like if you were using classic unittest or something), so the simplest way to write a pytest plugin function is to just apply this to each async test:

    from trio.testing import trio_test
    
    @pytest.mark.tryfirst
    def pytest_pyfunc_call(pyfuncitem):
        if inspect.iscoroutine(pyfuncitem.obj):
            # Apply the @trio_test decorator
            pyfuncitem.obj = trio_test(pyfuncitem.obj)
    

    In case you're curious, this is basically equivalent to:

    import trio
    from functools import wraps, partial
    
    @pytest.mark.tryfirst
    def pytest_pyfunc_call(pyfuncitem):
        if inspect.iscoroutine(pyfuncitem.obj):
            fn = pyfuncitem.obj
            @wraps(fn)
            def wrapper(**kwargs):
                trio.run(partial(fn, **kwargs))
            pyfuncitem.obj = wrapper
    

    Anyway, that doesn't solve your problem with fixtures – for that you need something much more involved.

    pytest-trio does support async fixtures, but doesn't currently have a mechanism to automatically use trio for async def (you have to use @pytest.mark.trio on them). Hopefully this will be added soon – cleaning it up more is high on my todo list.