26

I have an REST-API app written with Uvicorn+FastAPI

Which I want to test using PyTest.

I want to start the server in a fixture when I start the tests, so when the test complete, the fixture will kill the app.

FastAPI Testing shows how to test the API app,

from fastapi import FastAPI
from starlette.testclient import TestClient

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}


client = TestClient(app)


def test_read_main():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}

This doesn't bring the server online in the usual way. It seems that the specific functionality that is triggered by the client.get command is the only thing that runs.

I found these additional resources, but I can't make them work for me:

https://medium.com/@hmajid2301/pytest-with-background-thread-fixtures-f0dc34ee3c46

How to run server as fixture for py.test

How would you run the Uvicorn+FastAPI app from PyTest, so it goes up and down with the tests?

RaamEE
  • 3,017
  • 4
  • 33
  • 53

5 Answers5

26

Inspired from @Gabriel C answer. A fully object oriented and async approach (using the excellent asynctest framework).

import logging
from fastapi import FastAPI

class App:
    """ Core application to test. """

    def __init__(self):
        self.api = FastAPI()
        # register endpoints
        self.api.get("/")(self.read_root)
        self.api.on_event("shutdown")(self.close)

    async def close(self):
        """ Gracefull shutdown. """
        logging.warning("Shutting down the app.")

    async def read_root(self):
        """ Read the root. """
        return {"Hello": "World"}

""" Testing part."""
from multiprocessing import Process
import asynctest
import asyncio
import aiohttp
import uvicorn

class TestApp(asynctest.TestCase):
    """ Test the app class. """

    async def setUp(self):
        """ Bring server up. """
        app = App()
        self.proc = Process(target=uvicorn.run,
                            args=(app.api,),
                            kwargs={
                                "host": "127.0.0.1",
                                "port": 5000,
                                "log_level": "info"},
                            daemon=True)
        self.proc.start()
        await asyncio.sleep(0.1)  # time for the server to start

    async def tearDown(self):
        """ Shutdown the app. """
        self.proc.terminate()

    async def test_read_root(self):
        """ Fetch an endpoint from the app. """
        async with aiohttp.ClientSession() as session:
            async with session.get("http://127.0.0.1:5000/") as resp:
                data = await resp.json()
        self.assertEqual(data, {"Hello": "World"})
  • what additional values does asynctest bring unit testing? I can understand it can be important for end to end testing, or load test etc, but for unit test, didn't get it. – Baskaya May 30 '20 at 06:26
  • asynctest is a testing framework above unittest convinient to test coroutines. Unittest was only able to test sync functions, but maybe it has change since. – Constantin De La Roche May 31 '20 at 06:22
  • 3
    I don't think you need async test suite to test async FastAPI functions. Here is the tutorial: https://fastapi.tiangolo.com/tutorial/testing/. – Baskaya May 31 '20 at 23:47
  • [Here](https://github.com/miguelgrinberg/python-socketio/issues/332#issuecomment-712928157) I have another solution that spins up the server in the same process and does a graceful shutdown. – erny Oct 20 '20 at 22:37
  • From my newbie point of view, if you introduce new elements not required by the OP like the async approach, an explanation on why this is required or which benefits does it have if it's not required, would be appreciated. This would also help people who can't make it work without this approach find out if it's a requirement or not and why. – Btc Sources Mar 07 '21 at 20:56
17

If you want to bring the server up you will have to do it in a different process/thread, since uvicorn.run() is a blocking call.

Then instead of using the TestClient you will have to use something like requests to hit the actual URL your server is listening to.

from multiprocessing import Process

import pytest
import requests
import uvicorn
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}


def run_server():
    uvicorn.run(app)


@pytest.fixture
def server():
    proc = Process(target=run_server, args=(), daemon=True)
    proc.start() 
    yield
    proc.kill() # Cleanup after test


def test_read_main(server):
    response = requests.get("http://localhost:8000/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}
Gabriel Cappelli
  • 3,632
  • 1
  • 17
  • 31
  • This is not working with pytest >= 4.0, since it doesn't support `yield` anymore – M.Winkens May 26 '20 at 14:42
  • 3
    I just tested with pytest 4.0.0 and 5.4.2 and yield still works. In the [documentation](https://docs.pytest.org/en/latest/deprecations.html#cached-setup) it even says you should use this approach – Gabriel Cappelli May 26 '20 at 17:06
  • It says here in the [documentation](https://docs.pytest.org/en/latest/deprecations.html#yield-tests) that yield-test are deprecated. In my case I couldn't get it running with yield. The server didn't stop without it – M.Winkens May 26 '20 at 19:11
  • With your input I got it running with a `fixture(scope="module")` and `yield proc` (instead of just yield). Thank you very much! – M.Winkens May 26 '20 at 19:20
  • 6
    @M.Winkens, you're talking about `yield`s inside of **test functions**, which is deprecated. In this example, yield is in fixture, which is not at all deprecated. Here you go: https://docs.pytest.org/en/2.8.7/yieldfixture.html#yieldfixture – Baskaya May 30 '20 at 06:30
  • Note, if you `yield` immediately after starting the Uvicorn process you will get a `ConnectionError` because the server takes time to start – Miles Erickson Apr 29 '22 at 18:38
  • multiprocessing won't work if non-pickable stuff is used – Wolfgang Fahl Aug 26 '22 at 17:07
5

Here I have another solution which runs uvicorn in the same process (tested with Python 3.7.9):

from typing import List, Optional
import asyncio

import pytest

import uvicorn

PORT = 8000


class UvicornTestServer(uvicorn.Server):
    """Uvicorn test server

    Usage:
        @pytest.fixture
        server = UvicornTestServer()
        await server.up()
        yield
        await server.down()
    """

    def __init__(self, app, host='127.0.0.1', port=PORT):
        """Create a Uvicorn test server

        Args:
            app (FastAPI, optional): the FastAPI app. Defaults to main.app.
            host (str, optional): the host ip. Defaults to '127.0.0.1'.
            port (int, optional): the port. Defaults to PORT.
        """
        self._startup_done = asyncio.Event()
        super().__init__(config=uvicorn.Config(app, host=host, port=port))

    async def startup(self, sockets: Optional[List] = None) -> None:
        """Override uvicorn startup"""
        await super().startup(sockets=sockets)
        self.config.setup_event_loop()
        self._startup_done.set()

    async def up(self) -> None:
        """Start up server asynchronously"""
        self._serve_task = asyncio.create_task(self.serve())
        await self._startup_done.wait()

    async def down(self) -> None:
        """Shut down server asynchronously"""
        self.should_exit = True
        await self._serve_task


@pytest.fixture
async def startup_and_shutdown_server():
    """Start server as test fixture and tear down after test"""
    server = UvicornTestServer()
    await server.up()
    yield
    await server.down()


@pytest.mark.asyncio
async def test_chat_simple(startup_and_shutdown_server):
    """A simple websocket test"""
    # any test code here
erny
  • 1,296
  • 1
  • 13
  • 28
  • Didn't work for me. The server just got stuck and didn't reply. Tried to reach the host in a browser: the same story. Had to kill it with kill -9. Python 3.7.5 – GlaIZier Oct 22 '20 at 16:23
  • 1
    @GlaIZier, your testing code must be asynchronous too. Have you been using requests? You should use aiohttp. If you want the server to respond, everything must be non-blocking. – erny Oct 23 '20 at 12:09
0

There is a test client in FastAPI https://fastapi.tiangolo.com/tutorial/testing/ This is a sort of implementation on a requests library, so you can use it like this:

import unittest
from fastapi.testclient import TestClient
from engine.routes.base import app


class PostTest(unittest.TestCase):
    def setUp(self) -> None:
        self.client = TestClient(app)

    def test_home_page(self):
        response = self.client.get("/")
        assert response.status_code == 200

0

Digging deeper into the documentation, I stumbled upon https://fastapi.tiangolo.com/advanced/testing-events which proposes using with TestClient(app) as client to make the asynchronous events for @app.on_event("startup") and @app.on_event("shutdown") fire:

def test_read_main():
    with TestClient(app) as client:
        response = client.get("/")
        assert response.status_code == 200
        assert response.json() == {"msg": "Hello World"}

This allowed me to properly replicate the app's behavior of gunicorn also in pytest, without starting any additional background process.

Additional information

I just encountered this problem while I was trying to adapt the testing practice proposed in https://fastapi.tiangolo.com/tutorial/testing, i.e. without starting a background server like gunicorn, as others have proposed.

However, this does not execute the asynchronous events for @app.on_event("startup") and @app.on_event("shutdown"), which are properly executed on gunicorn.

For example, the following will not print Block reached

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

@app.on_event("startup")
async def startup_event():
    # This will not be reached in pytest, but in gunicorn it will
    print("Block reached")

@app.get("/")
async def read_main():
    return {"msg": "Hello World"}

def test_read_main():
    client = TestClient(app)
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}
flinz
  • 347
  • 4
  • 12