Skip to content

測試

🌐 AI 與人類共同完成的翻譯

此翻譯由人類指導的 AI 完成。🤝

可能會有對原意的誤解,或讀起來不自然等問題。🤖

你可以透過協助我們更好地引導 AI LLM來改進此翻譯。

英文版

多虧了 Starlette,測試 FastAPI 應用既簡單又好用。

它是基於 HTTPX 打造,而 HTTPX 的設計又參考了 Requests,所以用起來非常熟悉、直覺。

借助它,你可以直接用 pytest 來測試 FastAPI

使用 TestClient

Info

要使用 TestClient,請先安裝 httpx

請先建立並啟用一個虛擬環境,然後安裝,例如:

$ pip install httpx

匯入 TestClient

建立一個 TestClient,把你的 FastAPI 應用傳入其中。

建立名稱以 test_ 開頭的函式(這是 pytest 的慣例)。

像使用 httpx 一樣使用 TestClient 物件。

用簡單的 assert 敘述搭配標準的 Python 運算式來檢查(同樣是 pytest 的標準用法)。

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

Tip

注意測試函式是一般的 def,不是 async def

而且對 client 的呼叫也都是一般呼叫,不需要使用 await

這讓你可以直接使用 pytest,不必費心處理非同步。

技術細節

你也可以使用 from starlette.testclient import TestClient

FastAPI 為了方便開發者,也提供與 starlette.testclient 相同的 fastapi.testclient。但它其實直接來自 Starlette。

Tip

如果你想在測試中呼叫其他 async 函式,而不只是對 FastAPI 應用發送請求(例如非同步的資料庫函式),請參考進階教學中的非同步測試

分離測試

在真實專案中,你大概會把測試放在不同的檔案中。

你的 FastAPI 應用也可能由多個檔案/模組組成,等等。

FastAPI 應用檔案

假設你的檔案結構如更大型的應用所述:

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

main.py 檔案中有你的 FastAPI 應用:

from fastapi import FastAPI

app = FastAPI()


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

測試檔案

然後你可以建立一個 test_main.py 放你的測試。它可以與應用位於同一個 Python 套件(同一個包含 __init__.py 的目錄):

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

因為這個檔案在同一個套件中,你可以使用相對匯入,從 main 模組(main.py)匯入 app 這個物件:

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

...然後測試的程式碼就和先前一樣。

測試:進階範例

現在我們延伸這個範例並加入更多細節,看看如何測試不同部分。

擴充的 FastAPI 應用檔案

沿用先前相同的檔案結構:

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

假設現在你的 FastAPI 應用所在的 main.py 有一些其他的路徑操作(path operations)。

它有一個可能回傳錯誤的 GET 操作。

它有一個可能回傳多種錯誤的 POST 操作。

兩個路徑操作都需要一個 X-Token 標頭(header)。

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/")
async def create_item(item: Item, x_token: Annotated[str, Header()]) -> Item:
    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.model_dump()
    return item
🤓 Other versions and variants

Tip

Prefer to use the Annotated version if possible.

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/")
async def create_item(item: Item, x_token: str = Header()) -> Item:
    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.model_dump()
    return item

擴充的測試檔案

接著你可以把 test_main.py 更新為擴充後的測試:

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"}
🤓 Other versions and variants

Tip

Prefer to use the Annotated version if possible.

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

每當你需要在請求中讓 client 帶一些資料,但不確定該怎麼做時,你可以搜尋(Google)在 httpx 要如何傳遞,甚至用 Requests 怎麼做,因為 HTTPX 的設計是基於 Requests。

然後在你的測試中做一樣的事即可。

例如:

  • 要傳遞路徑或查詢參數,直接把它加在 URL 上。
  • 要傳遞 JSON 本文,將 Python 物件(例如 dict)傳給 json 參數。
  • 如果需要送出表單資料(Form Data)而不是 JSON,改用 data 參數。
  • 要傳遞標頭(headers),在 headers 參數中放一個 dict
  • 對於 Cookie(cookies),在 cookies 參數中放一個 dict

關於如何把資料傳給後端(使用 httpxTestClient),更多資訊請參考 HTTPX 文件

Info

請注意,TestClient 接收的是可轉為 JSON 的資料,而不是 Pydantic models。

如果你的測試裡有一個 Pydantic model,並想在測試時把它的資料送給應用,你可以使用JSON 相容編碼器中介紹的 jsonable_encoder

執行

接下來,你只需要安裝 pytest

請先建立並啟用一個虛擬環境,然後安裝,例如:

$ pip install pytest

---> 100%

它會自動偵測檔案與測試、執行它們,並把結果回報給你。

用以下指令執行測試:

$ 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>