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

阶段 5 · 测试

「应该没问题」和「验证过没问题」,中间隔着整个测试阶段。这一阶段的产出是一份《测试用例与测试报告》——里面有编号、有步骤、有预期结果、有实际结果和缺陷记录。你要练的不是「会不会用 pytest」,而是用证据说话:功能测试证明它对,异常测试证明它不会崩,接口测试让它以后每次改动都能一键回归。

为什么要测

缺陷越晚发现,越贵
先讲为什么软件工程界有一条被反复验证的经验:缺陷发现得越晚,修复成本越高。同一个「参数没校验」的 bug——
在写代码时发现,改一行,1 分钟;在测试时发现,改代码 + 重跑一遍,10 分钟;在答辩演示时被老师当场搜出一个空关键词把接口打崩,你要现场改代码、重新打包、重新部署,还带着一整页 PPT 讲不下去。
测试的全部意义,就是把缺陷往「成本最低的那个环节」赶。
讲透:测试的四个层次,各管一段
  • 功能测试——「该做的事做了没有」。点一遍主流程,看结果对不对。
  • 异常测试——「不该做的事拦住了没有」。空输入、超长输入、越权、过期 token。
  • 接口测试——把上面两类写成代码,从此一键回归,不再靠手点。
  • 兼容性测试——换个浏览器、换个屏幕尺寸,界面还站得住吗。
  • 本阶段的验收口径:功能测试全通过、异常测试全通过、接口测试可一键重跑、主流浏览器无错版。

测试用例怎么写

编号 / 前置条件 / 步骤 / 预期结果 / 实际结果
一条测试用例的五要素:编号、前置、步骤、预期、实际——缺一不可复核编号TC-ART-003前置条件已登录、库中有「唐」分类步骤列表页选「唐」→ 按浏览量排序预期结果只显示唐类作品,按 views 降序实际结果与预期一致 ✓ / 不一致 ✗ 记缺陷预期结果必须在执行前写好——先跑再定预期,测试就失去意义了
先讲为什么「我试了一下没问题」和「第 TC-07 条用例执行通过」,差别在哪?——前者没法复现,后者谁拿着都能重跑一遍。测试用例的本质,是把「我要怎么试」写成别人也能照着执行的说明书。它有三个作用:让测试可重复、让缺陷可追溯、让覆盖范围一眼可查。
动手做先定好用例表的列。五列缺一不可——尤其「前置条件」和「预期结果」这两列最容易被新手省掉,而它们恰恰是「能复现」的关键。
测试用例表(模板 · 古典文学网)text
编号    | 前置条件           | 操作步骤                          | 预期结果                        | 实际结果
TC-01  | 未登录             | 打开首页                          | 轮播图 1 张、分类 5 个、推荐 6 篇 | 通过
TC-02  | 未登录             | 列表页选「唐诗」分类              | 只剩 3 篇唐朝作品,分页仅 1 页   | 通过
TC-03  | 未登录             | 搜索框输入「明月」后回车          | 命中的作品出现在列表里           | 通过
TC-04  | 已注册 tester 账号 | 用 tester / 123456 登录           | 返回 token,顶栏显示昵称         | 通过
TC-05  | 已登录             | 进作品「静夜思」详情页            | 浏览量在原值上 +1                | 通过
TC-06  | 已登录             | 在详情页发表书评「好诗!」        | 书评立即出现在列表首位           | 通过
效果预览运行结果
运行结果: # 用例表写完后自查三件事 1)每条用例的「步骤」是不是别人照着就能执行?(不能出现「点一下那个东西」这种描述) 2)每条用例的「预期结果」是不是可判定的?(不能写「显示正常」,要写清楚显示什么) 3)编号连续、无重复,便于缺陷记录里引用(「TC-06 不通过」)
讲透:好用例的三个特征
  • 一条用例只验一件事。「登录后发表书评并点赞」应该拆成两条——否则失败时你分不清是登录坏了还是点赞坏了。
  • 前置条件写清「账号状态 + 数据状态」。比如「已登录、作品 1 已有 2 条一级书评」,缺了它,别人跑出来的结果和你不一样。
  • 覆盖要「正向 + 反向」成对写。有「输入正确密码能登录」,就必须有「输入错误密码提示用户名或密码错误」。只写正向用例,等于只测了一半。
  • 数量不是目标。6–10 条覆盖主流程的核心用例,胜过 50 条互相重复的凑数用例。
避坑只测「快乐路径」是新手最普遍的问题:每条用例都是「输入正确的东西、得到正确的结果」,全绿,然后一上线就被一个空关键词打崩。记住这个比例——正向用例和异常用例至少要 1:1。你写了几条「能成功」,就该写几条「会失败」。

功能测试

逐个模块走查要点
动手做按模块顺序走查。不要「东点一下西点一下」,按功能顺序走一遍,这样顺带就把「模块间的衔接」也测了(比如登录 → 收藏 → 去个人中心看收藏)。
功能走查清单(按模块)text
【用户模块】
  注册:合法信息能注册成功;用户名重复提示「用户名已存在」
  登录:正确密码返回 token;错误密码提示「用户名或密码错误」
  个人信息:登录后能打开,昵称/头像/统计数正确
  退出:退出后顶栏变回「登录/注册」,访问 /user 被拦回登录页

【首页模块】
  轮播图:按 sort 顺序展示,点击能跳到对应链接
  分类导航:5 个分类(唐诗/宋词/元曲/散文/小说)齐全,点击可筛选
  推荐位:显示 6 篇推荐作品,卡片信息完整(标题/作者/朝代/浏览量)

【作品模块】
  列表:分页正确(6 篇、每页 9 条 → 1 页);筛选、搜索、排序都生效
  详情:正文完整;每次刷新浏览量 +1(注意:是 +1,不是 +2)
  点赞:点一次变「已赞」且计数 +1;再点一次取消

【书评模块】
  列表:二级回复正确嵌在一级书评下(children)
  发表:发表后出现在列表,总数同步 +1
  删除:只能删自己的,删别人的提示「无权删除他人书评」

【收藏模块】
  收藏/取消:按钮状态与计数正确切换
  我的收藏:只显示当前用户的收藏,分页正确
效果预览运行结果
运行结果: # 走查时用一个固定的测试账号,别用管理员账号 账号:tester / 123456 理由:管理员账号数据多、状态杂,一次操作会牵动大量数据, 出了问题不好判断;固定的小号数据干净,结果可预期
讲透:功能测试里最容易漏掉的三类「衔接」
  • 状态同步——在详情页点了收藏,切到「我的收藏」页,能看到这条吗?很多人只顾着测单个页面,忘了测数据在两个页面之间是否一致。
  • 计数一致性——详情页显示「书评(2)」,书评列表真的渲染出 2 条吗?发表一条后,计数有没有变成 3?
  • 顺序正确性——新发表的书评应该在最上面(按时间倒序),翻页时不能有重复或漏条(这就要看后端排序有没有带次关键字 id)。

异常测试

空输入 / 超长 / 越权 / 过期 / 重复 / 越界
先讲为什么正常的用户会规规矩矩地填表单、点按钮。但真实世界里还有:手滑点到空白处就提交的、复制粘贴一大段文字的、把地址栏里的 /detail/1 手动改成 /detail/99999 的、拿别人的 token 去删别人书评的。
你的系统能不能扛住这些?扛不住就是 bug,而且是答辩现场最容易被老师一键触发的 bug。异常测试就是替老师把这些「坏输入」先试一遍。
动手做先把六类异常用例列全,再逐条执行。注意「预期结果」不能只写「不崩溃」,要写清楚具体返回什么码、什么提示。
异常用例表(六类)text
编号    | 类型     | 前置条件        | 操作步骤                            | 预期结果                                  | 实际结果
TC-10  | 空输入   | 已登录          | 书评内容留空直接点「发表」          | code 400「书评内容不能为空」,不新增数据  | 通过
TC-11  | 空输入   | 未登录          | 登录页用户名/密码留空点登录         | 前端提示必填,不发请求                    | 通过
TC-12  | 超长输入 | 已登录          | 书评粘贴 1200 个字提交              | code 400「书评内容不能超过 1000 字」      | 通过
TC-13  | 越权访问 | tester 已登录   | 调 DELETE /api/comment/1(他人的)  | code 403「无权删除他人书评」,数据未变    | 通过
TC-14  | token 过期| 把本地 token 改坏 | 带坏 token 访问 /api/user/profile  | code 401「登录状态无效,请重新登录」,跳登录 | 通过
TC-15  | 重复操作 | 已登录          | 对同一作品连点两次「收藏」          | 第一次 favorited=true,第二次 false(切换)| 通过
TC-16  | 分页越界 | 未登录          | 请求 /api/article/list?page=999     | code 200,list 为空数组,total 仍为 6      | 通过
效果预览运行结果
运行结果: # 用 curl 现场验证 TC-16(分页越界),确认后端不报错、不崩 $ curl -s "http://127.0.0.1:5000/api/article/list?page=999&page_size=9" {"code":200,"msg":"success","data":{"list":[],"total":6,"page":999,"page_size":9}} # 用 curl 验证 TC-13(越权删除他人书评) $ curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \ http://127.0.0.1:5000/api/comment/1 {"code":403,"msg":"无权删除他人书评","data":null}
讲透:六类异常各自的「背后原理」
  • 空输入——靠后端「参数校验前置」挡(if not content: return fail(...)),前端同时做必填提示,两层一起才稳。
  • 超长输入——后端 len(content) > 1000 拦截;顺便提一句,数据库字段是 TEXT,纵容超长也不会报错,所以要靠业务层限制。
  • 越权访问——这是最重要的一类。后端必须比对 comment.user_id != g.current_user.id 才返回 403。前端藏起「删除」按钮不算数,接口能被直接调用。
  • token 过期——靠 @jwt.expired_token_loader 统一回 401,前端拦截器再统一跳登录。
  • 重复操作——favorite 表有唯一键 uk_fav_user_article,并发连点会抛 IntegrityError;这一条一定要单独测,它是真实存在的并发缺陷。
  • 分页越界——后端用 error_out=False,越界返回空列表而不是 404。前端遇到空列表要显示「没有更多了」,不能白屏。
避坑做异常测试时,别在「看起来正常」的地方停手。举例:「连点两次收藏」你点了两次,界面显示「已收藏」,你就写通过了——但你有没有去数据库里看一眼,favorite 表里是不是真的只留了一条?界面正确 ≠ 数据正确。异常测试必须验证「数据库里的结果」,否则你测的只是前端的一场表演。

接口测试(pytest)

把上面两类用例写成代码,一键回归
先讲为什么手工测一遍要花半小时,改完代码还得重来一遍。而 pytest 的价值是:把「怎么测」写成代码,之后一条命令跑完全部用例,几秒钟出结果。更重要的是——它跑在测试数据库上(TestingConfig 用的是内存 SQLite),不会污染你的开发数据。
动手做先写 conftest.py:它负责在每次测试前建表 + 造最小数据,测试后拆表。所有测试文件都能直接用这些 fixture。
tests/conftest.pypython
"""测试夹具:每个测试用例都拿到一个「干净、有数据」的应用与客户端。"""

import pytest

from app import create_app
from extensions import db
from models import Article, Category, User


@pytest.fixture
def app():
    """用 testing 配置起应用:内存 SQLite,不碰开发库。"""
    app = create_app("testing")
    with app.app_context():
        db.create_all()
        _seed()          # 造最小可用数据
        yield app
        db.session.remove()
        db.drop_all()    # 用完即拆,用例之间互不影响


@pytest.fixture
def client(app):
    """Flask 自带的测试客户端,不用真的起服务器就能发请求。"""
    return app.test_client()


def _seed():
    """一条分类 + 一篇作品 + 两个用户,够跑完本阶段所有用例。"""
    poetry = Category(name="唐诗", sort=1)
    db.session.add(poetry)
    db.session.flush()          # 拿到 poetry.id

    db.session.add(Article(
        title="静夜思", author="李白", dynasty="唐",
        category_id=poetry.id, summary="床前明月光", content="床前明月光……",
    ))

    for name in ("tester", "other"):
        u = User(username=name, nickname=name)
        u.set_password("123456")
        db.session.add(u)

    db.session.commit()


# ---------- 小工具:登录拿 token / 拼鉴权头 ----------
def login(client, username="tester", password="123456"):
    resp = client.post("/api/user/login", json={"username": username, "password": password})
    return resp.get_json()["data"]["token"]


def auth(token):
    return {"Authorization": f"Bearer {token}"}
运行输出Python 运行结果
运行结果: # 目录结构 tests/ ├─ conftest.py ├─ test_user.py ├─ test_comment.py └─ test_favorite.py # 一条命令跑全部(-q 精简输出,-v 显示每条用例名) $ pytest -q collected 12 items ............ 12 passed in 1.84s
动手做写用户模块的用例:正向(登录成功)+ 反向(密码错误、未带 token)。注意断言断言的是业务 code 字段,不是 HTTP 状态码——因为本项目 HTTP 一律返回 200。
tests/test_user.pypython
from conftest import auth, login


def test_login_success(client):
    """TC-04:用户名密码正确 → 返回 token 与用户信息。"""
    resp = client.post("/api/user/login", json={"username": "tester", "password": "123456"})
    body = resp.get_json()

    assert body["code"] == 200
    assert body["data"]["token"]                    # token 非空
    assert body["data"]["user"]["nickname"] == "tester"
    assert "password" not in body["data"]["user"]    # 敏感字段绝不能返回


def test_login_wrong_password(client):
    """TC-04-反:密码错误 → 400,且提示不暴露账号是否存在。"""
    resp = client.post("/api/user/login", json={"username": "tester", "password": "000000"})
    body = resp.get_json()

    assert body["code"] == 400
    assert body["msg"] == "用户名或密码错误"


def test_profile_without_token(client):
    """TC-14:不带 token 访问需登录接口 → 401。"""
    resp = client.get("/api/user/profile")
    body = resp.get_json()

    assert body["code"] == 401
    assert body["data"] is None


def test_profile_with_token(client):
    """TC-14-正:带上合法 token → 200,且返回统计数。"""
    token = login(client)
    resp = client.get("/api/user/profile", headers=auth(token))
    body = resp.get_json()

    assert body["code"] == 200
    assert body["data"]["username"] == "tester"
    assert body["data"]["stat"]["favorite_count"] == 0
运行输出Python 运行结果
运行结果: $ pytest tests/test_user.py -v tests/test_user.py::test_login_success PASSED [ 25%] tests/test_user.py::test_login_wrong_password PASSED [ 50%] tests/test_user.py::test_profile_without_token PASSED [ 75%] tests/test_user.py::test_profile_with_token PASSED [100%] 4 passed in 0.62s
动手做写书评模块的两个关键用例:空内容与越权删除。越权这条最能体现接口测试的价值——因为它是「前端根本点不到」的场景,只有靠代码直接调接口才能测。
tests/test_comment.pypython
from conftest import auth, login


def test_add_comment_empty_content(client):
    """TC-10:书评内容为空 → 400,且不新增数据。"""
    token = login(client)
    resp = client.post("/api/comment/add",
                       json={"article_id": 1, "content": "   "},
                       headers=auth(token))
    body = resp.get_json()

    assert body["code"] == 400
    assert body["msg"] == "书评内容不能为空"

    # 再查一次列表,确认真的没写进去(不是「前端没显示」而已)
    listed = client.get("/api/comment/list?article_id=1").get_json()
    assert listed["data"]["total"] == 0


def test_add_comment_too_long(client):
    """TC-12:书评超过 1000 字 → 400。"""
    token = login(client)
    resp = client.post("/api/comment/add",
                       json={"article_id": 1, "content": "诗" * 1001},
                       headers=auth(token))

    assert resp.get_json()["code"] == 400
    assert "1000" in resp.get_json()["msg"]


def test_delete_others_comment_forbidden(client):
    """TC-13:删除别人的书评 → 403,且数据仍在。

    这是「前端点不到」的场景 —— 只有直接调接口才能验证后端的越权校验。
    """
    # ① tester 发表一条书评,记下 id
    token_tester = login(client, "tester")
    added = client.post("/api/comment/add",
                        json={"article_id": 1, "content": "好诗!"},
                        headers=auth(token_tester)).get_json()
    cid = added["data"]["comment_id"]

    # ② 换 other 登录,尝试删除 tester 的书评
    token_other = login(client, "other")
    resp = client.delete(f"/api/comment/{cid}", headers=auth(token_other))
    body = resp.get_json()

    assert body["code"] == 403
    assert body["msg"] == "无权删除他人书评"

    # ③ 书评应该还在
    listed = client.get("/api/comment/list?article_id=1").get_json()
    assert listed["data"]["total"] == 1
运行输出Python 运行结果
运行结果: $ pytest tests/test_comment.py -v tests/test_comment.py::test_add_comment_empty_content PASSED [ 33%] tests/test_comment.py::test_add_comment_too_long PASSED [ 66%] tests/test_comment.py::test_delete_others_comment_forbidden PASSED [100%] 3 passed in 0.71s # 注意:这三条全部通过,说明后端把「空内容 / 超长 / 越权」都挡住了 —— # 如果哪条失败,那就是一个真实缺陷,要立刻记进缺陷清单
动手做最后一条:收藏的「往返测试」——同一个用例里连调两次,验证 toggle 语义。
tests/test_favorite.pypython
from conftest import auth, login


def test_favorite_toggle_round_trip(client):
    """TC-15:连点两次收藏 —— 一次收藏、一次取消,状态正确切换。"""
    token = login(client)
    article_id = 1
    url = "/api/favorite/toggle"
    payload = {"article_id": article_id}

    # 第一次:收藏
    first = client.post(url, json=payload, headers=auth(token)).get_json()
    assert first["code"] == 200
    assert first["data"]["favorited"] is True

    # 第二次:取消收藏
    second = client.post(url, json=payload, headers=auth(token)).get_json()
    assert second["code"] == 200
    assert second["data"]["favorited"] is False

    # 最终:收藏列表里应该是空的(不是「多了一条」也不是「少了一条」)
    listed = client.get("/api/favorite/list", headers=auth(token)).get_json()
    assert listed["data"]["total"] == 0
运行输出Python 运行结果
运行结果: $ pytest -q collected 12 items ............ 12 passed in 1.94s # ── 汇总(写进测试报告)── 用例总数 12 | 通过 12 | 失败 0 | 通过率 100% 覆盖模块:用户 / 书评 / 收藏(其余模块见手工用例表)
避坑两件事一定要盯住:① 测试必须连测试库(create_app("testing") → 内存 SQLite)。如果你图省事用了 create_app("dev"),测试里的 db.drop_all() 会把你的开发库整个清空——这个坑每年都有人踩。② 用例之间不能互相依赖:每条用例都应该自己造数据、自己断言;A 用例的通过不能靠 B 用例先跑过,否则单跑某一条就会失败。

前端测试

手动为主,用 Vitest 补单元测试
先讲为什么前端的测试重心和后台不一样:页面靠「看」——布局乱没乱、文字串没串行、按钮点了有没有反应,这些人工走查最快。但有一类东西不适合用眼睛看:纯逻辑函数(比如「把 1234 格式化成 1.2k」、「按浏览量排序」)。这类逻辑用 Vitest 写单元测试最划算。
动手做前端测试分两条线走:手工走查(主体,占 90% 时间)+ Vitest 单元测试(挑 2–3 个纯函数)。这里给出 Vitest 的入口写法。
src/utils/format.js + 测试javascript
// ── 被测的纯函数:把浏览量变成人看得懂的写法 ──
export function formatCount(n) {
  if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, '') + 'w'
  if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
  return String(n)
}

// ── tests/unit/format.spec.js ──
import { describe, it, expect } from 'vitest'
import { formatCount } from '@/utils/format'

describe('formatCount', () => {
  it('小于 1000 原样返回', () => {
    expect(formatCount(328)).toBe('328')
  })
  it('千位用 k', () => {
    expect(formatCount(1200)).toBe('1.2k')
    expect(formatCount(2000)).toBe('2k')       // 整数不带 .0
  })
  it('万位用 w', () => {
    expect(formatCount(12345)).toBe('1.2w')
  })
})
页面效果浏览器运行结果
运行结果: $ npx vitest run RUN v1.6.0 ✓ tests/unit/format.spec.js (3 tests) 6ms Test Files 1 passed (1) Tests 3 passed (3) Duration 412ms
讲透:前端什么该测、什么不该测
  • 该写单元测试:纯函数(格式化、校验、排序)、工具方法、store 里的 action。它们「输入确定、输出确定」,测试成本最低。
  • 该手工走查:布局、样式、交互反馈、动画。这些没有客观标准,「看起来对不对」只能靠人。
  • 别过度追求自动化:给一个刚起步的项目写 E2E(端到端)测试框架,投入产出比很低。本课程的要求是「手工走查覆盖全部页面 + 关键逻辑有单元测试入口」,做到这一点就足够了。
  • 记住原则:测逻辑用代码,测体验用眼睛。

跨浏览器与响应式测试

Chrome / Edge / Safari + 手机尺寸
先讲为什么同一个页面,在 Chrome 里好好的,到了 Safari 可能就散架——不同浏览器对 CSS、日期格式、fetch 的实现细节有差异。而老师的电脑上装的很可能不是 Chrome。这个测试不是可选项,它是「答辩当天不出洋相」的保险。
动手做用 Chrome DevTools 的设备模拟器快速过一遍手机尺寸,再在真实浏览器里各开一次。
兼容性测试清单text
【桌面浏览器】
  Chrome 最新版    —— 基准,功能全测
  Edge 最新版      —— 内核与 Chrome 同源,重点看布局
  Safari 16+       —— 重点看:日期显示、input 样式、弹性布局

【手机尺寸(DevTools 设备模拟)】
  iPhone SE      375 × 667   —— 最小主流宽度,最容易崩
  iPhone 14 Pro  393 × 852
  iPad Mini      768 × 1024  —— 看平板下的栅格断点

【每个尺寸都看这四项】
  1)有没有横向滚动条(出现 = 布局溢出)
  2)卡片是否从每行 3 个自动降为 2 个 / 1 个
  3)顶栏菜单是否收进汉堡按钮且能展开
  4)图片是否变形、文字是否重叠
效果预览运行结果
运行结果: # 在 DevTools 里依次切换设备,记录问题(示例) iPhone SE 375px: 首页推荐卡片每行 1 个 —— 正常(el-col :xs="24") 列表页筛选栏两个控件挤在一行 —— 缺陷 BUG-03,需改为竖排 Safari 17: 书名号「」显示正常,日期格式正常 —— 无问题 Edge 120: 与 Chrome 一致 —— 无问题
讲透:响应式靠栅格属性,不靠写两套页面
  • Element Plus 的 el-col 有断点属性::xs="24" :sm="12" :md="8" 表示「手机占满一行、平板一行两个、桌面一行三个」。
  • 一套代码适配三种屏幕,不要给手机单独写一套页面——维护成本翻倍。
  • 测的时候重点看「最小宽度」:375px 下不溢出、不重叠,往上的尺寸基本就没问题了。
  • 样式问题优先用 DevTools 的 Elements 面板改着试,试好了再回代码里改——比反复改代码刷新快得多。
避坑「我电脑上用 Chrome 看着没问题啊」是兼容性测试里最没用的一句话。两个高频陷阱:① 只在最大化的宽屏上看,一缩小窗口就崩——测试时务必手动把窗口拉到 375px 宽;② 只在自己的电脑上测,而你的电脑可能装了各种字体和插件掩盖了问题。至少要换一台设备或让别人在你的页面上点一遍。

缺陷记录与测试报告

本阶段的两份文档产出
缺陷的一生:发现 → 提交 → 修复 → 回归 → 关闭,环环留痕发现执行用例时实际≠预期提交记录复现步骤+截图 +日志修复开发定位修改注明原因回归重跑原用例+相关用例关闭通过则关闭不通过退回回归不通过 → 退回修复(缺陷状态回退,直到通过才关闭)测试报告 = 用例执行统计 + 缺陷清单 + 遗留风险——答辩时最有说服力的附件
先讲为什么测出来的问题,如果只是「记在脑子里」,它迟早会丢。缺陷记录的作用是让问题可跟踪:谁发现的、什么条件下复现、严重程度多少、改没改、怎么验证的。测试报告则是给老师看的「体检结论」——测了多少、通过多少、还剩哪些问题、能不能上线。
动手做缺陷记录建议用表格 + 状态字段。下面的例子是一条真实会遇到的并发缺陷。
缺陷记录表(示例)text
BUG-ID | 关联用例 | 标题                          | 严重程度 | 复现步骤                             | 期望           | 实际        | 状态
BUG-01 | TC-15    | 连点两次「收藏」报 500         | 高       | 快速双击收藏按钮(间隔 < 200ms)      | 状态正确切换   | 500 服务器错误| 已修复
BUG-02 | TC-16    | 分页越界后页面白屏            | 中       | 列表页地址栏 page 改为 999            | 显示「没有更多」| 整页空白   | 已修复
BUG-03 | —        | 375px 下筛选栏控件重叠        | 低       | DevTools 切到 iPhone SE              | 竖排显示       | 横向重叠    | 待修复

# ── 修复流程(发现 → 复现 → 定位 → 修复 → 回归)──
1. 发现:测试时截屏 + 记下复现步骤(不写「收藏坏了」,写「连点两次报 500」)
2. 复现:换台电脑/清缓存再试一次,确认不是偶发(偶发也要记,标「偶发」)
3. 定位:看 Network 的状态码 + Flask 终端的堆栈 → 锁定是前端还是后端
4. 修复:改最小范围的代码,改完立刻补一条 pytest 用例把它「钉住」
5. 回归:重跑 pytest + 手工走查相关路径,确认修好且没引入新问题
效果预览运行结果
运行结果: # BUG-01 的定位过程(记录在缺陷单里) 现象:快速双击收藏 → POST /api/favorite/toggle 返回 500 堆栈:sqlalchemy.exc.IntegrityError: Duplicate entry '3-1' for key 'uk_fav_user_article' 原因:两个请求都走了 INSERT 分支,撞上唯一键 修复:在 favorite_toggle 里 try/except IntegrityError → db.session.rollback() (并发下第二次本就该当「已收藏」处理,不该报错) 回归:补 pytest 用例 test_favorite_concurrent_insert → 通过
动手做最后写《测试报告》。它不需要很长,但必须有这五块内容——这就是阶段 5 要交的文档。
测试报告模板.mdmarkdown
# 古典文学网 · 测试报告

## 一、测试概况
测试对象:古典文学网 v1.0(前端 hivue + 后端 hiflask + MySQL 8)
测试时间:2026-10-14 ~ 2026-10-16
测试环境:Windows 11 / Chrome 120、Edge 120、Safari 17(Mac)/ iPhone SE 模拟
测试人员:张三
测试方法:手工功能走查 + 异常走查 + pytest 接口自动化 + 兼容性走查

## 二、测试范围与结果汇总
| 模块   | 用例数 | 通过 | 失败 | 通过率 |
|---|---|---|---|---|
| 用户   | 6      | 6    | 0    | 100%   |
| 首页   | 3      | 3    | 0    | 100%   |
| 作品   | 7      | 7    | 0    | 100%   |
| 书评   | 6      | 6    | 0    | 100%   |
| 收藏   | 4      | 4    | 0    | 100%   |
| 合计   | 26     | 26   | 0    | 100%   |

## 三、缺陷统计
共发现 3 个缺陷:高 1 个(已修复)、中 1 个(已修复)、低 1 个(待修复)
遗留问题:BUG-03 移动端筛选栏重叠,不影响主流程,转阶段 6 前修复

## 四、测试结论
功能测试与异常测试全部通过;pytest 12 条接口用例一键回归通过。
判定:本版本达到上线标准,可进入阶段 6 部署上线。

## 五、附件
附件1:测试用例表(26 条,含步骤与预期结果)
附件2:缺陷记录表(3 条)
附件3:pytest 运行截图、Network 面板截图
效果预览运行结果
运行结果: # 测试报告交付前的自查 [ ] 用例表有编号、有前置条件、有可判定的预期结果 [ ] 正向与异常用例数量大致相当 [ ] 每条缺陷都有复现步骤,能被人照着复现 [ ] pytest 能一键跑过,命令和结果截图都附上 [ ] 结论明确:能不能上线,遗留问题是什么
讲透:本阶段的交付物清单
  • 《测试用例与测试报告》:用例表(26 条左右)+ 结果汇总 + 缺陷统计 + 测试结论。
  • 缺陷记录表:每条缺陷含 ID、复现步骤、严重程度、状态。
  • 可一键重跑的 pytest 用例集:tests/ 目录 + pytest -q 通过截图。
  • 兼容性测试记录:三个浏览器 + 三种手机尺寸的走查结论。
  • 一句话结论:能不能上线、遗留问题是什么——这是老师最关心的一行。
避坑别把「测试报告」写成「我测了,都挺好」。没有用例表、没有缺陷记录、没有结论的测试报告,评分基本为零。报告的价值在于「可核对」:随便挑一条用例,别人应该能照着你的步骤复现出同样的结果。另外,遗留缺陷一定要如实写出来——隐瞒一个已知问题,比承认它有风险得多。
动手练习
1为「作品列表」模块补 4 条用例(2 条正向 + 2 条异常),填进用例表:编号、前置条件、步骤、预期结果、实际结果五列都要写全。
2写一个 pytest 用例,验证「未登录时点赞作品返回 401,且点赞数不变」——要求断言 code、断言 msg,并重新查一次详情确认 likes 没被改动。
3迁移练习:「红影赏析」的电影模块多了一个「评分」功能(POST /api/movie/rate,参数 movie_id、score,分值 1–10 的整数)。请为它设计 5 条异常用例,至少覆盖「未登录」「score 非整数」「score 超出范围」「movie_id 不存在」这四种,并说明每条对应的后端校验逻辑写在哪一层。
参考答案
第 1 题 · 作品列表用例text
编号    | 前置条件      | 操作步骤                                | 预期结果                                | 实际结果
TC-20  | 未登录        | 打开 /article/list                      | 共 6 篇,每页 9 条 → 1 页,卡片信息完整 | 通过
TC-21  | 未登录        | 下拉选「宋词」分类                       | 只剩 2 篇宋朝作品(念奴娇/声声慢)      | 通过
TC-22  | 未登录        | keyword 传一个超长字符串(2000 字)     | code 200,list 为空,不报 500           | 通过
TC-23  | 未登录        | page_size 传 99999                      | 后端夹取为 50,正常返回,不拖垮数据库   | 通过
效果预览运行结果
运行结果: # TC-21 的请求与响应(对照验证) GET /api/article/list?category_id=2&page=1&page_size=9 {"code":200,"msg":"success","data":{"list":[ {"id":3,"title":"念奴娇·赤壁怀古","author":"苏轼","dynasty":"宋","category_name":"宋词"}, {"id":4,"title":"声声慢","author":"李清照","dynasty":"宋","category_name":"宋词"}], "total":2,"page":1,"page_size":9}}
第 2 题 · 未登录点赞python
def test_like_without_token(client):
    """未登录点赞 → 401,且点赞数不变(接口被挡住,业务没执行)。"""
    # ① 先记下点赞前的数字
    before = client.get("/api/article/detail/1").get_json()["data"]["likes"]

    # ② 不带 Authorization 头直接调点赞接口
    resp = client.post("/api/article/like", json={"article_id": 1})
    body = resp.get_json()

    assert body["code"] == 401
    assert body["msg"] == "请先登录"

    # ③ 点赞数必须没被改动
    after = client.get("/api/article/detail/1").get_json()["data"]["likes"]
    assert after == before
运行输出Python 运行结果
运行结果: $ pytest tests/test_article.py::test_like_without_token -v tests/test_article.py::test_like_without_token PASSED [100%] 1 passed in 0.28s # 关键点:第 ③ 步的 assert after == before 才是这条用例的灵魂 —— # 只断言 code=401,测不出「接口返回了 401 但数据也被改了」这种半吊子实现
第 3 题 · 红影评分异常用例text
TC-30 | 未登录         | POST /api/movie/rate 不带 token            | 401「请先登录」,评分不写入        | 视图层(@login_required)
TC-31 | 已登录         | score 传 "8.5"(字符串而非整数)           | 400「评分必须为 1-10 的整数」      | 参数校验层(视图内校验前置)
TC-32 | 已登录         | score 传 0 或 11(超出范围)               | 400「评分必须为 1-10 的整数」      | 参数校验层
TC-33 | 已登录         | movie_id 传 99999(不存在)                | 404「电影不存在」,评分不写入      | 业务校验层(查库后判断)
TC-34 | 已登录         | 对同一部电影重复提交评分                   | 200,更新已有评分而非新增一条      | 数据层(唯一键 uk_rate_user_movie + 冲突时更新)
效果预览运行结果
运行结果: # 三层各自管什么(这是第 3 题的答题要点) ① 鉴权层:login_required 装饰器 —— 管「有没有资格调用」 ② 参数层:视图入口先校验类型与范围 —— 管「参数合不合法」 ③ 业务层:查库确认资源存在、唯一键保证不重复 —— 管「业务规则成不成立」 顺序不能颠倒:鉴权 → 参数 → 业务。把 404 校验写在鉴权之前, 未登录的人就能通过返回码探测「哪部电影存在」,是信息泄露。

第 3 题的核心:异常用例要能指出「这个校验应该写在哪一层」。只会罗列异常场景,说明你还停留在「测试是点点点」;能说清每层该挡什么,才说明你理解了这个系统的结构。

本阶段小结