Как запустить Uvicorn + FastAPI в фоновом режиме при тестировании с PyTest

У меня есть приложение REST-API, написанное с помощью Uvicorn+ FastAPI

Который я хочу проверить с помощью PyTest.

Я хочу запустить сервер в приборе при запуске тестов, поэтому, когда тест завершится, прибор убьет приложение.

FastAPI Testing показывает, как тестировать приложение API,

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"}

Это не переводит сервер в обычный режим работы. Кажется, что конкретная функциональность, которая запускается командой client.get, - единственное, что запускается.

Я нашел эти дополнительные ресурсы, но не могу заставить их работать на меня:

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

Как запустить сервер как крепление для py.test

Как бы вы запустили приложение Uvicorn+FastAPI из PyTest, чтобы оно шло вверх и вниз с тестами?

5 ответов

На основе ответа @Gabriel C. Полностью объектно-ориентированный и асинхронный подход (с использованием превосходной среды asynctest).

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"})

Если вы хотите запустить сервер, вам придется сделать это в другом процессе / потоке, поскольку uvicorn.run() является блокирующим вызовом.

Тогда вместо использования TestClient вам придется использовать что-то вроде запросов для попадания по фактическому URL-адресу, который слушает ваш сервер.

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"}

Здесь у меня есть другое решение, которое запускает uvicorn в том же процессе (проверено с 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

В FastAPI есть тестовый клиент https://fastapi.tiangolo.com/tutorial/testing/ . Это своего рода реализация библиотеки запросов, поэтому вы можете использовать ее следующим образом:

      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

Углубившись в документацию, я наткнулся на https://fastapi.tiangolo.com/advanced/testing-events , где предлагается использоватьwith TestClient(app) as clientчтобы сделать асинхронные события для и запустить:

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

Это позволило мне правильно воспроизвести поведение приложения gunicorn также в pytest, не запуская никаких дополнительных фоновых процессов.

Дополнительная информация

Я только что столкнулся с этой проблемой, когда пытался адаптировать практику тестирования, предложенную в https://fastapi.tiangolo.com/tutorial/testing , т. е. без запуска фонового сервера, как предлагали другие.

Однако это не выполняет асинхронные события для@app.on_event("startup")и@app.on_event("shutdown"), которые правильно выполняются наgunicorn.

Например, следующее не будет напечатано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"}
Другие вопросы по тегам