Skip to content

测试应用行为 ​

文章页面能打开,不代表创建、错误处理或应用启动都正常。手动访问可以快速看效果;当功能逐渐增多,重复检查同一条路径就需要可运行的测试。本专题用一个小型文章应用,依次验证 HTTP、静态资源、Lifespan 和 WebSocket。

案例由 应用代码与测试文件组成。TestClient 在进程内向 Starlette 应用发送请求,不需要先启动 Uvicorn。测试仍按真实接口检查状态码和响应内容。

验证首页响应 ​

pytest 会发现 test_*.py 中以 test_ 开头的函数。assert 表达预期;条件不成立时,pytest 会显示失败位置和实际值。先验证首页:

python
from starlette.testclient import TestClient

from article_app import app


def test_homepage():
    with TestClient(app) as client:
        response = client.get("/")
        assert response.status_code == 200
        assert "Starlette" in response.text

这里的 response 包含状态码、响应头和响应体。检查 200 只能说明请求成功,还要检查页面中应出现的内容。文章列表与详情页也采用同样思路,但断言各自的标题或文章内容。

with TestClient(app) 会进入应用的 Lifespan。案例的文章数据正是在启动时创建的;若只构造客户端却不进入上下文,依赖这些数据的请求无法按预期运行。

TestClient 进程内直驱架构与 Lifespan 上下文

验证文章创建与错误路径 ​

接口返回 JSON 时,用 response.json() 读取响应体。创建文章要同时检查 201 和实际创建的标题:

python
def test_create_article():
    with TestClient(app) as client:
        response = client.post(
            "/api/articles",
            json={"title": "测试文章", "summary": "测试摘要", "content": "测试正文"},
        )
        assert response.status_code == 201
        assert response.json()["article"]["title"] == "测试文章"

这个函数可以与前面的导入放在同一个测试文件中。每个用例重新进入应用上下文,文章列表会从初始数据开始,避免一个用例创建的文章影响另一个用例。

成功路径之外,还要检查空标题返回 400、文章不存在返回 404。错误响应的断言应使用实际状态码,不能只检查响应体里有没有“失败”字样。完整测试文件还验证了文章 JSON 列表的结构。

API 业务断言矩阵与用例沙箱隔离

验证启动状态与静态资源 ​

GET /app-info 会读取 Lifespan 初始化的配置和文章数量。测试进入客户端上下文后发送请求,再检查返回的字段:

python
def test_app_info():
    with TestClient(app) as client:
        response = client.get("/app-info")
        assert response.status_code == 200
        data = response.json()
        assert "config" in data
        assert "article_count" in data

静态资源也要验证内容,而不仅是路径存在。案例请求 /static/style.css,检查返回 200 且响应体包含样式规则。这样能发现目录挂载错误或文件缺失。

Lifespan 状态注入断言与 StaticFiles 挂载检验

验证 WebSocket 回显 ​

WebSocket 使用持续连接,不能用普通的 client.get() 代替。连接建立后发送一条文本消息,再接收服务端回显:

python
def test_websocket_echo():
    with TestClient(app) as client:
        with client.websocket_connect("/ws") as websocket:
            websocket.send_text("你好")
            data = websocket.receive_text()
            assert data == "服务端回显:你好"

两个上下文分别管理应用生命周期和 WebSocket 连接。若用例卡在接收消息,先检查服务端是否已 accept()、路径是否正确,以及预期文本是否与处理函数一致。

WebSocket 测试:双重上下文管理与消息断言时序

运行完整测试 ​

在 _code_starlette/p04c01 中执行:

bash
python -m pytest -q tests/test_article_app.py

通过时应看到 10 个用例成功。需要定位单个问题,可运行 python -m pytest -q tests/test_article_app.py::test_websocket_echo。本专题的测试范围刻意保持紧凑:一个小应用覆盖页面、API、错误、静态资源、启动状态与实时连接,下一专题再把验证方法用于完整文章管理项目。

案例源码 ​

py
from contextlib import asynccontextmanager
from html import escape
from pathlib import Path

from starlette.applications import Starlette
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse, JSONResponse
from starlette.routing import Mount, Route, WebSocketRoute
from starlette.staticfiles import StaticFiles
from starlette.websockets import WebSocket, WebSocketDisconnect


@asynccontextmanager
async def lifespan(app):
    app.state.config = {"app_name": "Starlette 测试示例"}
    app.state.articles = [
        {"id": 1, "title": "认识 Starlette 应用对象"},
        {"id": 2, "title": "测试文章接口"},
    ]
    yield


def find_article(articles, article_id):
    return next((item for item in articles if item["id"] == article_id), None)


async def homepage(request):
    return HTMLResponse("<h1>Starlette 文章服务</h1>")


async def article_list(request):
    titles = "".join(
        f"<li>{escape(article['title'])}</li>"
        for article in request.app.state.articles
    )
    return HTMLResponse(f"<h1>文章列表</h1><ul>{titles}</ul>")


async def article_detail(request):
    article = find_article(
        request.app.state.articles, request.path_params["article_id"]
    )
    if article is None:
        raise HTTPException(status_code=404, detail="文章不存在")
    return HTMLResponse(f"<h1>{escape(article['title'])}</h1>")


async def api_articles(request):
    if request.method == "GET":
        return JSONResponse({"items": request.app.state.articles})

    data = await request.json()
    title = str(data.get("title", "")).strip()
    if not title:
        raise HTTPException(status_code=400, detail="文章标题不能为空")
    articles = request.app.state.articles
    article = {"id": len(articles) + 1, "title": title}
    articles.append(article)
    return JSONResponse({"article": article}, status_code=201)


async def app_info(request):
    return JSONResponse({
        "config": request.app.state.config,
        "article_count": len(request.app.state.articles),
    })


async def websocket_echo(websocket: WebSocket):
    await websocket.accept()
    try:
        while True:
            message = await websocket.receive_text()
            await websocket.send_text(f"服务端回显:{message}")
    except WebSocketDisconnect:
        pass


async def http_exception_handler(request, exc):
    return JSONResponse(
        {"code": exc.status_code, "message": exc.detail},
        status_code=exc.status_code,
    )


BASE_DIR = Path(__file__).parent
app = Starlette(
    routes=[
        Route("/", homepage),
        Route("/articles", article_list),
        Route("/articles/{article_id:int}", article_detail),
        Route("/api/articles", api_articles, methods=["GET", "POST"]),
        Route("/app-info", app_info),
        WebSocketRoute("/ws", websocket_echo),
        Mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static"),
    ],
    exception_handlers={HTTPException: http_exception_handler},
    lifespan=lifespan,
)

静态样式 ​

css
body {
    font-family: sans-serif;
    line-height: 1.5;
}

测试文件 ​

py
from starlette.testclient import TestClient

from article_app import app


def test_homepage():
    with TestClient(app) as client:
        response = client.get("/")
        assert response.status_code == 200
        assert "Starlette" in response.text


def test_article_list():
    with TestClient(app) as client:
        response = client.get("/articles")
        assert response.status_code == 200
        assert "文章列表" in response.text


def test_article_detail():
    with TestClient(app) as client:
        response = client.get("/articles/1")
        assert response.status_code == 200
        assert "认识 Starlette 应用对象" in response.text


def test_api_articles():
    with TestClient(app) as client:
        response = client.get("/api/articles")
        assert response.status_code == 200
        data = response.json()
        assert "items" in data
        assert isinstance(data["items"], list)


def test_create_article():
    with TestClient(app) as client:
        response = client.post(
            "/api/articles",
            json={
                "title": "测试文章",
                "summary": "测试摘要",
                "content": "测试正文",
            },
        )
        assert response.status_code == 201
        data = response.json()
        assert data["article"]["title"] == "测试文章"


def test_create_article_without_title():
    with TestClient(app) as client:
        response = client.post(
            "/api/articles",
            json={"title": "", "summary": "摘要", "content": "正文"},
        )
        assert response.status_code == 400
        assert response.json()["message"] == "文章标题不能为空"


def test_article_not_found():
    with TestClient(app) as client:
        response = client.get("/articles/999")
        assert response.status_code == 404


def test_static_file():
    with TestClient(app) as client:
        response = client.get("/static/style.css")
        assert response.status_code == 200
        assert "body" in response.text


def test_app_info():
    with TestClient(app) as client:
        response = client.get("/app-info")
        assert response.status_code == 200
        data = response.json()
        assert "config" in data
        assert "article_count" in data


def test_websocket_echo():
    with TestClient(app) as client:
        with client.websocket_connect("/ws") as websocket:
            websocket.send_text("你好")
            data = websocket.receive_text()
            assert data == "服务端回显:你好"