1. 迭代式接口开发验证流程的核心逻辑
1.1 什么是“迭代式”?
迭代式开发不是“一次性写完所有功能”,而是把接口拆成多个小周期 :每个周期只解决一个具体问题(比如“实现用户创建”→“加密码哈希”→“加邮箱唯一性校验”),每个周期都遵循“定义契约→写测试→实现功能→重构”的闭环。这种方式的好处是 风险可控——每一步都能验证功能正确性,避免最后发现“全盘错误”的情况。
1.2 FastAPI下的TDD适配:从接口契约到测试
FastAPI的类型提示和Pydantic模型天然支持TDD的“契约优先”原则。接口的“契约”就是请求/响应的数据格式 (比如“创建用户需要传哪些字段?返回哪些字段?”),而Pydantic模型就是这个契约的“文字版”。测试则是“验证契约是否被遵守”——比如测试接口是否返回契约规定的字段,是否拒绝不符合契约的请求。
2. 第一步:定义接口契约(红)
TDD的第一步是“写失败的测试”,但在FastAPI中,我们需要先明确接口的“契约”——用Pydantic定义请求和响应模型。这一步的核心是:* 先和前端/客户端约定“数据格式”,再写测试验证这个约定是否被遵守*。
2.1 用Pydantic定义契约模型
比如,我们要做一个“用户创建接口”,需要接收用户名、邮箱、密码,返回用户ID、用户名、邮箱(不返回密码)。用Pydantic定义如下:
# models.py(接口契约文件)
from pydantic import BaseModel, EmailStr, Field
# 请求模型:客户端需要传的参数
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50) # 必须,3-50字符
email: EmailStr # 必须是合法邮箱格式
password: str = Field(..., min_length=8) # 必须,至少8位
# 响应模型:服务器返回的结果
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
class Config:
orm_mode = True # 后续对接ORM(如SQLAlchemy)时用
2.2 编写第一个失败的测试
契约定义好后,我们写测试验证接口是否存在并遵守契约。此时接口还没实现,测试会失败(红阶段)。
# test_users.py(测试文件)
from fastapi.testclient import TestClient
from main import app # 先创建空的main.py,后续填充
client = TestClient(app)
def test_create_user_success():
# 1. 构造符合契约的请求数据
user_data = {
"username": "testuser",
"email": "test@example.com",
"password": "testpass123"
}
# 2. 发送请求(此时接口未实现,会返回404)
response = client.post("users", json=user_data)
# 3. 断言:期望接口返回201(创建成功),但实际返回404,测试失败
assert response.status_code == 201
# 4. 断言:响应数据符合UserOut模型(比如没有password字段)
response_json = response.json()
assert "password" not in response_json
assert response_json["username"] == "testuser"
3. 第二步:实现最小可用接口(绿)
红阶段的测试失败后,我们需要写最少的代码让测试通过——这就是“最小可用接口”。核心原则是:**只实现契约规定的功能,不做额外扩展 **。
3.1 编写FastAPI路由
在main.py中写路由,直接返回符合契约的响应:
# main.py
from fastapi import FastAPI
from models import UserCreate, UserOut # 导入契约模型
app = FastAPI()
# 路由:POST /users/,响应模型是UserOut(遵守契约)
@app.post(
"users", response_model=UserOut, status_code=201)
def create_user(user_data: UserCreate): # 自动校验请求数据是否符合UserCreate
# 最小实现:固定ID为1,忽略密码(后续迭代再优化)
return UserOut(
id=1,
username=user_data.username,
email=user_data.email
)
3.2 让测试通过的关键:匹配契约
此时重新运行pytest test_users.py,test_create_user_success会通过(绿阶段)——因为:
- 接口
/users/存在了(返回201); - 响应数据符合
UserOut模型(没有password字段,username和email正确)。
4. 第三步:重构与扩展(蓝)
绿阶段的代码能“用”但不一定“好”,比如上面的create_user直接把业务逻辑写在路由里,不利于维护。重构的目标是*
优化代码结构,但不改变接口的外部行为*(测试仍然通过)。
4.1 分离业务逻辑(重构)
把用户创建的业务逻辑抽到crud.py中,让路由只负责“接收请求→调用业务逻辑→返回响应”:
# crud.py(业务逻辑文件)
from models import UserCreate, UserOut
def create_user(user_in: UserCreate) -> UserOut:
# 这里可以后续加密码哈希、数据库操作等逻辑
return UserOut(
id=1,
username=user_in.username,
email=user_in.email
)
修改main.py的路由:
# main.py(重构后)
from fastapi import FastAPI
from models import UserCreate, UserOut
from crud import create_user # 导入业务逻辑
app = FastAPI()
@app.post(
"users", response_model=UserOut, status_code=201)
def create_user_route(user_data: UserCreate):
return create_user(user_data) # 调用业务逻辑
此时运行测试,仍然通过——因为接口的输入输出没有变,只是内部结构更清晰了。
4.2 扩展:新增密码哈希功能(迭代)
接下来我们要加“密码哈希”的需求,这时候需要新增测试→修改代码→保持测试通过:
-
新增测试:验证密码不是明文存储(用SQLAlchemy做数据库):
# test_users.py(新增测试) from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from models import Base, UserDB # 新增UserDB数据库模型 # 测试用数据库(内存SQLite) engine = create_engine("sqlite://:memory:") TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base.metadata.create_all(bind=engine) # 创建表 def test_password_hashed(): db = TestingSessionLocal() user = db.query(UserDB).filter(UserDB.username == "testuser").first() assert user.hashed_password != "testpass123" # 密码不是明文 db.close() -
修改业务逻辑:用
passlib哈希密码:# crud.py(修改后) from passlib.context import CryptContext from sqlalchemy.orm import Session from models import UserCreate, UserOut, UserDB pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def get_password_hash(password: str) -> str: return pwd_context.hash(password) def create_user(db: Session, user_in: UserCreate) -> UserOut: # 哈希密码 hashed_password = get_password_hash(user_in.password) # 存入数据库 db_user = UserDB( username=user_in.username, email=user_in.email, hashed_password=hashed_password ) db.add(db_user) db.commit() db.refresh(db_user) # 转换为响应模型 return UserOut( id=db_user.id, username=db_user.username, email=db_user.email ) -
修改路由:注入数据库会话:
# main.py(修改后) from fastapi import FastAPI, Depends from sqlalchemy.orm import Session from models import UserCreate, UserOut, Base, UserDB from crud import create_user from database import get_db # 数据库依赖(比如获取Session) app = FastAPI() @app.post("users", response_model=UserOut, status_code=201) def create_user_route(user_data: UserCreate, db: Session = Depends(get_db)): return create_user(db, user_data)
此时运行测试,test_password_hashed会通过——这就是迭代扩展:每加一个功能,都用测试覆盖,确保不破坏原有功能。
5. 迭代循环:从单接口到复杂场景
5.1 示例:用户认证接口的迭代
假设我们要做“用户登录接口”,迭代流程如下:
- 契约定义:请求模型
UserLogin(email+password),响应模型Token(access_token+token_type); - 红:写测试
test_login_success(期望返回200和Token,但接口未实现,测试失败); - 绿:写路由
/login/,验证密码是否正确,返回Token; - 重构:把认证逻辑抽到
auth.py中; - 扩展:加“Token过期时间”需求,新增测试
test_token_expired,修改代码。
5.2 流程图:迭代式流程的闭环
graph TD
A[需求分析] --> B[定义接口契约(Pydantic)]
B --> C[写失败的测试]
C --> D[实现最小可用接口]
D --> E[运行测试→通过]
E --> F[重构代码(优化结构)]
F --> G[扩展需求(如加密码哈希)]
G --> A[需求分析]
6. 课后Quiz
问题1:为什么在迭代式TDD中,要先定义Pydantic模型而不是直接写路由?
答案
:Pydantic模型是接口的“契约”——它明确了“客户端要传什么”“服务器要返回什么”。如果先写路由再补模型,容易出现“接口返回的字段和客户端预期不一致”的问题(比如客户端期待user_id
,但路由返回id),后期修改成本很高。先定义模型,相当于“先和客户端签合同”,再按合同干活。
问题2:测试时返回422错误,可能的原因是什么?如何排查?
答案:422错误是“请求数据不符合Pydantic模型约束”,常见原因:
- 缺少必填字段(比如
UserCreate的password没传); - 字段类型错误(比如给
age字段传字符串); - 格式不符合要求(比如
email字段传了无效邮箱)。
排查步骤:
- 看测试中的请求数据,对比Pydantic模型的字段;
- 用FastAPI的
/docs接口测试,看返回的具体错误信息(比如“field required”或“value is not a valid email”); - 检查模型的验证规则(比如
Field(min_length=8)是否被遵守)。
7. 常见报错解决方案
报错1:422 Unprocessable Entity(Validation Error)
原因:请求数据不符合Pydantic模型的约束(比如UserCreate的password长度不足8位)。
解决:
- 检查请求数据的字段名、类型、格式是否和模型一致;
- 用
print(response.json())看具体错误信息(比如“password must be at least 8 characters”)。
预防:在测试中覆盖所有验证场景(比如测试“密码长度不足8位”时返回422)。
报错2:500 Internal Server Error
原因:接口实现中有未捕获的异常(比如数据库查询时user = db.query(UserDB).first()返回None,后续调用user.id
会报错)。
解决:
- 看FastAPI的日志(运行时加
--reload参数),定位异常位置; - 在代码中加
try-except块捕获异常,返回有意义的状态码(比如raise HTTPException(status_code=404, detail="User not found"))。
预防:编写测试覆盖异常场景(比如测试“查询不存在的用户”时返回404)。
报错3:404 Not Found
原因:测试中的URL和路由定义不一致(比如路由是/users/,但测试用了/user/)。
解决:复制粘贴路由的路径到测试中,避免手敲错误。
预防:用app.url_path_for("create_user_route")
获取路由路径(比如client.post(app.url_path_for("create_user_route"), json=user_data))。
第三方库版本说明
fastapi==0.109.0(FastAPI最新稳定版)pydantic==2.5.3(Pydantic v2,支持更严格的验证)pytest==7.4.4(Python测试框架)httpx==0.26.0(TestClient依赖)sqlalchemy==2.0.25(ORM框架,用于数据库操作)passlib==1.7.4(密码哈希库)python-multipart==0.0.6(处理表单数据,可选)
安装命令:
pip install fastapi pydantic pytest httpx sqlalchemy passlib python-multipart