为什么TDD能让你的FastAPI开发飞起来?

80 阅读8分钟

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.pytest_create_user_success通过(绿阶段)——因为:

  • 接口/users/存在了(返回201);
  • 响应数据符合UserOut模型(没有password字段,usernameemail正确)。

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 扩展:新增密码哈希功能(迭代)

接下来我们要加“密码哈希”的需求,这时候需要新增测试→修改代码→保持测试通过

  1. 新增测试:验证密码不是明文存储(用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()
    
  2. 修改业务逻辑:用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
        )
    
  3. 修改路由:注入数据库会话:

    # 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 示例:用户认证接口的迭代

假设我们要做“用户登录接口”,迭代流程如下:

  1. 契约定义:请求模型UserLoginemail+password),响应模型Tokenaccess_token+token_type);
  2. :写测试test_login_success(期望返回200和Token,但接口未实现,测试失败);
  3. 绿:写路由/login/,验证密码是否正确,返回Token;
  4. 重构:把认证逻辑抽到auth.py中;
  5. 扩展:加“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模型约束”,常见原因:

  • 缺少必填字段(比如UserCreatepassword没传);
  • 字段类型错误(比如给age字段传字符串);
  • 格式不符合要求(比如email字段传了无效邮箱)。

排查步骤

  1. 看测试中的请求数据,对比Pydantic模型的字段;
  2. 用FastAPI的/docs接口测试,看返回的具体错误信息(比如“field required”或“value is not a valid email”);
  3. 检查模型的验证规则(比如Field(min_length=8)是否被遵守)。

7. 常见报错解决方案

报错1:422 Unprocessable Entity(Validation Error)

原因:请求数据不符合Pydantic模型的约束(比如UserCreatepassword长度不足8位)。
解决

  1. 检查请求数据的字段名、类型、格式是否和模型一致;
  2. 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 会报错)。
解决

  1. 看FastAPI的日志(运行时加--reload参数),定位异常位置;
  2. 在代码中加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