Ir para o conteúdo

Testando

Graças ao Starlette, testar aplicativos FastAPI é fácil e agradável.

Ele é baseado no HTTPX, que por sua vez é projetado com base em Requests, por isso é muito familiar e intuitivo.

Com ele, você pode usar o pytest diretamente com FastAPI.

Usando TestClient

Informação

Para usar o TestClient, primeiro instale o httpx.

Certifique-se de criar um ambiente virtual, ativá-lo e instalá-lo, por exemplo:

$ pip install httpx

Importe TestClient.

Crie um TestClient passando seu aplicativo FastAPI para ele.

Crie funções com um nome que comece com test_ (essa é a convenção padrão do pytest).

Use o objeto TestClient da mesma forma que você faz com httpx.

Escreva instruções assert simples com as expressões Python padrão que você precisa verificar (novamente, pytest padrão).

from fastapi import FastAPI
from fastapi.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"}

Dica

Observe que as funções de teste são def normais, não async def.

E as chamadas para o cliente também são chamadas normais, não usando await.

Isso permite que você use pytest diretamente sem complicações.

Detalhes técnicos

Você também pode usar from starlette.testclient import TestClient.

FastAPI fornece o mesmo starlette.testclient que fastapi.testclient apenas como uma conveniência para você, o desenvolvedor. Mas ele vem diretamente da Starlette.

Dica

Se você quiser chamar funções async em seus testes além de enviar solicitações ao seu aplicativo FastAPI (por exemplo, funções de banco de dados assíncronas), dê uma olhada em Testes assíncronos no tutorial avançado.

Separando testes

Em uma aplicação real, você provavelmente teria seus testes em um arquivo diferente.

E seu aplicativo FastAPI também pode ser composto de vários arquivos/módulos, etc.

Arquivo do aplicativo FastAPI

Digamos que você tenha uma estrutura de arquivo conforme descrito em Aplicativos maiores:

.
├── app
│   ├── __init__.py
│   └── main.py

No arquivo main.py você tem seu aplicativo FastAPI:

from fastapi import FastAPI

app = FastAPI()


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

Arquivo de teste

Então você poderia ter um arquivo test_main.py com seus testes. Ele poderia estar no mesmo pacote Python (o mesmo diretório com um arquivo __init__.py):

.
├── app
│   ├── __init__.py
│   ├── main.py
│   └── test_main.py

Como esse arquivo está no mesmo pacote, você pode usar importações relativas para importar o objeto app do módulo main (main.py):

from fastapi.testclient import TestClient

from .main import app

client = TestClient(app)


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

...e ter o código para os testes como antes.

Testando: exemplo estendido

Agora vamos estender este exemplo e adicionar mais detalhes para ver como testar diferentes partes.

Arquivo de aplicativo FastAPI estendido

Vamos continuar com a mesma estrutura de arquivo de antes:

.
├── app
│   ├── __init__.py
│   ├── main.py
│   └── test_main.py

Digamos que agora o arquivo main.py com seu aplicativo FastAPI tenha algumas outras operações de rotas.

Ele tem uma operação GET que pode retornar um erro.

Ele tem uma operação POST que pode retornar vários erros.

Ambas as operações de rotas requerem um cabeçalho X-Token.

from typing import Annotated

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

fake_secret_token = "coneofsilence"

fake_db = {
    "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
    "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}

app = FastAPI()


class Item(BaseModel):
    id: str
    title: str
    description: str | None = None


@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: Annotated[str, Header()]):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return fake_db[item_id]


@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: Annotated[str, Header()]):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in fake_db:
        raise HTTPException(status_code=409, detail="Item already exists")
    fake_db[item.id] = item
    return item
from typing import Annotated, Union

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

fake_secret_token = "coneofsilence"

fake_db = {
    "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
    "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}

app = FastAPI()


class Item(BaseModel):
    id: str
    title: str
    description: Union[str, None] = None


@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: Annotated[str, Header()]):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return fake_db[item_id]


@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: Annotated[str, Header()]):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in fake_db:
        raise HTTPException(status_code=409, detail="Item already exists")
    fake_db[item.id] = item
    return item
from typing import Union

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
from typing_extensions import Annotated

fake_secret_token = "coneofsilence"

fake_db = {
    "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
    "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}

app = FastAPI()


class Item(BaseModel):
    id: str
    title: str
    description: Union[str, None] = None


@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: Annotated[str, Header()]):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return fake_db[item_id]


@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: Annotated[str, Header()]):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in fake_db:
        raise HTTPException(status_code=409, detail="Item already exists")
    fake_db[item.id] = item
    return item

Dica

Prefira usar a versão Annotated se possível.

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

fake_secret_token = "coneofsilence"

fake_db = {
    "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
    "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}

app = FastAPI()


class Item(BaseModel):
    id: str
    title: str
    description: str | None = None


@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: str = Header()):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return fake_db[item_id]


@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: str = Header()):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in fake_db:
        raise HTTPException(status_code=409, detail="Item already exists")
    fake_db[item.id] = item
    return item

Dica

Prefira usar a versão Annotated se possível.

from typing import Union

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

fake_secret_token = "coneofsilence"

fake_db = {
    "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
    "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}

app = FastAPI()


class Item(BaseModel):
    id: str
    title: str
    description: Union[str, None] = None


@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: str = Header()):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return fake_db[item_id]


@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: str = Header()):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in fake_db:
        raise HTTPException(status_code=409, detail="Item already exists")
    fake_db[item.id] = item
    return item

Arquivo de teste estendido

Você pode então atualizar test_main.py com os testes estendidos:

from fastapi.testclient import TestClient

from .main import app

client = TestClient(app)


def test_read_item():
    response = client.get("/items/foo", headers={"X-Token": "coneofsilence"})
    assert response.status_code == 200
    assert response.json() == {
        "id": "foo",
        "title": "Foo",
        "description": "There goes my hero",
    }


def test_read_item_bad_token():
    response = client.get("/items/foo", headers={"X-Token": "hailhydra"})
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}


def test_read_nonexistent_item():
    response = client.get("/items/baz", headers={"X-Token": "coneofsilence"})
    assert response.status_code == 404
    assert response.json() == {"detail": "Item not found"}


def test_create_item():
    response = client.post(
        "/items/",
        headers={"X-Token": "coneofsilence"},
        json={"id": "foobar", "title": "Foo Bar", "description": "The Foo Barters"},
    )
    assert response.status_code == 200
    assert response.json() == {
        "id": "foobar",
        "title": "Foo Bar",
        "description": "The Foo Barters",
    }


def test_create_item_bad_token():
    response = client.post(
        "/items/",
        headers={"X-Token": "hailhydra"},
        json={"id": "bazz", "title": "Bazz", "description": "Drop the bazz"},
    )
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}


def test_create_existing_item():
    response = client.post(
        "/items/",
        headers={"X-Token": "coneofsilence"},
        json={
            "id": "foo",
            "title": "The Foo ID Stealers",
            "description": "There goes my stealer",
        },
    )
    assert response.status_code == 409
    assert response.json() == {"detail": "Item already exists"}

Sempre que você precisar que o cliente passe informações na requisição e não souber como, você pode pesquisar (no Google) como fazer isso no httpx, ou até mesmo como fazer isso com requests, já que o design do HTTPX é baseado no design do Requests.

Depois é só fazer o mesmo nos seus testes.

Por exemplo:

  • Para passar um parâmetro path ou query, adicione-o à própria URL.
  • Para passar um corpo JSON, passe um objeto Python (por exemplo, um dict) para o parâmetro json.
  • Se você precisar enviar Dados de Formulário em vez de JSON, use o parâmetro data.
  • Para passar headers, use um dict no parâmetro headers.
  • Para cookies, um dict no parâmetro cookies.

Para mais informações sobre como passar dados para o backend (usando httpx ou TestClient), consulte a documentação do HTTPX.

Informação

Observe que o TestClient recebe dados que podem ser convertidos para JSON, não para modelos Pydantic.

Se você tiver um modelo Pydantic em seu teste e quiser enviar seus dados para o aplicativo durante o teste, poderá usar o jsonable_encoder descrito em Codificador compatível com JSON.

Execute-o

Depois disso, você só precisa instalar o pytest.

Certifique-se de criar um ambiente virtual, ativá-lo e instalá-lo, por exemplo:

$ pip install pytest

---> 100%

Ele detectará os arquivos e os testes automaticamente, os executará e informará os resultados para você.

Execute os testes com:

$ pytest

================ test session starts ================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/user/code/superawesome-cli/app
plugins: forked-1.1.3, xdist-1.31.0, cov-2.8.1
collected 6 items

---> 100%

test_main.py <span style="color: green; white-space: pre;">......                            [100%]</span>

<span style="color: green;">================= 1 passed in 0.03s =================</span>