「应该没问题」和「验证过没问题」,中间隔着整个测试阶段。这一阶段的产出是一份《测试用例与测试报告》——里面有编号、有步骤、有预期结果、有实际结果和缺陷记录。你要练的不是「会不会用 pytest」,而是用证据说话:功能测试证明它对,异常测试证明它不会崩,接口测试让它以后每次改动都能一键回归。
编号 | 前置条件 | 操作步骤 | 预期结果 | 实际结果
TC-01 | 未登录 | 打开首页 | 轮播图 1 张、分类 5 个、推荐 6 篇 | 通过
TC-02 | 未登录 | 列表页选「唐诗」分类 | 只剩 3 篇唐朝作品,分页仅 1 页 | 通过
TC-03 | 未登录 | 搜索框输入「明月」后回车 | 命中的作品出现在列表里 | 通过
TC-04 | 已注册 tester 账号 | 用 tester / 123456 登录 | 返回 token,顶栏显示昵称 | 通过
TC-05 | 已登录 | 进作品「静夜思」详情页 | 浏览量在原值上 +1 | 通过
TC-06 | 已登录 | 在详情页发表书评「好诗!」 | 书评立即出现在列表首位 | 通过
【用户模块】
注册:合法信息能注册成功;用户名重复提示「用户名已存在」
登录:正确密码返回 token;错误密码提示「用户名或密码错误」
个人信息:登录后能打开,昵称/头像/统计数正确
退出:退出后顶栏变回「登录/注册」,访问 /user 被拦回登录页
【首页模块】
轮播图:按 sort 顺序展示,点击能跳到对应链接
分类导航:5 个分类(唐诗/宋词/元曲/散文/小说)齐全,点击可筛选
推荐位:显示 6 篇推荐作品,卡片信息完整(标题/作者/朝代/浏览量)
【作品模块】
列表:分页正确(6 篇、每页 9 条 → 1 页);筛选、搜索、排序都生效
详情:正文完整;每次刷新浏览量 +1(注意:是 +1,不是 +2)
点赞:点一次变「已赞」且计数 +1;再点一次取消
【书评模块】
列表:二级回复正确嵌在一级书评下(children)
发表:发表后出现在列表,总数同步 +1
删除:只能删自己的,删别人的提示「无权删除他人书评」
【收藏模块】
收藏/取消:按钮状态与计数正确切换
我的收藏:只显示当前用户的收藏,分页正确
编号 | 类型 | 前置条件 | 操作步骤 | 预期结果 | 实际结果
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 | 通过
"""测试夹具:每个测试用例都拿到一个「干净、有数据」的应用与客户端。"""
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}"}
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
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
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
// ── 被测的纯函数:把浏览量变成人看得懂的写法 ──
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')
})
})
【桌面浏览器】
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)图片是否变形、文字是否重叠
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 + 手工走查相关路径,确认修好且没引入新问题
# 古典文学网 · 测试报告
## 一、测试概况
测试对象:古典文学网 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 面板截图
编号 | 前置条件 | 操作步骤 | 预期结果 | 实际结果
TC-20 | 未登录 | 打开 /article/list | 共 6 篇,每页 9 条 → 1 页,卡片信息完整 | 通过
TC-21 | 未登录 | 下拉选「宋词」分类 | 只剩 2 篇宋朝作品(念奴娇/声声慢) | 通过
TC-22 | 未登录 | keyword 传一个超长字符串(2000 字) | code 200,list 为空,不报 500 | 通过
TC-23 | 未登录 | page_size 传 99999 | 后端夹取为 50,正常返回,不拖垮数据库 | 通过
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
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 题的核心:异常用例要能指出「这个校验应该写在哪一层」。只会罗列异常场景,说明你还停留在「测试是点点点」;能说清每层该挡什么,才说明你理解了这个系统的结构。