第 11 章 · 测试、安全与生产部署
本章目标:使用 pytest 与 Flask test client fixture 编写 API 与视图测试;理解 SECRET_KEY 与敏感配置管理;掌握 DEBUG=False 行为差异;配置 Gunicorn + Nginx 反向代理与静态/上传托管;设置 HTTPS 相关代理头;为 api-demo 建立可上线的最小安全清单。
学时建议:5~6 小时(含 2 小时部署演练)
前置:本模块 ch01~ch10;python-dev ch10 虚拟环境与依赖。运维概念可对照 ops-deploy 模块。
11.1 测试金字塔(Flask 视角)
┌─────────┐
│ E2E 少量 │ Playwright(选修)
├─────────┤
│ API 集成 │ pytest + Flask test client
├─────────┤
│ 单元较多 │ utils、序列化、表单校验
└─────────┘
| 层级 | 工具 | api-demo 示例 |
|---|---|---|
| 单元 | pytest | save_cover 扩展名校验 |
| 集成 | test client | 商品 CRUD API |
| E2E | 浏览器 | 管理页登录(毕业项目加分) |
本章测试均针对虚构 api-demo,不依赖任何企业内部 CI 或私有镜像仓库。
11.2 pytest 环境搭建
pip install pytest pytest-cov
pytest.ini(项目根):
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -ra --strict-markers
tests/conftest.py:
import pytest
from api_demo import create_app
from api_demo.extensions import db
@pytest.fixture
def app():
application = create_app("testing")
with application.app_context():
db.create_all()
yield application
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def runner(app):
return app.test_cli_runner()
@pytest.fixture
def auth_client(client, app):
"""创建用户并登录,返回带 session 的 client。"""
from api_demo.models.user import User
with app.app_context():
user = User(username="tester", email="test@user-demo.example.com")
user.set_password("TestPass123!")
db.session.add(user)
db.session.commit()
client.post("/auth/login", data={
"username": "tester",
"password": "TestPass123!",
})
return client
运行:
pytest
pytest tests/test_api_products.py -v
pytest --cov=api_demo --cov-report=term-missing
11.3 API 测试示例
tests/test_api_products.py:
import json
from decimal import Decimal
from api_demo.models.product import Product
from api_demo.extensions import db
def test_list_products_empty(client):
res = client.get("/api/v1/products")
assert res.status_code == 200
body = res.get_json()
assert body["code"] == 0
assert body["data"] == []
assert body["pagination"]["total"] == 0
def test_list_products_with_data(client, app):
with app.app_context():
p = Product(name="测试商品", slug="test-item", price=Decimal("29.9"), is_published=True)
db.session.add(p)
db.session.commit()
res = client.get("/api/v1/products")
body = res.get_json()
assert body["pagination"]["total"] == 1
assert body["data"][0]["name"] == "测试商品"
def test_create_product_requires_auth(client):
res = client.post(
"/api/v1/products",
data=json.dumps({"name": "新品", "slug": "new", "price": "10"}),
content_type="application/json",
)
assert res.status_code == 401
assert res.get_json()["code"] == 40101
def test_create_product_authenticated(auth_client, app):
res = auth_client.post(
"/api/v1/products",
data=json.dumps({"name": "新品", "slug": "new-sku", "price": "99.00", "stock": 10}),
content_type="application/json",
)
assert res.status_code == 201
assert res.get_json()["data"]["slug"] == "new-sku"
with app.app_context():
assert Product.query.filter_by(slug="new-sku").count() == 1
def test_not_found_json(client):
res = client.get("/api/v1/products/99999")
assert res.status_code == 404
assert res.get_json()["code"] == 40400
| 断言 | 说明 |
|---|---|
status_code | HTTP 层 |
body["code"] | 业务层(ch07 约定) |
app.app_context() | 测试中访问 db 需上下文 |
11.4 表单与上传测试
def test_upload_cover(auth_client, app):
with app.app_context():
p = Product(name="封面试", slug="cover-test", price=1)
db.session.add(p)
db.session.commit()
pid = p.id
from io import BytesIO
data = {"file": (BytesIO(b"fake-image-bytes"), "test.jpg")}
res = auth_client.post(f"/api/v1/products/{pid}/cover", data=data, content_type="multipart/form-data")
assert res.status_code == 200
assert "cover_url" in res.get_json()["data"]
TestingConfig 中 UPLOAD_FOLDER 指向临时目录,测试结束可清理。
11.5 DEBUG=False 的影响
class ProductionConfig(Config):
DEBUG = False
TESTING = False
| DEBUG=True | DEBUG=False |
|---|---|
| Werkzeug 交互式调试器(切勿用于生产) | 通用 500 页 |
| 详细错误堆栈返回浏览器 | 堆栈仅写日志 |
send_from_directory 托管上传(ch08) | 必须 Nginx 提供 /uploads/ |
本地验证生产配置:
APP_ENV=production SECRET_KEY=prod-test-key flask run
# 故意访问 /api/v1/boom 应无堆栈泄露
11.6 SECRET_KEY 与安全配置
禁止将真实 SECRET_KEY、数据库密码提交 Git。
.env.example:
APP_ENV=production
SECRET_KEY=change-me-use-openssl-rand-hex-32