In this article I will show you how you can test an async Socketio application in Python, where the ASGI server we are running is uvicorn. I will be referring to these tests as integration tests, though depending on who you ask they could be called E2E tests, system tests, slow test etc. What I am referring to is simply testing out the entire “flow” of a socketio event i.e. emitting an event from a client, then receiving it on the web service and for my actual projects interacting with an actual database.
We will be using pytest
as our testing framework.
ASGI
ASGI (Asynchronous Server Gateway Interface) is a spiritual successor to WSGI, intended to provide a standard interface between async-capable Python web servers, frameworks, and applications. - https://asgi.readthedocs.io/en/latest/main.py
import socketio
import uvicorn
from pydantic import BaseModel
async def startup():
print("Starting Application")
sio = socketio.AsyncServer(async_mode="asgi")
app = socketio.ASGIApp(sio, on_startup=startup)
class FooEvent(BaseModel):
name: str
@sio.on("FOO")
async def foo_event(sid, *args, **kwargs):
data = FooEvent(**args[0])
await sio.emit("BAR", {"foo": data.name})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8080)
Let’s take a look at our socketio app. Which is a very simple web app, that listens to one event FOO
and
responds with a BAR
event. It is just this single file.
conftest.py
The conftest.py
file is automatically run by pytest and allows our test modules to access fixtures defined
in this file. One of the best features of Pytest is fixtures. Fixture are functions that have re-usable bits of code we
can run in our tests, such as static data used by tests.
import asyncio
from typing import Any, AsyncIterator, Awaitable, List, Optional
import pytest
import socketio
import uvicorn
from app import main
from socketio import ASGIApp
from socketio.asyncio_client import AsyncClient
PORT = 8000
LISTENING_IF = "127.0.0.1"
BASE_URL = f"http://{LISTENING_IF}:{PORT}"
class UvicornTestServer(uvicorn.Server):
def __init__(self, app: ASGIApp = main.app, host: str = LISTENING_IF, port: int = PORT):
self._startup_done = asyncio.Event()
self._serve_task: Optional[Awaitable[Any]] = None
super().__init__(config=uvicorn.Config(app, host=host, port=port))
async def startup(self) -> None:
"""Override uvicorn startup"""
await super().startup()
self.config.setup_event_loop()
self._startup_done.set()
async def start_up(self) -> None:
"""Start up server asynchronously"""
self._serve_task = asyncio.create_task(self.serve())
await self._startup_done.wait()
async def tear_down(self) -> None:
"""Shut down server asynchronously"""
self.should_exit = True
if self._serve_task:
await self._serve_task
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop()
yield loop
loop.close()
@pytest.fixture(autouse=True, scope="session")
async def startup_and_shutdown_server():
server = UvicornTestServer()
await server.start_up()
yield
await server.tear_down()
@pytest.fixture(scope="session")
async def client() -> AsyncIterator[AsyncClient]:
sio = socketio.AsyncClient()
await sio.connect(BASE_URL)
yield sio
await sio.disconnect()
Quick Aside FastAPI Testing
Uvicorn
tl:dr: We need to start and stop the Uvicorn server within our tests.Now when testing say a FastAPI application, it has a builtin test client we can use. This means we don’t actually have to spin up a Uvicorn server to test our application. We can simply pretend to send requests to the FastAPI web service and it will handle the routing behind the scenes.
We can do something like this, where httpx
is a async HTTP client (think like the requests
library).
import pytest
from asgi_lifespan import LifespanManager
from httpx import AsyncClient
from app.main import app
@pytest.fixture()
async def client() -> AsyncIterator[AsyncClient]:
async with LifespanManager(app):
async with AsyncClient(app=app, base_url="http://localhost") as client:
yield client
Then we can use it like so in our tests:
from fastapi import status
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_add_game(client: AsyncClient):
response = await client.post("/game", json=request_data)
assert response.status_code == status.HTTP_201_CREATED
However socketio at the moment does not provide us with a test client we can use. So we will start and stop a Uvicorn server and send actual Socketio requests from a Socketio client. There is a Socketio client library we can use to do this, available in Python.
class UvicornTestServer(uvicorn.Server):
def __init__(self, app: ASGIApp = main.app, host: str = LISTENING_IF, port: int = PORT):
self._startup_done = asyncio.Event()
self._serve_task: Optional[Awaitable[Any]] = None
super().__init__(config=uvicorn.Config(app, host=host, port=port))
async def startup(self) -> None:
"""Override uvicorn startup"""
await super().startup()
self.config.setup_event_loop()
self._startup_done.set()
async def start_up(self) -> None:
"""Start up server asynchronously"""
self._serve_task = asyncio.create_task(self.serve())
await self._startup_done.wait()
async def tear_down(self) -> None:
"""Shut down server asynchronously"""
self.should_exit = True
if self._serve_task:
await self._serve_task
This is a test class which we can use to start and stop the Uvicorn server. Note that the class inherits
from uvicorn.server
, we need to overwrite the startup()
method as we want to change the startup a bit.
Before explaining the code above let’s take a look at how we may use it:
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop()
yield loop
loop.close()
@pytest.fixture(autouse=True, scope="session")
async def startup_and_shutdown_server():
server = UvicornTestServer()
await server.start_up()
yield
await server.tear_down()
@pytest.fixture(scope="session")
async def client() -> AsyncIterator[AsyncClient]:
sio = socketio.AsyncClient()
await sio.connect(BASE_URL)
yield sio
await sio.disconnect()
What we have done is created two pytest fixtures, the first simply starts an event loop so we can test async code.
Tangent on asyncio
To test async code with pytest we need to install the pyest-asyncio
library.
By default this will give us an event_loop
fixture that runs on scope of function
. So it will start and stop after
each test function. However if you want to use fixtures that aren’t of scope function
i.e. session
or module
.
Then we need to redefine the event_loop
function as we have done in the example above.
Okay back to our code above. The main bit we are interested in is the startup_and_shutdown_server
function, here we
start the server before all of our tests and due to how yield
, you can read more about how
yield works here, we will stop our server after all of our tests have run.
This happens automatically without calling the function because of the decorator we have provided
@pytest.fixture(autouse=True, scope="session")
.
Again we are using scope session
so that this function isn’t called either for
every function (which would slow down our tests). We could’ve set it to module
but again
if we have multiple test files we don’t want to run this function for every file (module).
Deeper diver into UvicornTestServer
Let’s take a look at the first two methods
def __init__(self, app: ASGIApp = main.app, host: str = LISTENING_IF, port: int = PORT):
self._startup_done = asyncio.Event()
self._serve_task: Optional[Awaitable[Any]] = None
super().__init__(config=uvicorn.Config(app, host=host, port=port))
async def startup(self) -> None:
"""Override uvicorn startup"""
await super().startup()
self.config.setup_event_loop()
self._startup_done.set()
The __init__
magic dunder method creates an asyncio event asyncio.Event()
. These events are often used to:
An asyncio event can be used to notify multiple asyncio tasks that some event has happened. - https://docs.python.org/3/library/asyncio-sync.html#asyncio.Event
Then we create a variable self._serve_task: Optional[Awaitable[Any]] = None
, we will see how this used later.
Finally we call the parent calls __init__
method (super().__init__()
). This calls the __init__
function
of the uvicorn.Server
class. We do this to set the uvicorn.Config
, which includes our app and which host and port
to start the server.
Onto the second method startup
this also overwrites a method in the parent class. In fact the first we do is call
the parent class’s startup
method (await super().startup()
). Then we start the event loop ourselves
self.config.setup_event_loop()
, where our web app will run.
Event Loop
This is a different event loop in which our tests run in.Finally we do self._startup_done.set()
, we are setting this event as true i.e. is complete. So any coroutines waiting
until this set can be carry on their execution.
Asyncio
An Event object manages an internal flag that can be set to true with the set() method and reset to false with the clear() method. The wait() method blocks until the flag is set to true. The flag is set to false initially. - https://docs.python.org/3/library/asyncio-sync.html#asyncio.EventYet another tangent on run() method
Now the parent class does have a run
method we could use, which would start the event loop for us. This however won’t work,
lets pretend we change startup_and_shutdown_server
function too look like this (server.run()
).
@pytest.fixture(autouse=True, scope="session")
async def startup_and_shutdown_server():
server = UvicornTestServer()
await server.run()
yield
await server.tear_down()
We would get the following error RuntimeError: asyncio.run() cannot be called from a running event loop
. This because if
we take a look at the run
method in the parent class it contains something like this line
return asyncio.run(self.serve(...))
.
This is why we need to write our own code to handle starting the Uvicorn server.
start_up
and tear_down
Okay let’s move and take a look at the start_up
and tear_down
methods
async def start_up(self) -> None:
self._serve_task = asyncio.create_task(self.serve())
await self._startup_done.wait()
async def tear_down(self) -> None:
self.should_exit = True
if self._serve_task:
await self._serve_task
Remember these are the two methods we will call in our “startup and shutdown” fixture. The start_up
method, creates a task and assigns it
to our empty variable from the __init__
method self._serve_task = asyncio.create_task(self.serve())
. It calls the serve
method to start
the Uvicorn server.
What does create_task do?
It submits the coroutine to run “in the background”, i.e. concurrently with the current task and all other tasks, switching between them at await points. It returns an awaitable handle called a “task” which you can also use to cancel the execution of the coroutine. - https://stackoverflow.com/questions/62528272/what-does-asyncio-create-task-doWhat is a task?
It’s an asyncio construct that tracks execution of a coroutine in a concrete event loop. When you call create_task, you submit a coroutine for execution and receive back a handle. You can await this handle when you actually need the result, or you can never await it, if you don’t care about the result. This handle is the task, and it inherits from Future, which makes it awaitable and also provides the lower-level callback-based interface, such as add_done_callback. - https://stackoverflow.com/questions/62528272/what-does-asyncio-create-task-doThen we await self._startup_done.wait()
, this is the event we created earlier. It will wait until the set()
function
has been called in the in the startup
method above.
Now onto the tear_down
method where we set the should_exit
to true. There is a main_loop
method called by our
serve
method in the parent class. This main_loop
calls an on_tick
function which returns if self.should_exit
is true.
So the call chain looks like: serve
-> main_loop
-> on_tick
. When on_tick returns should_exist
as true, it exits it main loop:
async def main_loop(self) -> None:
counter = 0
should_exit = await self.on_tick(counter)
while not should_exit:
counter += 1
counter = counter % 864000
await asyncio.sleep(0.1)
should_exit = await self.on_tick(counter)
Client Fixture
Finally lets take a look at our final fixture, here we create a client that can be used to make requests with socketio.
We use a similar technique with yields
so we return a socketio client. We will see how this used in one of our tests.
@pytest.fixture(scope="session")
async def client() -> AsyncIterator[AsyncClient]:
sio = socketio.AsyncClient()
await sio.connect(BASE_URL)
yield sio
await sio.disconnect()
test_room.py
import asyncio
import pytest
from socketio.asyncio_client import AsyncClient
@pytest.mark.asyncio
async def test_success(client: AsyncClient):
future = asyncio.get_running_loop().create_future()
@client.on("BAR")
def _(data):
future.set_result(data)
await client.emit("FOO", {"name": "haseeb"})
await asyncio.wait_for(future, timeout=5.0)
result = future.result()
assert result == {"foo": "haseeb"}
Since we need to wait for the FOO
event to return a BAR
event we use a future
to await until we get a response then set the return data in the future
@client.on("BAR")
def _(data):
future.set_result(data)
We await asyncio.wait_for(future, timeout=5.0)
for the future to have data set on it.
That’s it, the code itself is fairly simple once everything is setup in conftest
to actually do the test.