码上问学问代码之理,学编程之道

第 12 章 · 接口测试与文档

接口写完的那一刻,是信心最虚的时候——「我觉得能跑」。这一章教你用三件武器把「我觉得」换成「验证过」:Postman 手工走通业务链路、pytest 自动回归、curl 现场取证;再补上Swagger 自动生成接口文档,让前端不用再追着你要参数说明。

为什么必须测试

「我以为能跑」是最大的坑
测试金字塔:越往下越该多写 界面 / 端到端测试 少 · 慢 · 脆 接口测试(Postman / pytest) 中 · 覆盖主流程 单元测试(函数级) 多 · 快 · 稳

测试金字塔:底层的单元测试要多、快、稳;中层的接口测试覆盖主流程;顶层的端到端测试少而慢。本课重点在「接口测试 + pytest 单元/集成测试」。

先讲为什么你写完登录接口,用浏览器或 Postman 发一次请求,返回了 "code": 200——于是你说「好了」。可这只是一条快乐路径。真实世界里有:密码错的、用户名不存在的、token 过期的、article_id 传字符串的、同一个人重复注册的……这些情况你一条都没试过。
更麻烦的是「改一处、坏三处」:三天后你为了做收藏功能动了 models/user.py,登录还好不好用?靠手点,你迟早会漏。测试就是把「我知道它能跑」变成一条随时能重跑的命令。
讲透:三个层次,用途不同
  • curl——最快的「点一下」。看单条接口通不通、看原始响应长什么样。
  • Postman / Apifox——走「业务流程」。注册→登录→带 token 查资料,靠环境变量把 token 在请求之间传下去。
  • pytest——自动回归。一次跑几十个用例,覆盖正常与异常分支,写完就一劳永逸。
  • 三者不是替代关系,而是配合关系:开发时用 curl 快查,联调时用 Postman 走链路,提交前用 pytest 全量过一遍。

Postman:集合与环境变量

让 token 自动在请求之间流转
Postman:登录一次,token 处处可用 古典文学网(集合) 📁 登录请求 📁 个人信息 📁 发书评 📁 点赞 / 收藏 登录请求 响应里取 token 写入环境变量 {{token}} 后续请求 Authorization: Bearer {{token}} 把 token 存进环境变量,集合里所有请求都能 {{token}} 引用,登录一次处处可用

把登录请求和各业务请求放进同一个 Postman 集合,登录后把 token 写入环境变量 {{token}};后续请求在 Authorization 头里引用它,省去每次手动复制。

先讲为什么我们验证接口要反复做三件事:改地址(本地 / 测试服 / 线上)、换 token、改参数。把这三样写死在每个请求里,等于给自己埋雷——换环境时得逐个改,改漏一个就报莫名其妙的错。
Postman 的解法是变量:地址写成 {{base_url}}、token 写成 {{token}},具体值放在「环境」里。切环境只需在右上角下拉框里选一下。
动手做新建一个环境,至少放三个变量。token 先留空——它要等登录成功后由脚本写进去。
Postman 环境(可导入).jsonjson
{
  "name": "古典文学网-本地",
  "values": [
    { "key": "base_url",   "value": "http://127.0.0.1:5000/api", "enabled": true },
    { "key": "token",      "value": "",                          "enabled": true },
    { "key": "username",   "value": "zhangsan",                  "enabled": true },
    { "key": "password",   "value": "123456",                    "enabled": true },
    { "key": "article_id", "value": "1",                         "enabled": true }
  ]
}
响应内容接口返回数据
运行结果: # 集合里的请求就可以这样写,地址与 token 全部用变量代替 GET {{base_url}}/article/list?page=1&page_size=10 POST {{base_url}}/user/login GET {{base_url}}/user/profile Headers: Authorization = Bearer {{token}} # 右上角环境下拉框切到「古典文学网-本地」,{{base_url}} 才会被替换成真实地址 # 切到生产环境时,只要把 base_url 换成 https://你的域名/api 即可
动手做(关键一步)打开 POST {{base_url}}/user/login 这个请求,切到 Tests 标签,写下把 token 存进环境变量的脚本。只要登录一次,后面所有需要鉴权的请求就都自动带上了。
登录请求 → Tests 标签javascript
// 1)先确认这次登录本身是成功的
pm.test("业务码 code = 200", function () {
    const body = pm.response.json();
    pm.expect(body.code).to.eql(200);
});

// 2)把 token 写进当前环境变量
//    environment 是「环境级」,collection 是「集合级」,
//    需要跨集合复用时用 global 级
const body = pm.response.json();
if (body.code === 200) {
    pm.environment.set("token", body.data.token);
    pm.environment.set("user_id", body.data.user.id);
}

// 3)顺便把登录耗时打印到控制台,方便排查慢查询
console.log("登录耗时(ms):" + pm.response.responseTime);
页面效果浏览器运行结果
运行结果: # Postman 的 Test Results 面板 PASS 业务码 code = 200 # Console 面板 登录耗时(ms):37 # 环境变量面板里 token 的值已经被写进去了 token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzIiwiZXhwIjoxNzkx...
讲透:Postman 的四层结构
  • Collection(集合)——一个项目的所有请求,可以导成 JSON 交给同学或同事。本课程的集合就按模块分文件夹:用户 / 首页 / 作品 / 书评 / 收藏。
  • Environment(环境)——一组变量,如 base_url、token。本地、测试服、线上各建一个,切换不改编代码。
  • Request(请求)——URL、方法、Headers、Body 都写变量。
  • Script(脚本)——Pre-request 在发请求前跑(可用来生成时间戳、签名),Tests 在收到响应后跑(做断言、提取数据)。
  • 变量优先级:环境变量 > 集合变量 > 全局变量。同名时环境变量赢,所以 token 放环境里最合适。
避坑「{{base_url}} 没被替换,原样发出去了」——这是 Postman 最常见的翻车。原因几乎都是环境没选中:右上角下拉框显示的是 No Environment,变量就没人解析,地址变成字面量 http://127.0.0.1:5000/api 还好,一旦是 {{base_url}}/user/login 就必然请求失败。请记住:改变量之前先确认环境选中了。另一种情况是变量名拼错(base_url 写成 baseurl),Postman 会把它当普通文本发出去,不报错,更隐蔽。

Postman 断言:让用例自己判断对错

别用肉眼比对 JSON
Postman 断言:让用例自己判断对错 接口响应 { code, msg, data } 断言脚本 状态码 == 200 json.code == 200 data.list 是数组 ✓ 通过 绿色,继续跑下一个 断言失败会显示 ✗ 并给出期望值与实际值——不用肉眼比对 JSON 回归时一键跑完整个集合,哪个接口坏了立刻知道

Postman 断言让用例自动判断:检查状态码、业务 code、关键字段类型。失败会标红并给出期望与实际值,回归时一键跑完整个集合即可定位问题接口。

先讲为什么发出一堆响应,靠眼睛看「这个字段对不对」会累且不可靠。更糟的是回归时没人愿意重看一遍。Postman 的 Tests 面板本质是一个「拿到响应之后自动执行的脚本」:你把「应该是什么」写成断言,它给你一个绿色的 PASS 或红色的 FAIL,几十个请求一起跑还能一次汇总。
动手做给 GET {{base_url}}/article/list?page=1&page_size=2 写一组断言:校验分页结构、校验条数上限、校验字段齐全、校验响应时间。
作品列表 → Tests 标签javascript
// ① HTTP 层:网络是通的
pm.test("HTTP 状态码为 200", function () {
    pm.response.to.have.status(200);
});

// ② 业务层:统一响应结构对得上
const body = pm.response.json();

pm.test("统一响应结构正确", function () {
    pm.expect(body).to.have.all.keys("code", "msg", "data");
    pm.expect(body.code).to.eql(200);
    pm.expect(body.msg).to.be.a("string");
});

// ③ 数据层:分页字段齐全、条数不超过 page_size
pm.test("返回了标准分页结构", function () {
    pm.expect(body.data).to.have.property("list");
    pm.expect(body.data).to.have.property("total");
    pm.expect(body.data).to.have.property("page");
    pm.expect(body.data).to.have.property("page_size");
});

pm.test("列表长度不超过 page_size", function () {
    pm.expect(body.data.list).to.be.an("array");
    pm.expect(body.data.list.length).to.be.at.most(body.data.page_size);
});

pm.test("每条作品都有标题和作者", function () {
    body.data.list.forEach(function (item) {
        pm.expect(item.title).to.be.a("string").and.not.empty;
        pm.expect(item).to.have.property("author");
        pm.expect(item).to.have.property("views");
    });
});

// ④ 性能层:响应时间兜底
pm.test("响应时间小于 500ms", function () {
    pm.expect(pm.response.responseTime).to.be.below(500);
});

// ⑤ 顺带把第一条作品的 id 存起来,供后续「详情 / 点赞」用例复用
if (body.data.list.length > 0) {
    pm.environment.set("article_id", body.data.list[0].id);
}
页面效果浏览器运行结果
运行结果: # 全部通过时 PASS HTTP 状态码为 200 PASS 统一响应结构正确 PASS 返回了标准分页结构 PASS 列表长度不超过 page_size PASS 每条作品都有标题和作者 PASS 响应时间小于 500ms # 把 page_size 故意改成 -1 再跑,断言会变成: FAIL 列表长度不超过 page_size | AssertionError: expected 10 to be at most -1 PASS 统一响应结构正确 ...
讲透:常用的 pm 断言
  • 等值 pm.expect(x).to.eql(200);类型 .to.be.a("string");布尔为真 .to.be.true;包含 .to.include("登录成功")。
  • 判断字段存在 .to.have.property("data");判断 key 集合完全一致 .to.have.all.keys("code","msg","data")——这条最能防止后端偷偷加字段。
  • 断言要在失败时给人线索:写清测试名,比如「列表长度不超过 page_size」比「test1」有用一百倍。
  • Collection Runner 批量跑:点集合右侧的 Run,勾选要跑的请求,一次跑完给出 PASS/FAIL 汇总——这就是「点一下走完 18 个接口」。第 13 章的 M3 验收,就是用这个方式演示的。

Apifox:中文的接口协作工具

Postman 的平替,团队协作更顺手
先讲为什么一个团队里,后端改接口、前端看接口、测试测接口,如果三拨人各持一份「口头版本」,联调就是灾难。大家需要一份唯一的事实来源。Apifox 的思路是:把「接口定义」当成源头,调试用例、Mock 数据、在线文档、自动化测试全部从它自动生成。改一处,所有人都同步看到。
动手做把课程配套的 Postman 集合导入 Apifox,体验一下它自动生成的在线文档。步骤只有三步,界面全中文,照着点就行。
讲透:Postman 还是 Apifox?
  • Postman:全球主流、生态成熟、面试常问。本课程用它演示,你以后打开任何一家公司的电脑,多半装的都是它。
  • Apifox:中文界面、把「设计 / 调试 / 文档 / Mock / 测试」合成一个工具,国内团队用得多,入门门槛更低。
  • 工具不是知识点,方法才是。你要真正掌握的是:环境变量怎么传 token、怎么用断言表达预期、怎么把接口串成业务链路。这三样换到任何工具都一样,包括以后公司自研的平台。
  • 别把时间花在选工具上。两个都装,Postman 走本课程流程,Apifox 用来生成文档链接——这已经足够覆盖你的项目需求。

pytest 单元测试

一条命令跑完全部用例
pytest:一条命令跑完全部用例 pytest 一条命令 收集用例 test_*.py / test_*() 执行 + 断言 assert 判断结果 报告 通过/失败 命名约定:文件叫 test_*.py,函数叫 test_*(),pytest 才能自动发现 配合 fixture 准备测试数据(如临时客户端、测试库),用例之间互不干扰

pytest 按命名约定(test_*.py / test_*)自动收集并执行用例,assert 判断结果,最后给出通过/失败报告。配合 fixture 准备测试数据,用例之间互不干扰。

先讲为什么Postman 能走链路,但它需要你打开界面、点按钮、用眼睛看结果。提交代码前想自动跑一遍,就得靠代码写测试。Flask 内置了测试客户端 app.test_client()——它不用真的启动服务器、不占端口,直接拿请求去调你的视图,就像在进程内模拟了一次 HTTP。
配上 pytest,几十个用例跑完只要几百毫秒。
动手做先装依赖,再写第一个测试文件。测试方法名必须以 test_ 开头,否则 pytest 不会收集它。
终端.安装bash
$ pip install pytest requests
$ pip freeze > requirements.txt

# 目录约定:测试文件放在项目根的 tests/ 下,名字以 test_ 开头
hiflask/
├── app.py
├── extensions.py
├── models/
├── views/
└── tests/
    ├── conftest.py           # 放公共 fixture
    ├── test_user_api.py
    └── test_article_api.py
终端输出命令行执行结果
运行结果: Successfully installed pytest-8.2.0 ... # 没写测试时先看 pytest 能不能跑起来 $ pytest collected 0 items no tests ran in 0.01s
动手做写 4 类真实用例:健康检查(冒烟)、注册+登录(主流程)、重复注册(异常分支)、详情浏览量自增(业务规则)。注意:断言要断在 body["code"] 上,不是 HTTP 状态码——我们的接口按文档约定「HTTP 状态码统一 200,业务成败看 code」。
tests/test_user_api.pypython
# client 这个 fixture 来自 conftest.py,本节末尾就会写


def test_health(client):
    """冒烟测试:服务活着、能返回统一响应结构"""
    resp = client.get("/api/health")
    assert resp.status_code == 200
    body = resp.get_json()
    assert body["code"] == 200
    assert body["data"]["status"] == "up"


def test_register_then_login(client):
    """主流程:注册 → 登录 → 拿到 token"""
    resp = client.post("/api/user/register",
                       json={"username": "zhangsan",
                             "password": "123456",
                             "nickname": "张三"})
    body = resp.get_json()
    assert body["code"] == 200
    assert body["msg"] == "注册成功"
    assert body["data"]["user_id"] > 0

    resp = client.post("/api/user/login",
                       json={"username": "zhangsan",
                             "password": "123456"})
    body = resp.get_json()
    assert body["code"] == 200
    assert len(body["data"]["token"]) > 20
    assert body["data"]["user"]["username"] == "zhangsan"
    # 顺手确认敏感字段没有泄漏
    assert "password" not in body["data"]["user"]


def test_register_duplicate_username(client):
    """异常分支:用户名重复必须被拦住"""
    payload = {"username": "zhangsan", "password": "123456"}
    client.post("/api/user/register", json=payload)
    resp = client.post("/api/user/register", json=payload)
    body = resp.get_json()
    assert body["code"] == 400
    assert body["msg"] == "用户名已存在"


def test_register_short_password(client):
    """异常分支:密码长度校验"""
    resp = client.post("/api/user/register",
                       json={"username": "lisi", "password": "123"})
    assert resp.get_json()["code"] == 400


def test_profile_requires_token(client):
    """鉴权分支:不带 token 访问需登录接口"""
    resp = client.get("/api/user/profile")
    body = resp.get_json()
    assert body["code"] == 401


def test_profile_with_token(client):
    """鉴权主流程:注册 → 登录 → 拿 token → 查个人信息"""
    client.post("/api/user/register",
                json={"username": "tester", "password": "123456"})
    token = client.post("/api/user/login",
                        json={"username": "tester",
                              "password": "123456"}) \
                   .get_json()["data"]["token"]

    resp = client.get("/api/user/profile",
                      headers={"Authorization": f"Bearer {token}"})
    body = resp.get_json()
    assert body["code"] == 200
    assert body["data"]["username"] == "tester"
    assert body["data"]["stat"]["favorite_count"] == 0
运行输出Python 运行结果
运行结果: $ pytest -v ============================= test session starts ============================== platform darwin -- Python 3.11.6, pytest-8.2.0, pluggy-1.5.0 rootdir: /Users/you/code/hiflask collected 6 items tests/test_user_api.py::test_health PASSED [ 16%] tests/test_user_api.py::test_register_then_login PASSED [ 33%] tests/test_user_api.py::test_register_duplicate_username PASSED [ 50%] tests/test_user_api.py::test_register_short_password PASSED [ 66%] tests/test_user_api.py::test_profile_requires_token PASSED [ 83%] tests/test_user_api.py::test_profile_with_token PASSED [100%] ============================== 6 passed in 0.41s =============================== # 有断言失败时会明确指出哪一行、期望什么、实际什么 # FAILED tests/test_user_api.py::test_health - assert 500 == 200
讲透:test_client 的用法
  • client.get(url) / client.post(url, json={...}):请求体用 json= 传字典,Flask 会自动设 Content-Type: application/json;表单用 data=;上传文件用 data={"file": (io.BytesIO(b"..."), "a.png")}。
  • resp.get_json() 把响应解析成 Python 字典;resp.status_code 是 HTTP 状态码;resp.data 是原始字节。
  • 带 token 就是用真实的请求头:headers={"Authorization": f"Bearer {token}"}。这样测的是完整链路,不是「绕过鉴权」。
  • 一个用例只测一件事,名字写清楚测什么。失败时 pytest 会告诉你「哪个用例、哪一行」,比看半天日志快得多。
  • 先跑冒烟,再跑分支:pytest -k health 只跑名字含 health 的用例;pytest -x 遇到第一个失败就停——调试时很省时间。

测试数据库隔离

别让测试污染开发库
测试用独立库,绝不污染开发库 开发库 你手点测试用的真库 要保留,不能动 测试库(TESTING) pytest 专用,跑前建、跑后毁 每次都是干净起点 配置切换,不碰开发库 测试连 TESTING=True 指向测试库;用例用事务回滚或跑完销毁,绝不污染你正在用的开发数据

pytest 跑测试时切换到独立的测试库(或开启事务回滚),用例产生的脏数据在跑完就被清掉。这样自动化测试可以反复跑,又不会弄脏你手点调试用的开发库。

先讲为什么上面的用例会往库里写数据:注册了 zhangsan、给作品点了赞。如果它连的是你的开发库 hiwen,跑几遍测试你的库里就全是垃圾数据;更惨的是「重复注册」这类用例第二次跑就失败了(用户名已存在),测试变得不可重复。
办法很简单:让测试用另一个库。最省事的就是内存 SQLite——进程结束数据就没了,天然干净。
动手做两步:① 在 config.py 里加一个 TestingConfig,把数据库指到内存;② 在 tests/conftest.py 里写 fixture,每个用例建表、用完删表。
config.py(追加测试配置)python
class TestingConfig(Config):
    """pytest 专用:内存 SQLite,跑完即销毁"""
    TESTING = True
    SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
    JWT_SECRET_KEY = "test-only-secret"

    # Flask-SQLAlchemy 3.x 对内存 SQLite 会自动使用 StaticPool,
    # 并设置 check_same_thread=False,所以同一个内存库在多个连接间是共享的。
    # 不需要自己写 SQLALCHEMY_ENGINE_OPTIONS。
运行输出Python 运行结果
运行结果: # config.py 里的配置类一览 Config 基类:公共配置,从环境变量读 DevConfig 开发:MySQL 的 hiwen 库 ProdConfig 生产:MySQL 的 hiwen 库 + DEBUG=False TestingConfig 测试:内存 SQLite,与开发库完全隔离
tests/conftest.pypython
import pytest

from app import create_app
from extensions import db


@pytest.fixture()
def app():
    """每个用例一个全新的应用实例 + 一个全新的空库"""
    app = create_app("testing")
    with app.app_context():
        db.create_all()
        yield app            # 用例在这一行执行
        db.session.remove()  # 先断开会话,避免删表时连接被占用
        db.drop_all()


@pytest.fixture()
def client(app):
    """测试客户端:不用真的起服务器,直接在进程内发请求"""
    return app.test_client()


@pytest.fixture()
def auth_headers(client):
    """通用 fixture:注册一个测试用户并登录,返回可直接用的请求头

    需要登录的用例只要在参数里写上 auth_headers,就自动带上 token。
    """
    client.post("/api/user/register",
                json={"username": "tester", "password": "123456"})
    token = client.post("/api/user/login",
                        json={"username": "tester",
                              "password": "123456"}) \
                   .get_json()["data"]["token"]
    return {"Authorization": f"Bearer {token}"}
运行输出Python 运行结果
运行结果: $ pytest -v collected 6 items tests/test_user_api.py::test_health PASSED [ 16%] ... ============================== 6 passed in 0.41s =============================== # 关键验证:查一下开发库,测试数据一条都没进来 $ mysql -uroot -p -e "SELECT id, username FROM hiwen.user;" +----+----------+ | id | username | +----+----------+ | 1 | admin | | 2 | libai | +----+----------+ -- 共 2 行(还是种子数据,测试完全没碰到开发库)
避坑「SQLite 上全绿」不等于「MySQL 上没问题」,两者方言差异会在这些地方咬你:
① ENUM('article','comment')——SQLite 不校验,写错的值照样存进去;
② FULLTEXT 全文索引、utf8mb4 字符集——SQLite 没有;
③ ON UPDATE CURRENT_TIMESTAMP 这类自动更新时间,SQLite 不认(模型里已改成 onupdate 才行);
④ db.create_all() 按模型定义建表,和 hiwen.sql 里手写的建表语句并非逐字等价。
所以:pytest 用来保障逻辑,MySQL 上的真实验证必须再跑一遍(用 Postman 或 curl)。两者都要,不能互相替代。

Swagger 自动文档

让代码自己长出文档
先讲为什么手写 Markdown 文档最大的问题是会过期:接口改了,文档忘了改,前端照着旧文档写,然后一起debug到半夜。
Swagger 的思路是:文档就写在接口旁边,由代码生成一个可视化的交互页面。打开它,你不仅能看参数说明,还能直接在页面上点「Try it out」发请求——文档和调试合成一件事。
动手做选 flasgger(最省事,改几行代码就出文档)。三步:装包 → 在 create_app() 里初始化 → 给视图函数写 YAML 格式的 docstring。
app.py(集成 flasgger)python
# pip install flasgger
from flasgger import Swagger

SWAGGER_CONFIG = {
    "headers": [],
    "specs": [{
        "endpoint": "apispec",
        "route": "/apidocs.json",
        "rule_filter": lambda rule: "/api/" in rule.rule,
        "model_filter": lambda tag: "api" in (tag or []),
    }],
    "static_url_path": "/flasgger_static",
    "swagger_ui": True,
    "specs_route": "/apidocs/",     # 文档页面地址
}


def create_app(config_name=None):
    app = Flask(__name__)
    # ... 加载配置、初始化扩展、注册蓝图 ...

    Swagger(app, config=SWAGGER_CONFIG, merge=True)
    return app
运行输出Python 运行结果
运行结果: $ python app.py * Running on http://127.0.0.1:5000 # 浏览器打开文档页面 http://127.0.0.1:5000/apidocs/ # 页面左侧会按蓝图列出所有 /api/ 开头的接口, # 点开任意一个都能看到参数表格、点击 Try it out 直接发请求。
views/article.py(写 docstring 就会生成文档)python
@article_bp.get("/list")
def article_list():
    """作品列表(分页 / 筛选 / 搜索)
    ---
    tags:
      - 作品
    parameters:
      - name: page
        in: query
        type: integer
        default: 1
        description: 页码,从 1 开始
      - name: page_size
        in: query
        type: integer
        default: 10
        description: 每页条数
      - name: category_id
        in: query
        type: integer
        description: 分类 ID,如 1 表示唐诗
      - name: keyword
        in: query
        type: string
        description: 标题或摘要的模糊关键词
      - name: order_by
        in: query
        type: string
        enum: [latest, hot, likes]
        default: latest
    responses:
      200:
        description: 统一响应,data 内含 list / total / page / page_size
    """
    page = request.args.get("page", 1, type=int)
    # ... 其余实现不变 ...
运行输出Python 运行结果
运行结果: # /apidocs/ 页面上实际呈现的效果 作品 ▼ GET /api/article/list 作品列表(分页 / 筛选 / 搜索) Parameters Name Description Type Value page 页码,从 1 开始 integer 1 page_size 每页条数 integer 10 category_id 分类 ID,如 1 表示唐诗 integer keyword 标题或摘要的模糊关键词 string order_by (latest / hot / likes) string latest [Try it out]
讲透:flasgger 还是 flask-smorest?
  • flasgger:适合「已有接口,补文档」。docstring 里写 YAML,改完刷新页面就更新,学习成本极低——本课程推荐先用它。
  • flask-smorest:基于 marshmallow 的 schema 驱动,请求校验、序列化、文档三合一。适合从零设计、字段多且要求严格的 API,但要先学会定义 schema,代价更高。
  • 核心思想相同:接口的结构信息只有一个来源(docstring 或 schema),文档由它生成,不靠人手工同步。
  • 和《API 接口文档》的关系:Markdown 文档是「给人看的约定与示例」,Swagger 是「可交互的运行时说明」。项目里两份都要,且以 Markdown 文档为对外契约。
避坑不要把 /apidocs/ 原样暴露到公网。它把全部接口、参数、甚至示例请求都摊开了,等于给扫描器送了一份「攻击清单」。生产环境二选一:① 用 if current_app.debug: 包住 Swagger(app, ...),只在开发环境挂载;② 保留但加一层登录鉴权。课程项目的做法是只在开发环境开启,部署到服务器时自动关掉。

curl 快速验证

不开界面,一行命令看真相
先讲为什么有时候你只想确认一件事:「这个接口现在返回什么」。打开 Postman 要点四五下鼠标,而 curl 只要一行。它看到的是最原始的响应——没有界面帮你美化,也没有缓存帮你遮掩,所以排查问题时它最可信。服务器上没装 Postman 时,curl 往往是你唯一的工具。
动手做下面这五条命令,覆盖了「公开 GET → 带参 GET → 登录拿 token → 带 token 访问 → 异常分支」全流程。建议把它们抄进 README,以后每次改完接口照着跑一遍。
终端.curlbash
# ① 健康检查:确认服务活着
$ curl -s http://127.0.0.1:5000/api/health

# ② 作品列表:带分页与排序参数(注意 URL 里的 & 要放在引号里)
$ curl -s "http://127.0.0.1:5000/api/article/list?page=1&page_size=2&order_by=hot"

# ③ 登录:-X 指定方法,-H 设请求头,-d 传 JSON 请求体
$ curl -s -X POST http://127.0.0.1:5000/api/user/login \
       -H "Content-Type: application/json" \
       -d '{"username": "zhangsan", "password": "123456"}'

# ④ 把 token 存进 shell 变量,再带着它查个人信息
$ TOKEN=$(curl -s -X POST http://127.0.0.1:5000/api/user/login \
          -H "Content-Type: application/json" \
          -d '{"username": "zhangsan", "password": "123456"}' \
          | python -c "import sys, json; print(json.load(sys.stdin)['data']['token'])")

$ curl -s http://127.0.0.1:5000/api/user/profile \
       -H "Authorization: Bearer $TOKEN"

# ⑤ 只看状态码,不看响应体(写脚本时常用)
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5000/api/health
终端输出命令行执行结果
运行结果: # ① { "code": 200, "msg": "success", "data": { "service": "hiflask", "status": "up" } } # ② { "code": 200, "msg": "success", "data": { "list": [ { "id": 3, "title": "念奴娇·赤壁怀古", "author": "苏轼", "dynasty": "宋", "category_name": "宋词", "views": 412, "likes": 58 }, { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "category_name": "唐诗", "views": 328, "likes": 42 } ], "total": 6, "page": 1, "page_size": 2 } } # ③ { "code": 200, "msg": "登录成功", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": 3, "username": "zhangsan", "nickname": "张三", "avatar": "" } } } # ④ { "code": 200, "msg": "success", "data": { "id": 3, "username": "zhangsan", "nickname": "张三", "email": "zhangsan@example.com", "avatar": "", "intro": null, "gender": 0, "created_at": "2026-10-12 10:30:00", "stat": { "comment_count": 0, "favorite_count": 0 } } } # ⑤ 200
讲透:几个真能省时间的技巧
  • URL 里有 & 一定要加引号,否则 shell 会把 & 当成「放到后台运行」,命令行为变得很奇怪。
  • -i 连响应头一起看(排查 CORS、Content-Type 必用);-v 看完整握手过程(排查连不上、证书问题)。
  • 管道接 python -m json.tool 让 JSON 变整齐:curl -s URL | python -m json.tool。装了 jq 更好用:| jq .data.list[0].title。
  • 用 -w "%{http_code}" 只看状态码,脚本里判断成败最方便。
  • 中文乱码时不用慌:终端编码问题而已,响应本身是 UTF-8,postman 里看是正常的。加 | python -m json.tool 通常也能解决显示。
动手练习
1在你的 Postman 里建一个「注册」请求的 Tests:断言 code 为 200、data.user_id 是数字,并把 user_id 存进环境变量。再建一个「重复注册」用例,断言 code 为 400 且 msg 为「用户名已存在」。
2给「登录失败」写一个 pytest 用例:用正确的用户名 + 错误的密码登录,断言 code == 400 且 msg 是「用户名或密码错误」。再想一下:为什么这个接口不告诉你「用户名不存在」?
3迁移练习:「红影赏析」的电影详情接口 GET /api/movie/detail/<id> 要求「每次访问浏览量 +1」,并且不存在的 id 返回 code=404。请为它写 3 个 pytest 用例(正常访问、浏览量自增、id 不存在),并用 curl 命令各验证一遍。
参考答案
练习答案.py / .js / .shmixed
# ============ 第 1 题:Postman → 「注册」请求的 Tests 标签 ============
pm.test("注册成功,code = 200", function () {
    pm.expect(pm.response.json().code).to.eql(200);
});

pm.test("返回了数字类型的 user_id", function () {
    const uid = pm.response.json().data.user_id;
    pm.expect(uid).to.be.a("number");
    pm.expect(uid).to.be.above(0);
    pm.environment.set("user_id", uid);
});

# 「重复注册」请求的 Tests 标签(Body 里用户名和上面保持一致)
pm.test("重复注册被拒绝", function () {
    const body = pm.response.json();
    pm.expect(body.code).to.eql(400);
    pm.expect(body.msg).to.eql("用户名已存在");
    pm.expect(body.data).to.be.null;
});


# ============ 第 2 题:tests/test_user_api.py ============
def test_login_wrong_password(client):
    """密码错误必须返回统一的模糊提示,不能暴露账号是否存在"""
    client.post("/api/user/register",
                json={"username": "zhangsan", "password": "123456"})

    resp = client.post("/api/user/login",
                       json={"username": "zhangsan",
                             "password": "wrong-pwd"})
    body = resp.get_json()
    assert body["code"] == 400
    assert body["msg"] == "用户名或密码错误"

# 为什么统一提示?如果「用户名不存在」单独提示,攻击者就能拿一份
# 用户名字典逐个试:返回「密码错误」说明账号存在,返回「用户不存在」说明不存在。
# 这叫「用户名枚举漏洞」,所以要统一提示、统一耗时。


# ============ 第 3 题:tests/test_movie_api.py ============
def test_movie_detail(client):
    resp = client.get("/api/movie/detail/1")
    body = resp.get_json()
    assert body["code"] == 200
    assert body["data"]["id"] == 1
    assert "title" in body["data"]


def test_movie_detail_views_plus_one(client):
    first = client.get("/api/movie/detail/1").get_json()["data"]["views"]
    second = client.get("/api/movie/detail/1").get_json()["data"]["views"]
    assert second == first + 1


def test_movie_detail_not_found(client):
    resp = client.get("/api/movie/detail/999999")
    body = resp.get_json()
    assert body["code"] == 404
    assert body["msg"] == "电影不存在"


# ============ 第 3 题:curl 验证(真机上跑)============
# 正常访问
$ curl -s http://127.0.0.1:5000/api/movie/detail/1 | python -m json.tool

# 连跑两次看 views 是否 +1
$ curl -s http://127.0.0.1:5000/api/movie/detail/1 | grep -o '"views": [0-9]*'
$ curl -s http://127.0.0.1:5000/api/movie/detail/1 | grep -o '"views": [0-9]*'

# id 不存在
$ curl -s http://127.0.0.1:5000/api/movie/detail/999999
效果预览运行结果
运行结果: # 第 1 题:Postman Test Results 面板 PASS 注册成功,code = 200 PASS 返回了数字类型的 user_id PASS 重复注册被拒绝 # 第 2 题 $ pytest -k wrong_password -v tests/test_user_api.py::test_login_wrong_password PASSED [100%] ============================== 1 passed in 0.12s =============================== # 第 3 题 $ pytest -k movie -v tests/test_movie_api.py::test_movie_detail PASSED [ 33%] tests/test_movie_api.py::test_movie_detail_views_plus_one PASSED [ 66%] tests/test_movie_api.py::test_movie_detail_not_found PASSED [100%] ============================== 3 passed in 0.18s =============================== # 第 3 题:curl 连跑两次 "views": 120 "views": 121 -- 两次相差 1,说明自增逻辑生效 # id 不存在 { "code": 404, "msg": "电影不存在", "data": null }

第 3 题的关键:换掉的是「资源名 movie 和它的字段」,不变的是「正常 / 边界 / 异常三条用例 + 一条命令验证」这套骨架。第 2 题那个「为什么统一提示」的思考,是接口安全里最容易被忽略的一课。

本章小结