Strategies for Testing Async Code – PyCon 2019

Also referred to:

  • Testing Asyncio Python Code with Pytest

Previous articles on asynchronous programming:

  • Asynchronous Programming 101: What is Python Asyncio
  • Asynchronous Programming 101: A Brief history of Python Async await development
  • Asynchronous programming 101: Write an event loop
  • Asynchronous Programming 101: For loops in Asyncio
  • Asynchronous Programming 101: Advanced Asyncio part 1

Asynchronous programming, by its very nature, achieves concurrency through cooperation, i.e., passing control to the main event loop when a wait is required, i.e., when I/O occurs. (Yield control when ‘awaiting’ asynchronous results.) This process is kind of like an event loop that completes the work of the operating system. You can think of the event loop as the operating system, and then think of the coroutine as a thread. The entire event loop is in one thread, which means task switching is more efficient without context switching.

Asynchronous code is efficient, but there is also a pain in the neck, and that is testing.

0x01: Async test instance

Consider a simple example: a Cat class has a move method that is asynchronous.

Then write a test class using UnitTest. Can you find a problem with the following code?

Herd (grafield, ‘forward’) returns a coroutine object, and nothing happens if you don’t await it. The Coroutine object is truthy, so assertTrue() will pass. If you run test, you will see the warning coroutine herd was nerver.

It is still wrong to call await as shown below, because the await keyword can only appear inside an async function.

One solution is to add event loops:

It works, but as you can probably see, it’s a hassle. If I have multiple methods, do I need an event loop for each test method? More importantly, I just want to do some unit testing, and the event loop is really a low-level detail at this point that I don’t need to care about.

In Python3.7, asyncio has a new method called asyncio.run() that hides the details of the event loop from you, so it makes the code much cleaner:

0x02 pytest-asyncio

PIP install Pytest-Asyncio, which is actually a pyTest plugin.

It’s easy to use, but it’s important to know how it works. The problem with the previous code is that pyTest’s default Runner treats all functions as normal functions, while async functions return a Coroutine object. So we had to find a way to tell PyTest to run the test method using an EventLoop.

One way is to instantiate an Eventloop and inject it into tests, for example:

import asyncio
import pytest

async def say(what, when):
    await asyncio.sleep(when)
    return what

@pytest.fixture
def event_loop():
    loop = asyncio.get_event_loop()
    yield loop
    loop.close()


def test_say(event_loop):
    expected = 'This should fail! '
    assert expected == event_loop.run_until_complete(say('Hello! ', 0))
Copy the code

This approach is inconvenient because you need to manually inject eventLoop each time. A more elegant approach would be to tune the Test Runner to recognize async functions and execute them as Asyncio Tasks.

This is what Pytest-Asyncio does. Its API is very simple, you just need to add a @pytest.mark.Asyncio modifier to async function:

import pytest
from say import say

@pytest.mark.asyncio
async def test_say():
    assert 'Hello! ' == await say('Hello! ', 0)
Copy the code