Async Support

TestSlide’s DSL supports asynchronous I/O testing.

For that, you must declare all of these as async:

  • Hooks: around, before and after.
  • Examples.
  • Memoize before.
  • Functions.

like this:

from testslide.dsl import context

@context
def testing_async_code(context):
  @context.around
  async def around(self, example):
    await example()  # Note that this must be awaited!

  @context.before
  async def before(self):
    pass

  @context.after
  async def after(self):
    pass

  @context.memoize_before
  async def memoize_before(self):
    return "memoize_before"

  @context.function
  async def function(self):
    return "function"

  @context.example
  async def example(self):
    assert self.memoize_before == "memoize_before"
    assert self.function == "function"

The test runner will create a new event look to execute each example.

Note

You can not mix async and sync stuff for the same example. If your example is async, then all its hooks and memoize before must also be async.

Note

It is not possible to support async @context.memoize. They depend on __getattr__ to work, which has no async support. Use @context.memoize_before instead.

Event Loop Health

Event loops are the engine that runs Python async code. It works by alternating the execution between different bits of async code. Eg: when await is used, it allows the event loop to switch to another task. A requirement for this model to work is that async code must be “well behaved”, so that it does what it needs to do without impacting other tasks.

TestSlide DSL has specific checks that detect if tested async code is doing something it should not.

Not Awaited Coroutine

Every called coroutine must be awaited. If they are not, it means their code never got to be executed, which indicates a bug in the code. In this example, a forgotten to be awaited coroutine triggers a test failure, despite the fact that no direct failure was reported by the test:

import asyncio
from testslide.dsl import context

@context
def Not_awaited_coroutine(context):
  @context.example
  async def awaited_sleep(self):
    await asyncio.sleep(1)

  @context.example
  async def forgotten_sleep(self):
    asyncio.sleep(1)
$ testslide not_awaited_coroutine.py
Not awaited coroutine
  awaited sleep
  forgotten sleep: RuntimeWarning: coroutine 'sleep' was never awaited

Failures:

  1) Not awaited coroutine: forgotten sleep
    1) RuntimeWarning: coroutine 'sleep' was never awaited
    Coroutine created at (most recent call last)
      File "/opt/python/lib/python3.7/site-packages/testslide/__init__.py", line 394, in run
        self._async_run_all_hooks_and_example(context_data)
      File "/opt/python/lib/python3.7/site-packages/testslide/__init__.py", line 334, in _async_run_all_hooks_and_example
        asyncio.run(coro, debug=True)
      File "/opt/python/lib/python3.7/asyncio/runners.py", line 43, in run
        return loop.run_until_complete(main)
      File "/opt/python/lib/python3.7/asyncio/base_events.py", line 566, in run_until_complete
        self.run_forever()
      File "/opt/python/lib/python3.7/asyncio/base_events.py", line 534, in run_forever
        self._run_once()
      File "/opt/python/lib/python3.7/asyncio/base_events.py", line 1763, in _run_once
        handle._run()
      File "/opt/python/lib/python3.7/asyncio/events.py", line 88, in _run
        self._context.run(self._callback, *self._args)
      File "/opt/python/lib/python3.7/site-packages/testslide/__init__.py", line 244, in _real_async_run_all_hooks_and_example
        self.example.code, context_data
      File "/opt/python/lib/python3.7/site-packages/testslide/__init__.py", line 218, in _fail_if_not_coroutine_function
        return await func(*args, **kwargs)
      File "/home/fornellas/tmp/not_awaited_coroutine.py", line 12, in forgotten_sleep
        asyncio.sleep(1)
      File "/opt/python/lib/python3.7/contextlib.py", line 119, in __exit__
        next(self.gen)

Finished 2 example(s) in 1.0s:
  Successful: 1
  Failed: 1

Slow Callback

Async code must do their work in small chunks, properly awaiting other functions when needed. If an async function does some CPU intensive task that takes a long time to compute, or if it calls a sync function that takes a long time to return, the entirety of the event loop will be locked up. This means that no other code can be executed until this bad async function returns.

If during the test execution a task blocks the event loop, it will trigger a test failure, despite the fact that no direct failure was reported by the test:

import time
from testslide.dsl import context

@context
def Blocked_event_loop(context):
  @context.example
  async def blocking_sleep(self):
    time.sleep(1)
$ testslide blocked_event_loop.py
Blocked event loop
  blocking sleep: SlowCallback: Executing <Task finished coro=<_ExampleRunner._real_async_run_all_hooks_and_example() done, defined at /opt/python/lib/python3.7/site-packages/testslide/__init__.py:220> result=None created at /opt/python/lib/python3.7/asyncio/base_events.py:558> took 1.002 seconds

Failures:

  1) Blocked event loop: blocking sleep
    1) SlowCallback: Executing <Task finished coro=<_ExampleRunner._real_async_run_all_hooks_and_example() done, defined at /opt/python/lib/python3.7/site-packages/testslide/__init__.py:220> result=None created at /opt/python/lib/python3.7/asyncio/base_events.py:558> took 1.002 seconds
      During the execution of the async test a slow callback that blocked the event loop was detected.
      Tip: you can customize the detection threshold with:
        asyncio.get_running_loop().slow_callback_duration = seconds
      File "/opt/python/lib/python3.7/contextlib.py", line 119, in __exit__
        next(self.gen)

Finished 1 example(s) in 1.0s:
  Failed: 1

Python’s default threshold for triggering this event loop lock up failure is 100ms. If your problem domain requires something smaller or bigger, you can easily customize it:

import asyncio
import time
from testslide.dsl import context

@context
def Custom_slow_callback_duration(context):
  @context.before
  async def increase_slow_callback_duration(self):
    loop = asyncio.get_running_loop()
    loop.slow_callback_duration = 2

  @context.example
  async def blocking_sleep(self):
    time.sleep(1)
$ testslide custom_slow_callback_duration.py
Custom slow callback duration
  blocking sleep

Finished 1 example(s) in 1.0s:
  Successful: 1