测试

感谢 Starlette,测试FastAPI 应用轻松又愉快。

它基于 HTTPX, 而HTTPX又是基于Requests设计的,所以很相似且易懂。

有了它,你可以直接与FastAPI一起使用 pytest

使用 TestClient

信息

要使用 TestClient,先要安装 httpx.

例:pip install httpx.

导入 TestClient.

通过传入你的FastAPI应用创建一个 TestClient

创建名字以 test_ 开头的函数(这是标准的 pytest 约定)。

像使用 httpx 那样使用 TestClient 对象。

为你需要检查的地方用标准的Python表达式写个简单的 assert 语句(重申,标准的pytest)。

  1. from fastapi import FastAPI
  2. from fastapi.testclient import TestClient
  3. app = FastAPI()
  4. @app.get("/")
  5. async def read_main():
  6. return {"msg": "Hello World"}
  7. client = TestClient(app)
  8. def test_read_main():
  9. response = client.get("/")
  10. assert response.status_code == 200
  11. assert response.json() == {"msg": "Hello World"}

提示

注意测试函数是普通的 def,不是 async def

还有client的调用也是普通的调用,不是用 await

这让你可以直接使用 pytest 而不会遇到麻烦。

技术细节

你也可以用 from starlette.testclient import TestClient

FastAPI 提供了和 starlette.testclient 一样的 fastapi.testclient,只是为了方便开发者。但它直接来自Starlette。

提示

除了发送请求之外,如果你还想测试时在FastAPI应用中调用 async 函数(例如异步数据库函数), 可以在高级教程中看下 Async Tests

分离测试

在实际应用中,你可能会把你的测试放在另一个文件里。

您的FastAPI应用程序也可能由一些文件/模块组成等等。

FastAPI app 文件

假设你有一个像 更大的应用 中所描述的文件结构:

  1. .
  2. ├── app
  3. ├── __init__.py
  4. └── main.py

main.py 文件中你有一个 FastAPI app:

  1. from fastapi import FastAPI
  2. app = FastAPI()
  3. @app.get("/")
  4. async def read_main():
  5. return {"msg": "Hello World"}

测试文件

然后你会有一个包含测试的文件 test_main.py 。app可以像Python包那样存在(一样是目录,但有个 __init__.py 文件):

  1. .
  2. ├── app
  3. ├── __init__.py
  4. ├── main.py
  5. └── test_main.py

因为这文件在同一个包中,所以你可以通过相对导入从 main 模块(main.py)导入app对象:

  1. from fastapi.testclient import TestClient
  2. from .main import app
  3. client = TestClient(app)
  4. def test_read_main():
  5. response = client.get("/")
  6. assert response.status_code == 200
  7. assert response.json() == {"msg": "Hello World"}

…然后测试代码和之前一样的。

测试:扩展示例

现在让我们扩展这个例子,并添加更多细节,看下如何测试不同部分。

扩展后的 FastAPI app 文件

让我们继续之前的文件结构:

  1. .
  2. ├── app
  3. ├── __init__.py
  4. ├── main.py
  5. └── test_main.py

假设现在包含FastAPI app的文件 main.py 有些其他路径操作

有个 GET 操作会返回错误。

有个 POST 操作会返回一些错误。

所有路径操作 都需要一个X-Token 头。

Python 3.10+Python 3.9+Python 3.8+Python 3.10+ non-AnnotatedPython 3.8+ non-Annotated

  1. from typing import Annotated
  2. from fastapi import FastAPI, Header, HTTPException
  3. from pydantic import BaseModel
  4. fake_secret_token = "coneofsilence"
  5. fake_db = {
  6. "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
  7. "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
  8. }
  9. app = FastAPI()
  10. class Item(BaseModel):
  11. id: str
  12. title: str
  13. description: str | None = None
  14. @app.get("/items/{item_id}", response_model=Item)
  15. async def read_main(item_id: str, x_token: Annotated[str, Header()]):
  16. if x_token != fake_secret_token:
  17. raise HTTPException(status_code=400, detail="Invalid X-Token header")
  18. if item_id not in fake_db:
  19. raise HTTPException(status_code=404, detail="Item not found")
  20. return fake_db[item_id]
  21. @app.post("/items/", response_model=Item)
  22. async def create_item(item: Item, x_token: Annotated[str, Header()]):
  23. if x_token != fake_secret_token:
  24. raise HTTPException(status_code=400, detail="Invalid X-Token header")
  25. if item.id in fake_db:
  26. raise HTTPException(status_code=400, detail="Item already exists")
  27. fake_db[item.id] = item
  28. return item
  1. from typing import Annotated, Union
  2. from fastapi import FastAPI, Header, HTTPException
  3. from pydantic import BaseModel
  4. fake_secret_token = "coneofsilence"
  5. fake_db = {
  6. "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
  7. "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
  8. }
  9. app = FastAPI()
  10. class Item(BaseModel):
  11. id: str
  12. title: str
  13. description: Union[str, None] = None
  14. @app.get("/items/{item_id}", response_model=Item)
  15. async def read_main(item_id: str, x_token: Annotated[str, Header()]):
  16. if x_token != fake_secret_token:
  17. raise HTTPException(status_code=400, detail="Invalid X-Token header")
  18. if item_id not in fake_db:
  19. raise HTTPException(status_code=404, detail="Item not found")
  20. return fake_db[item_id]
  21. @app.post("/items/", response_model=Item)
  22. async def create_item(item: Item, x_token: Annotated[str, Header()]):
  23. if x_token != fake_secret_token:
  24. raise HTTPException(status_code=400, detail="Invalid X-Token header")
  25. if item.id in fake_db:
  26. raise HTTPException(status_code=400, detail="Item already exists")
  27. fake_db[item.id] = item
  28. return item
  1. from typing import Union
  2. from fastapi import FastAPI, Header, HTTPException
  3. from pydantic import BaseModel
  4. from typing_extensions import Annotated
  5. fake_secret_token = "coneofsilence"
  6. fake_db = {
  7. "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
  8. "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
  9. }
  10. app = FastAPI()
  11. class Item(BaseModel):
  12. id: str
  13. title: str
  14. description: Union[str, None] = None
  15. @app.get("/items/{item_id}", response_model=Item)
  16. async def read_main(item_id: str, x_token: Annotated[str, Header()]):
  17. if x_token != fake_secret_token:
  18. raise HTTPException(status_code=400, detail="Invalid X-Token header")
  19. if item_id not in fake_db:
  20. raise HTTPException(status_code=404, detail="Item not found")
  21. return fake_db[item_id]
  22. @app.post("/items/", response_model=Item)
  23. async def create_item(item: Item, x_token: Annotated[str, Header()]):
  24. if x_token != fake_secret_token:
  25. raise HTTPException(status_code=400, detail="Invalid X-Token header")
  26. if item.id in fake_db:
  27. raise HTTPException(status_code=400, detail="Item already exists")
  28. fake_db[item.id] = item
  29. return item

Tip

Prefer to use the Annotated version if possible.

  1. from fastapi import FastAPI, Header, HTTPException
  2. from pydantic import BaseModel
  3. fake_secret_token = "coneofsilence"
  4. fake_db = {
  5. "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
  6. "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
  7. }
  8. app = FastAPI()
  9. class Item(BaseModel):
  10. id: str
  11. title: str
  12. description: str | None = None
  13. @app.get("/items/{item_id}", response_model=Item)
  14. async def read_main(item_id: str, x_token: str = Header()):
  15. if x_token != fake_secret_token:
  16. raise HTTPException(status_code=400, detail="Invalid X-Token header")
  17. if item_id not in fake_db:
  18. raise HTTPException(status_code=404, detail="Item not found")
  19. return fake_db[item_id]
  20. @app.post("/items/", response_model=Item)
  21. async def create_item(item: Item, x_token: str = Header()):
  22. if x_token != fake_secret_token:
  23. raise HTTPException(status_code=400, detail="Invalid X-Token header")
  24. if item.id in fake_db:
  25. raise HTTPException(status_code=400, detail="Item already exists")
  26. fake_db[item.id] = item
  27. return item

Tip

Prefer to use the Annotated version if possible.

  1. from typing import Union
  2. from fastapi import FastAPI, Header, HTTPException
  3. from pydantic import BaseModel
  4. fake_secret_token = "coneofsilence"
  5. fake_db = {
  6. "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
  7. "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
  8. }
  9. app = FastAPI()
  10. class Item(BaseModel):
  11. id: str
  12. title: str
  13. description: Union[str, None] = None
  14. @app.get("/items/{item_id}", response_model=Item)
  15. async def read_main(item_id: str, x_token: str = Header()):
  16. if x_token != fake_secret_token:
  17. raise HTTPException(status_code=400, detail="Invalid X-Token header")
  18. if item_id not in fake_db:
  19. raise HTTPException(status_code=404, detail="Item not found")
  20. return fake_db[item_id]
  21. @app.post("/items/", response_model=Item)
  22. async def create_item(item: Item, x_token: str = Header()):
  23. if x_token != fake_secret_token:
  24. raise HTTPException(status_code=400, detail="Invalid X-Token header")
  25. if item.id in fake_db:
  26. raise HTTPException(status_code=400, detail="Item already exists")
  27. fake_db[item.id] = item
  28. return item

扩展后的测试文件

然后您可以使用扩展后的测试更新test_main.py

  1. from fastapi.testclient import TestClient
  2. from .main import app
  3. client = TestClient(app)
  4. def test_read_item():
  5. response = client.get("/items/foo", headers={"X-Token": "coneofsilence"})
  6. assert response.status_code == 200
  7. assert response.json() == {
  8. "id": "foo",
  9. "title": "Foo",
  10. "description": "There goes my hero",
  11. }
  12. def test_read_item_bad_token():
  13. response = client.get("/items/foo", headers={"X-Token": "hailhydra"})
  14. assert response.status_code == 400
  15. assert response.json() == {"detail": "Invalid X-Token header"}
  16. def test_read_inexistent_item():
  17. response = client.get("/items/baz", headers={"X-Token": "coneofsilence"})
  18. assert response.status_code == 404
  19. assert response.json() == {"detail": "Item not found"}
  20. def test_create_item():
  21. response = client.post(
  22. "/items/",
  23. headers={"X-Token": "coneofsilence"},
  24. json={"id": "foobar", "title": "Foo Bar", "description": "The Foo Barters"},
  25. )
  26. assert response.status_code == 200
  27. assert response.json() == {
  28. "id": "foobar",
  29. "title": "Foo Bar",
  30. "description": "The Foo Barters",
  31. }
  32. def test_create_item_bad_token():
  33. response = client.post(
  34. "/items/",
  35. headers={"X-Token": "hailhydra"},
  36. json={"id": "bazz", "title": "Bazz", "description": "Drop the bazz"},
  37. )
  38. assert response.status_code == 400
  39. assert response.json() == {"detail": "Invalid X-Token header"}
  40. def test_create_existing_item():
  41. response = client.post(
  42. "/items/",
  43. headers={"X-Token": "coneofsilence"},
  44. json={
  45. "id": "foo",
  46. "title": "The Foo ID Stealers",
  47. "description": "There goes my stealer",
  48. },
  49. )
  50. assert response.status_code == 400
  51. assert response.json() == {"detail": "Item already exists"}

每当你需要客户端在请求中传递信息,但你不知道如何传递时,你可以通过搜索(谷歌)如何用 httpx做,或者是用 requests 做,毕竟HTTPX的设计是基于Requests的设计的。

接着只需在测试中同样操作。

示例:

  • 传一个路径查询 参数,添加到URL上。
  • 传一个JSON体,传一个Python对象(例如一个dict)到参数 json
  • 如果你需要发送 Form Data 而不是 JSON,使用 data 参数。
  • 要发送 headers,传 dictheaders 参数。
  • 对于 cookies,传 dictcookies 参数。

关于如何传数据给后端的更多信息 (使用httpxTestClient),请查阅 HTTPX 文档.

信息

注意 TestClient 接收可以被转化为JSON的数据,而不是Pydantic模型。

如果你在测试中有一个Pydantic模型,并且你想在测试时发送它的数据给应用,你可以使用在JSON Compatible Encoder介绍的jsonable_encoder

运行起来

之后,你只需要安装 pytest:

  1. $ pip install pytest
  2. ---> 100%

他会自动检测文件和测试,执行测试,然后向你报告结果。

执行测试:

  1. $ pytest
  2. ================ test session starts ================
  3. platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
  4. rootdir: /home/user/code/superawesome-cli/app
  5. plugins: forked-1.1.3, xdist-1.31.0, cov-2.8.1
  6. collected 6 items
  7. ---> 100%
  8. test_main.py <span style="color: green; white-space: pre;">...... [100%]</span>
  9. <span style="color: green;">================= 1 passed in 0.03s =================</span>