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âmetrojson
. - 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âmetroheaders
. - Para cookies, um
dict
no parâmetrocookies
.
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>