FastAPI:(14)静态文件,测试


FastAPI:(14)静态文件,测试

概述

文章内容概括

flowchart TD
    %% 核心概念
    A[挂载] 
    B[静态文件]
    C[测试]

    %% 子概念
    A1[子应用]
    A2[静态资源]
    C1[端到端测试]
    C2[单元测试]
    C3[集成测试]

    %% 概念间关系
    A -->|方式| A1
    A -->|对象| B
    B -->|应用场景| A2
    B -->|需要| C
    C -->|包含| C1
    C -->|包含| C2
    C -->|包含| C3

1. 静态文件

挂载

挂载(Mount)是指将某些资源(如静态文件目录、子应用或文件系统的路径)挂载到主应用的指定路径下,从而将这些资源作为应用的一部分提供给客户端。通过挂载,FastAPI允许将一些独立的服务或资源集成到应用中,而不需要改变原应用的逻辑。

挂载可以通过app.mount()方法来实现,它接受三个参数:

  • path:挂载的路径;
  • app:要挂载的子应用或资源;
  • name:挂载的名称。

挂载的资源通常是独立的服务、文件目录、或其他应用程序,它们可以有自己的独立生命周期和配置。

重要特征:

  • 子应用支持:可以将一个子应用挂载到主应用的某个路径下,子应用独立运行,但它的一部分功能将成为主应用的一部分。
  • 资源挂载:除了子应用,也可以挂载静态文件、文件系统路径等资源,允许主应用直接访问它们。
  • 路径隔离:挂载的路径独立于主应用路径,并且可以提供特定的URL处理或静态文件服务。
  • 不干扰主应用:挂载的资源不会直接影响主应用的路由逻辑,主应用可以独立运行,不受干扰。

挂载静态文件夹(正例)

挂载

例子描述:
在FastAPI应用中,挂载一个静态文件目录,以便访问如图片、CSS、JavaScript等静态资源。通过app.mount()方法将静态文件目录挂载到/static路径下,这样用户访问/static路径时会直接获取对应的静态文件。

特征对比:

  • 资源挂载:通过挂载静态文件目录,使得FastAPI应用能够提供静态资源,且不需要额外的路由逻辑。
  • 路径隔离:静态文件被挂载到/static路径下,访问该路径时,主应用的其他路由不受影响。
  • 不干扰主应用:挂载的静态资源目录与主应用的逻辑独立,不会对主应用的API逻辑产生影响。
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

app = FastAPI()

# 将 "static" 文件夹挂载到 /static 路径下
app.mount("/static", StaticFiles(directory="static"), name="static")

挂载子应用(正例)

挂载

例子描述:
FastAPI支持将多个子应用挂载到主应用中,例如,你可以将一个用户认证系统作为子应用挂载到主应用的/auth路径下,这样用户访问/auth时会调用该子应用的路由。

特征对比:

  • 子应用支持:通过挂载子应用,将子应用的功能集成到主应用中,使得子应用具有独立的路由和功能。
  • 路径隔离:子应用的路由被挂载到指定的路径下,主应用的其他路由不受影响。
  • 不干扰主应用:子应用可以独立运行,而不会影响主应用的其他功能或API接口。
from fastapi import FastAPI, APIRouter

# 创建一个子应用(路由)
auth_router = APIRouter()

@auth_router.get("/login")
async def login():
    return {"message": "Login page"}

# 创建主应用
app = FastAPI()

# 将子应用挂载到主应用的 /auth 路径下
app.include_router(auth_router, prefix="/auth")

官网挂载子应用(正例)

挂载

如果需要两个独立的 FastAPI 应用,拥有各自独立的 OpenAPI 与文档,则需设置一个主应用,并挂载一个(或多个)子应用。

from fastapi import FastAPI

app = FastAPI() # 创建主(顶层)应用


@app.get("/app")
def read_main():
    return {"message": "Hello World from main app"}


subapi = FastAPI() # 创建子应用


@subapi.get("/sub")
def read_sub():
    return {"message": "Hello World from sub API"}


app.mount("/subapi", subapi) # 「挂载」子应用

运行app后访问各自的文档:
主应用API文档:http://127.0.0.1:8000/docs

访问子应用API文档:http://127.0.0.1:8000/subapi/docs

错误的挂载路径(反例)

挂载

例子描述:
尝试将一个子应用挂载到主应用的根路径/下,导致根路径的所有请求都进入了子应用的路由逻辑,从而干扰了主应用的行为。

特征对比:

  • 路径隔离:根路径/应该由主应用控制,而挂载到根路径的子应用可能会使主应用的其他路由无法正常工作,违反了路径隔离的特性。
  • 不干扰主应用:子应用应当挂载在独立的路径下,而将其挂载到根路径上,可能会导致主应用的其他路由无法访问或发生冲突。
from fastapi import FastAPI, APIRouter

# 创建一个子应用(路由)
auth_router = APIRouter()

@auth_router.get("/login")
async def login():
    return {"message": "Login page"}

# 创建主应用
app = FastAPI()

# 错误示范:将子应用挂载到根路径,导致主应用的路由不可访问
app.include_router(auth_router, prefix="/")  # 不推荐挂载到根路径

静态文件

静态文件是指在Web应用中,不需要任何后台处理即可直接呈现给用户的文件。例如,HTML文件、CSS样式表、JavaScript文件、图片、字体文件等。通常这些文件在应用启动时就已经准备好,不会发生动态变化。静态文件的特点在于,它们不依赖于数据库或实时计算,且可以通过HTTP协议直接传输给客户端。

重要特征:

  • 无需后台处理:静态文件直接由Web服务器或FastAPI通过文件系统提供,无需额外的计算或业务逻辑支持。
  • 固定内容:文件内容在服务器和客户端之间传输时保持不变,如图片、样式、脚本等。
  • 高效缓存:由于其固定不变,静态文件可以通过浏览器缓存或代理服务器缓存以提高性能。
  • 直接映射:文件路径和URL之间的映射关系是固定的,用户可以直接访问该路径来获得文件。

静态资源服务(正例)

静态文件

例子描述:
在一个简单的Web应用中,静态文件如index.htmlstyle.cssapp.js被放在一个名为static的文件夹中,FastAPI配置了一个静态文件路径,将此文件夹暴露给客户端请求访问。

特征对比:

  • 无需后台处理:这些文件通过FastAPI的StaticFiles组件直接映射到HTTP请求,而不需要任何额外的处理。
  • 固定内容index.htmlstyle.cssapp.js等文件内容是固定的,直接传输给客户端,不进行动态生成或计算。
  • 高效缓存:这些静态文件可以在客户端缓存,避免重复加载。
  • 直接映射:浏览器请求/static/index.html时,FastAPI会直接返回该文件。
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

app = FastAPI()

# 将 "static" 文件夹中的文件暴露为静态文件
app.mount("/static", StaticFiles(directory="static"), name="static")

细节:
这个 “静态文件” 会被 “挂载” 到第一个 "/static" 指向的子路径。因此,任何以"/static"开头的路径都会被它处理。
directory="static" 指向包含你的静态文件的目录名字。
name="static" 提供了一个能被FastAPI内部使用的名字。
所有这些参数可以不同于"static",根据应用的需要和具体细节调整它们。

上传的文件作为静态资源(正例)

静态文件

例子描述:
用户上传的图片文件会被保存在服务器的某个目录下,并通过FastAPI提供下载链接,让其他用户能够访问和下载这些图片文件。

特征对比:

  • 无需后台处理:上传的图片文件在上传时保存到服务器,而通过FastAPI直接提供给用户下载。
  • 固定内容:上传的图片内容保持不变,用户下载的文件是直接从磁盘读取。
  • 高效缓存:可以为上传的图片设置合适的HTTP缓存头,减少不必要的重复请求。
  • 直接映射:通过定义/uploads/{filename} URL,用户可以直接访问上传的文件。

动态作为静态(反例)

例子描述:
在FastAPI中,将动态生成的HTML文件(如根据用户请求生成的页面)作为静态文件来返回。尽管文件内容由后端动态生成,但却将其当作静态文件处理并提供给用户。

特征对比:

  • 无需后台处理:动态生成的文件应该由后台处理,但此例中将其误作为静态文件,违反了不需要后台计算的特征。
  • 固定内容:动态生成的内容不是固定的,用户请求时内容会根据输入变化,这与静态文件的特性不符。
  • 高效缓存:动态内容应该使用适当的缓存策略,而不是将其当作静态文件来处理,因为内容是变化的。
  • 直接映射:将动态生成的内容误作为静态文件处理会导致路径和内容的映射关系不一致。
from fastapi import FastAPI, Response
from fastapi.staticfiles import StaticFiles
import random

app = FastAPI()

# 错误示范:动态生成内容误当静态文件处理
@app.get("/dynamic_page")
async def dynamic_page():
    content = f"<html><body><h1>随机数: {random.randint(1, 100)}</h1></body></html>"
    return Response(content=content, media_type="text/html")

2. 测试

测试

测试是指在开发过程中对应用的各个部分进行验证,确保它们按照预期工作。FastAPI支持多种测试方法,尤其是在API端点和业务逻辑上。测试可以通过模拟请求、验证响应、检查异常等方式,确保应用的正确性和健壮性。FastAPI内置的测试功能基于Python的pytest框架和TestClient,允许开发者在不启动真正的服务器的情况下直接对API进行测试。

FastAPI中的测试通常关注以下几个方面:

  • 端到端测试 (E2E):模拟实际的HTTP请求,检查API接口的返回结果。
  • 单元测试 (Unit Test):验证应用的独立模块、函数或类是否按预期工作。
  • 集成测试 (Integration Test):检查多个组件协作时的行为,确保它们之间的交互正常。

重要特征:

  • 模拟请求:FastAPI提供TestClient类,可以模拟HTTP请求而不需要启动实际的服务器。
  • 端到端验证:测试通过模拟用户的HTTP请求,直接验证整个API接口的行为,包括请求、响应以及状态码等。
  • 响应断言:可以通过assert语句来验证API返回的响应是否符合预期,包括内容、状态码和响应头等。
  • 集成与单元测试支持:FastAPI非常容易与pytest集成进行单元和集成测试,可以模拟数据库操作,验证接口的正确性。

注意测试函数是普通的 def,不是 async def
还有client的调用也是普通的调用,不是用 await
这使得可以直接使用 pytest 而不会遇到麻烦。

API端点的基本测试(正例)

测试

例子描述:
我们创建一个简单的FastAPI应用,包含一个返回"Hello, World!"的API端点,并使用TestClient对该端点进行测试,确保其返回200状态码以及正确的响应内容。

特征对比:

  • 模拟请求:使用TestClient模拟GET请求,无需启动服务器。
  • 端到端验证:测试模拟了一个完整的API请求并验证了返回的状态码和内容。
  • 响应断言:使用assert验证返回的响应是否符合预期。
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

@app.get("/hello")
async def hello():
    return {"message": "Hello, World!"}

# 创建TestClient进行测试
client = TestClient(app)

def test_hello():
    response = client.get("/hello")
    assert response.status_code == 200  # 验证状态码
    assert response.json() == {"message": "Hello, World!"}  # 验证返回的JSON内容

带参数的API测试(正例)

测试

例子描述:
FastAPI提供了处理带参数的路径和查询字符串的功能。在这个例子中,我们将测试一个简单的加法API,它接受两个数字作为路径参数,并返回它们的和。

特征对比:

  • 模拟请求:通过TestClient模拟GET请求,带有路径参数。
  • 端到端验证:测试验证了API处理路径参数并返回正确结果的能力。
  • 响应断言:通过assert确保返回结果与预期一致。
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

@app.get("/add/{a}/{b}")
async def add(a: int, b: int):
    return {"sum": a + b}

# 创建TestClient进行测试
client = TestClient(app)

def test_add():
    response = client.get("/add/3/5")
    assert response.status_code == 200  # 验证状态码
    assert response.json() == {"sum": 8}  # 验证返回的计算结果

错误的API测试(反例)

测试

例子描述:
在这个反例中,我们尝试测试一个API端点,但故意将返回的数据与期望值不匹配。这将验证一个错误的测试用例,确保错误能够被及时捕获。

特征对比:

  • 模拟请求:同样使用TestClient模拟请求,但故意设置了不正确的期望值。
  • 端到端验证:测试虽然模拟了API请求,但通过错误的断言来检验结果,导致测试失败。
  • 响应断言错误:使用错误的断言与API的真实返回不一致,导致测试失败。
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

@app.get("/hello")
async def hello():
    return {"message": "Hello, World!"}

# 创建TestClient进行测试
client = TestClient(app)

def test_hello_error():
    response = client.get("/hello")
    assert response.status_code == 200  # 验证状态码(正确)
    assert response.json() == {"message": "Hello, Error!"}  # 错误的断言,导致测试失败

官网分离测试(正例)

测试

在实际应用中,可能会把你的测试放在另一个文件里。FastAPI应用程序也可能由一些文件/模块组成等等。

# 一个包含测试的文件 `test_main.py` 。app可以像Python包那样存在(一样是目录,但有个 `__init__.py` 文件)
.
├── app
│   ├── __init__.py
│   ├── main.py
│   └── test_main.py
#  `main.py` 文件中你有一个 **FastAPI** app
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}
# 包含测试的文件 `test_main.py` 。app可以像Python包那样存在(一样是目录,但有个 `__init__.py` 文件):
from fastapi.testclient import TestClient

from .main import app # 从main模块中导入app对象

client = TestClient(app)


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

官网扩展测试例子(正例)

测试

测试文件结构

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

main.py文件
有个 GET 操作会返回错误。
有个 POST 操作会返回一些错误。
所有_路径操作_ 都需要一个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

测试文件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"}

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

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

  • 传一个_路径_ 或_查询_ 参数,添加到URL上。
  • 传一个JSON体,传一个Python对象(例如一个dict)到参数 json
  • 如果你需要发送 Form Data 而不是 JSON,使用 data 参数。
  • 要发送 headers,传 dict 给 headers 参数。
  • 对于 cookies,传 dict 给 cookies 参数。
    关于如何传数据给后端的更多信息 (使用httpx 或 TestClient),请查阅 HTTPX 文档.

注意 TestClient 接收可以被转化为JSON的数据,而不是Pydantic模型。
如果你在测试中有一个Pydantic模型,并且你想在测试时发送它的数据给应用,你可以使用在JSON Compatible Encoder介绍的jsonable_encoder 。


文章作者: Hkini
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Hkini !
评论
  目录