零基础入门python29:用 pytest 验收完整 Flask 业务流程

一、上一篇课后练习讲解
报表 kind 筛选应在基础查询上追加 Transaction.kind == kind,无效月份在解析阶段返回 400。测试不仅断言响应,还要断言用户隔离和数据库状态。
上一篇课后练习完整答案
上一篇练习的要求已落实到下面完整文件;先运行项目测试,再用 curl 对照状态码和数据库持久化结果
答案要点:报表用 SUM/CASE/COALESCE 在数据库聚合,过滤用户与软删除记录,Decimal 输出避免浮点误差。
文件:app/reports.py
完整参考答案文件
完整文件:app/reports.py
python
from decimal import Decimal
from flask import Blueprint, request
from flask_login import login_required, current_user
from sqlalchemy import func, case
from .extensions import db
from .models import Entry
bp = Blueprint("reports", __name__, url_prefix="/api/reports")
@bp.get("/month")
@login_required
def month():
start, end = request.args["start"], request.args["end"]
income = func.coalesce(func.sum(case((Entry.kind == "income", Entry.amount), else_=0)), 0)
expense = func.coalesce(func.sum(case((Entry.kind == "expense", Entry.amount), else_=0)), 0)
row = db.session.query(income, expense).filter(Entry.user_id == current_user.id, Entry.happened_on.between(start, end)).one()
return {"income": str(Decimal(row[0])), "expense": str(Decimal(row[1]))}
完整参考答案文件
本篇对应的交付源码完整文件:flask-ledger/app/ledger.py
python
from datetime import date
from decimal import Decimal, InvalidOperation
from flask import Blueprint, request
from flask_login import current_user, login_required
from sqlalchemy import func
from .extensions import db
from .models import Category, Transaction
bp = Blueprint("ledger", __name__, url_prefix="/api")
def owned_category(category_id: int):
return db.session.scalar(
db.select(Category).where(Category.id == category_id, Category.user_id == current_user.id)
)
@bp.get("/categories")
@login_required
def list_categories():
rows = db.session.scalars(
db.select(Category).where(Category.user_id == current_user.id).order_by(Category.name)
).all()
return [{"id": row.id, "name": row.name} for row in rows]
@bp.post("/categories")
@login_required
def create_category():
name = str((request.get_json(silent=True) or {}).get("name", "")).strip()
if not 1 <= len(name) <= 40:
return {"message": "分类名称长度应为1到40"}, 400
exists = db.session.scalar(
db.select(Category).where(Category.user_id == current_user.id, Category.name == name)
)
if exists:
return {"message": "分类已存在"}, 409
row = Category(name=name, user_id=current_user.id)
db.session.add(row)
db.session.commit()
return {"id": row.id, "name": row.name}, 201
@bp.post("/transactions")
@login_required
def create_transaction():
data = request.get_json(silent=True) or {}
try:
amount = Decimal(str(data.get("amount", "0"))).quantize(Decimal("0.01"))
happened_on = date.fromisoformat(str(data.get("happened_on", date.today())))
category_id = int(data.get("category_id"))
except (InvalidOperation, ValueError, TypeError):
return {"message": "金额、日期或分类格式不正确"}, 400
kind = str(data.get("kind", ""))
if kind not in {"income", "expense"} or amount <= 0:
return {"message": "类型或金额不正确"}, 400
if not owned_category(category_id):
return {"message": "分类不存在"}, 404
row = Transaction(
kind=kind, amount=amount, note=str(data.get("note", ""))[:200],
happened_on=happened_on, category_id=category_id, user_id=current_user.id,
)
db.session.add(row)
db.session.commit()
return row.to_dict(), 201
@bp.get("/transactions")
@login_required
def list_transactions():
page = max(request.args.get("page", 1, type=int), 1)
size = min(max(request.args.get("size", 10, type=int), 1), 100)
query = db.select(Transaction).where(Transaction.user_id == current_user.id)
if kind := request.args.get("kind"):
query = query.where(Transaction.kind == kind)
if category_id := request.args.get("category_id", type=int):
query = query.where(Transaction.category_id == category_id)
rows = db.session.scalars(
query.order_by(Transaction.happened_on.desc(), Transaction.id.desc())
.offset((page - 1) * size).limit(size)
).all()
return {"page": page, "size": size, "items": [row.to_dict() for row in rows]}
@bp.delete("/transactions/<int:transaction_id>")
@login_required
def delete_transaction(transaction_id: int):
row = db.session.scalar(
db.select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == current_user.id
)
)
if not row:
return {"message": "账目不存在"}, 404
db.session.delete(row)
db.session.commit()
return "", 204
@bp.get("/statistics/monthly")
@login_required
def monthly_statistics():
month = request.args.get("month", date.today().strftime("%Y-%m"))
try:
start = date.fromisoformat(month + "-01")
except ValueError:
return {"message": "月份格式应为YYYY-MM"}, 400
end = date(start.year + (start.month == 12), 1 if start.month == 12 else start.month + 1, 1)
rows = db.session.execute(
db.select(Transaction.kind, func.sum(Transaction.amount))
.where(Transaction.user_id == current_user.id,
Transaction.happened_on >= start, Transaction.happened_on < end)
.group_by(Transaction.kind)
).all()
totals = {"income": Decimal("0"), "expense": Decimal("0")}
totals.update({kind: Decimal(total) for kind, total in rows})
return {"month": month, "income": str(totals["income"]),
"expense": str(totals["expense"]),
"balance": str(totals["income"] - totals["expense"])}
验收命令:python -m pytest -q(Django 项目使用 python manage.py test)。预期测试通过;若失败先检查迁移、配置和事务回滚。
二、本篇测试目标
从注册、登录、创建分类、创建账目、读取报表到删除账目,写一条完整业务流程;再写错误场景验证事务没有留下脏数据。

三、测试夹具
python
@pytest.fixture()
def app(tmp_path):
app = create_app({'TESTING': True, 'SQLALCHEMY_DATABASE_URI': f'sqlite:///{tmp_path / "test.db"}'})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
每个测试使用独立临时文件,测试结束删除;不能使用开发数据库,否则测试可能删除真实账目。
四、验收重点
必须覆盖:未登录访问 401、重复注册 409、错误金额 400、第二个用户看不到第一个用户数据、报表金额正确、删除后记录不存在。运行:
powershell
python -m pytest -q
课后练习:为月度报表和分类排行各增加一个断言,并测试无效月份。
项目增量:pytest 验收一条完整业务链
测试从注册开始,经过登录、分类、账目新增、筛选、报表和越权请求;每一步使用前一步产生的 id,才能证明项目真正串联起来。
python
def test_ledger_flow(client):
register(client, 'a@example.com')
login(client, 'a@example.com')
category = client.post('/categories', data={'name': '餐饮'})
entry = client.post('/entries', data={'amount': '35.00', 'kind': 'expense',
'category_id': category.json['id']})
assert entry.status_code == 201
assert client.get('/reports/month').json['expense'] == '35.00'
失败场景同样重要:重复邮箱、非法金额、他人 id、数据库约束和 session 失效。课后练习把删除账目加入流程,并保存 pytest 输出。
五、从单接口测试到用户故事测试
单元测试只能证明某个函数返回值正确,后端更需要验证"注册---登录---创建分类---记账---报表"的完整用户故事。下面的测试使用 Flask 测试客户端,所有请求共享 cookie:
python
def test_user_story(client):
assert client.post("/api/auth/register", json={
"email": "a@example.com", "password": "correct-horse"
}).status_code == 201
assert client.post("/api/auth/login", json={
"email": "a@example.com", "password": "correct-horse"
}).status_code == 200
category = client.post("/api/ledger/categories", json={"name": "餐饮"})
assert category.status_code == 201
entry = client.post("/api/ledger/entries", json={
"amount": "35.50", "kind": "expense",
"happened_on": "2025-01-02", "category_id": category.json["id"]
})
assert entry.status_code == 201
report = client.get("/api/ledger/report?start=2025-01-01&end=2025-01-31")
assert report.json["expense"] == "35.50"
这段测试有意不直接访问数据库,验证的是 HTTP 契约和会话行为。若失败,先看哪个断言失败:注册失败检查输入校验;分类失败检查 login_required;报表金额错误则检查 Decimal 和软删除过滤。
六、失败场景比成功场景更有价值
python
def test_cannot_read_another_users_entry(client, app):
response = client.get(f"/api/ledger/entries/{app.config['OTHER_ENTRY_ID']}")
assert response.status_code == 404
def test_duplicate_category_rolls_back(client):
client.post("/api/ledger/categories", json={"name": "餐饮"})
response = client.post("/api/ledger/categories", json={"name": "餐饮"})
assert response.status_code == 409
assert len(client.get("/api/ledger/categories").json["items"]) == 1
数据库唯一约束和事务回滚必须通过请求验证,不能只在模型测试里"相信它会工作"。建议用 pytest.mark.parametrize 覆盖空邮箱、短密码、负金额、未来日期等输入,减少重复代码。
七、上一篇练习讲解与本篇练习
上一篇的每日余额曲线应先按日期聚合,再在 Python 中按日期累加余额;不要为每个日期执行一次 SQL(典型 N+1)。本篇练习:增加参数化测试,覆盖金额边界、分页最后一页、删除后报表和跨用户访问;同时把测试数据 fixture 抽成工厂函数,避免复制大量字典。
测试命名要描述业务结果,例如 test_deleted_entry_is_not_counted_in_report,而不是 test_report_1。每次修复越权或事务 bug,都先添加一个能复现它的测试,再修改实现;这样项目在后续重构、切换数据库或增加缓存时仍有保护网。
本篇结束:完整模块文件
下面是交付项目中真实存在的完整文件 flask-ledger/tests/test_app.py。它覆盖本篇新增逻辑以及前文已经完成的依赖代码;复制单个函数会丢失上下文,因此这里提供整份文件。
python
def test_health(client):
assert client.get("/api/health").get_json() == {"status": "ok"}
def test_register_login_and_me(client):
response = client.post("/api/auth/register", json={"email": "A@example.com", "password": "password123"})
assert response.status_code == 201
assert client.post("/api/auth/login", json={"email": "a@example.com", "password": "password123"}).status_code == 200
assert client.get("/api/auth/me").get_json()["email"] == "a@example.com"
def test_ledger_flow(logged_client):
category = logged_client.post("/api/categories", json={"name": "餐饮"}).get_json()
created = logged_client.post("/api/transactions", json={
"kind": "expense", "amount": "28.50", "category_id": category["id"],
"happened_on": "2026-08-06", "note": "午饭",
})
assert created.status_code == 201
assert created.get_json()["amount"] == "28.50"
items = logged_client.get("/api/transactions").get_json()["items"]
assert len(items) == 1
stats = logged_client.get("/api/statistics/monthly?month=2026-08").get_json()
assert stats["expense"] == "28.50"
assert logged_client.delete(f"/api/transactions/{items[0]['id']}").status_code == 204
def test_users_cannot_share_categories(client):
client.post("/api/auth/register", json={"email": "first@example.com", "password": "password123"})
client.post("/api/auth/login", json={"email": "first@example.com", "password": "password123"})
category = client.post("/api/categories", json={"name": "工资"}).get_json()
client.post("/api/auth/logout")
client.post("/api/auth/register", json={"email": "second@example.com", "password": "password123"})
client.post("/api/auth/login", json={"email": "second@example.com", "password": "password123"})
response = client.post("/api/transactions", json={"kind": "income", "amount": "100", "category_id": category["id"]})
assert response.status_code == 404