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

零基础入门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
相关推荐
wanglei20070826 分钟前
消息队列的协作模式
开发语言·python
三十岁老牛再出发36 分钟前
8月25-26日总结
python·pandas
小江的记录本39 分钟前
【ORM 框架】MyBatis-Plus 核心特性、条件构造器、分页插件、乐观锁插件(附《思维导图》、《问题排查与实践清单》和《面试高频考点汇总》)
java·windows·spring boot·python·spring·面试·mybatis
CODER03041 小时前
win11系统编译安装cuda版llama-cpp-python(踩完所有的坑)
开发语言·python·llama
严谨的麻辣烫1 小时前
批量静态 IP 如何管理?用 Python 建立一个简单的 IP 资源监控方案
运维·服务器·网络·python·tcp/ip
卷无止境1 小时前
FastAPI生产环境密钥管理全解析,从一个.env文件说起
后端·python·fastapi
卷无止境1 小时前
SigV4与HTTPS,两套完全不同维度的安全机制
后端·python·fastapi
qq_426003961 小时前
多语言新增语种全量测试策略的测试范围
前端·javascript·python·自动化
SamChan901 小时前
Python+ReportLab自动生成PDF翻译质量审计报告:从数据到可视化的完整方案
开发语言·python·ai·pdf·wpf