测试应用行为
文章页面能打开,不代表创建、错误处理或应用启动都正常。手动访问可以快速看效果;当功能逐渐增多,重复检查同一条路径就需要可运行的测试。本专题用一个小型文章应用,依次验证 HTTP、静态资源、Lifespan 和 WebSocket。
案例由 应用代码与测试文件组成。TestClient 在进程内向 Starlette 应用发送请求,不需要先启动 Uvicorn。测试仍按真实接口检查状态码和响应内容。
验证首页响应
pytest 会发现 test_*.py 中以 test_ 开头的函数。assert 表达预期;条件不成立时,pytest 会显示失败位置和实际值。先验证首页:
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。案例的文章数据正是在启动时创建的;若只构造客户端却不进入上下文,依赖这些数据的请求无法按预期运行。
验证文章创建与错误路径
接口返回 JSON 时,用 response.json() 读取响应体。创建文章要同时检查 201 和实际创建的标题:
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 列表的结构。
验证启动状态与静态资源
GET /app-info 会读取 Lifespan 初始化的配置和文章数量。测试进入客户端上下文后发送请求,再检查返回的字段:
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 且响应体包含样式规则。这样能发现目录挂载错误或文件缺失。
验证 WebSocket 回显
WebSocket 使用持续连接,不能用普通的 client.get() 代替。连接建立后发送一条文本消息,再接收服务端回显:
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()、路径是否正确,以及预期文本是否与处理函数一致。
运行完整测试
在 _code_starlette/p04c01 中执行:
python -m pytest -q tests/test_article_app.py通过时应看到 10 个用例成功。需要定位单个问题,可运行 python -m pytest -q tests/test_article_app.py::test_websocket_echo。本专题的测试范围刻意保持紧凑:一个小应用覆盖页面、API、错误、静态资源、启动状态与实时连接,下一专题再把验证方法用于完整文章管理项目。
案例源码
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,
)静态样式
body {
font-family: sans-serif;
line-height: 1.5;
}测试文件
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 == "服务端回显:你好"