测试 — 单元、集成、端到端,和一个真实抓到的 bug¶
这一章讲一件事: 怎么让应用不靠「我点开看过了」来保证正确。 工具是 pytest(测试框架)和 AppTest(Streamlit 官方的应用模拟器)。 读完整套你会得到一条可以照抄的测试策略:单元测试(mock 一切外部)守住每一小块, 集成测试守住两两组合,端到端测试守住整条链——以及一个反直觉的经验: 端到端测试往往是最好写的,单元测试才是最难的。
1. 这一章讲什么¶
被测对象是 MovieScout:输入片名 → 后端调 TMDB 电影 API → 前端把结果渲染成 可折叠卡片1。它故意选得够简单——这一章的聚光灯要打在测试上,不在应用上。
为什么要自动化测试?手测慢且靠不住;「未经测试的代码就是坏代码」这句行业老话, 本章让它落地2。测试分两大类:手动、自动;自动的再分三层—— 单元测试(单个函数/方法,隔离外界)、集成测试(组件之间的协作)、 端到端测试(整个应用按真实条件跑)3。本章自下而上全部走一遍4。
补遗:st.image —— 渲染图片的元素¶
解决什么问题: 界面里要显示图片(海报、图表、示意图)时,Streamlit 提供
st.image 元素:给它一个图片来源,它负责取图、按指定宽度渲染到页面上——
MovieScout 用它显示电影海报23。
怎么用: 最简形式 st.image(poster_url, width=250):第一个参数接受
图片 URL,width 固定渲染宽度(像素);caption 参数可在图下加一行说明文字;
把 URL 列表传给第一个参数,还能让一系列图片排成一行24。
边界: 原书只演示了「URL + 宽度」这一种用法——本地文件路径、内存字节流、
按列布局(st.columns 里各放一个)等更工程化的用法本章未展开;图片加载失败
(404、限流)时元素行为如何,书里也没交代,生产使用需自行验证(补充,不在书里)。
章末总结把「Use st.image to render images」列为要点之一,但除此一段外全书
无更多展开25。
2. 核心原理(一):单元测试的心智模型——隔离与 mock¶
单元测试的第一问:被测组件「自己不做什么」? 以 Backend.search_movies 为例: 它自己不发 HTTP 请求(那是 requests 库干的),也不执行搜索(那是 TMDB 干的)。 这两样就是它的外部依赖5。
隔离的手段叫 mock(打桩/模拟):把真依赖临时换成假的、可控的版本。 收益四条:控制外部行为(想让它返回什么就返回什么)、消除不确定性(不依赖网络)、 测试跑得独立、不发起真实 API 调用(慢、不可靠、可能烧钱)6。
一个单元测试的标准结构(书里那张流程图的文字版):
① setup:备好环境(建 Backend 实例)
② testing:
把外部依赖换成 mock(假 requests、假 API 响应)
备好输入 → 定义期望输出 → 跑被测函数 → assert 比对实际与期望
③ teardown:清理(本章无,pytest 代劳)
断言全过 = PASSED;任何一个 assert 为假 = FAILED + 期望/实际对照 diff
pytest 三件套登场:fixture(@pytest.fixture 标记的函数,测试函数的同名参数
会自动被注入返回值——setup 的复用工具);@patch(unittest.mock 的装饰器,
测试期间把 requests.get 换成 Mock 对象,该对象作为测试函数的第一个参数传入);
assert(断言语句,失败时 pytest 打印期望与实际)7。
看后端测试的真身(骨架):
@patch('requests.get') # 测试中,requests.get 被替换成假函数
def test_search_movies_success(mock_get, backend):
mock_response = Mock()
mock_response.json.return_value = {'results': MOCK_MOVIES} # 假响应,吐出测试数据
mock_get.return_value = mock_response
results = backend.search_movies("test query")
mock_get.assert_called_once_with( # 断言「行为正确」:以什么参数被调了一次
SEARCH_URL, params={"api_key": backend.api_key, "query": "test query"})
assert len(results) == len(MOCK_MOVIES) # 断言「结果正确」
两类断言的分工值得记住:对 mock 的断言(它被以正确的参数调用过)检查
「我们的代码和外部世界握手的方式」;对返回值的断言检查「我们的代码
处理数据的方式」。Mock 对象有个魔法:任何 .方法.return_value = x 都能现造
一个返回 x 的假方法8。pytest -v 跑起来,4 个测试全绿9。
3. 核心原理(二):前端测试——AppTest 模拟整个应用¶
后端的行为完全由输入决定;前端不一样,它的行为取决于用户怎么点。 测试前端需要一台「用户模拟器」——Streamlit 内置的 AppTest 类: 从你的代码构建一个可运行的应用实例,既能模拟交互、又能检查渲染输出10。
@pytest.fixture
def setup_app():
at = AppTest.from_file("frontend.py") # 造一个「应用实例」
at.secrets["api_key"] = "test_api_key" # 连 secrets 都能直接喂
return at
@patch("backend.Backend", MockBackend) # 前端视角下,Backend 也是外部依赖 → 整个换成假类
def test_search_functionality(setup_app):
at = setup_app
at.run()
at.sidebar.text_input[0].set_value("test movie").run() # 模拟输入 + 触发重跑
at.sidebar.button[0].click().run() # 模拟点击 + 触发重跑
assert at.session_state["results"] == MOCK_RESULTS # 状态对了
assert len(at.expander) == len(MOCK_MOVIES) # 界面对了
三个机制各一句话:元素访问用点号——at.sidebar.text_input 是该类型元素的
列表(at.button、at.markdown、at.expander 同理);交互后必须链 .run()——
模拟「用户动作触发的那次重跑」;MockBackend 是一整个假类(方法和真 Backend
同签名,内部只返回固定值)——@patch("backend.Backend", MockBackend) 直接把
前端文件里的 Backend 引用整个换掉11。
细节里有真金:对前端而言,后端就是外部依赖,单元测试同样要 mock 掉——
这条「谁是被测件、谁就是隔离的圆心」的原则,比任何具体 API 都值得带走。
断言也值得升级:不只数元素个数,还检查内容
(any(overview_text in text for text in markdown_texts)),
并在 assert 后面带上自定义错误消息,失败时一眼定位12。
4. 核心原理(三):测试真的抓到了 bug——rating vs vote_average¶
前端测试 test_movie_details_display 红了,报错:找不到 Test Movie 1 的评分。
追下去,前端代码里是 movie.get('rating', 'N/A'),而 API 返回的字段叫
vote_average——键名错了13。
这个 bug 的教学价值在于它为什么不曾被发现:
.get('rating', 'N/A') ←—— 键不存在时不报错,静默返回默认值
→ 界面上每部电影的评分永远显示 "N/A"
→ 不崩溃、无报错、肉眼若不专门去看评分列就完全无感
图说:静默失败是手测的天敌。测试断言的是「内容正确」,
默认值兜出来的「N/A」在断言面前现形。
书里原话:这就是「写测试能提醒我们遗漏的问题」的实例14。修掉(rating →
vote_average),7 个测试全绿。我们也把这条沉淀成经验:凡是有默认值兜底
的 .get(key, default),都值得有一个「真值出现」的断言。
5. 核心原理(四):集成测试——两两组合,端到端——全链不 mock¶
组件各自都对,不保证拼起来对;外部依赖(mock 掉的东西)本身也是应用的一部分。 所以需要集成测试:MovieScout 是「前端 → 后端 → API」的链,两两组合出两种——
- 前端 + 后端:只 patch
requests.get,其余全真。代码几乎就是单元测试的拼装 (fixture + 交互来自前端测试,假响应来自后端测试)15。 - 后端 + 真 API:连 patch 都不要,直接
Backend(secrets["api_key"])搜 "ferris bueller",断言「至少有一条结果」且「1986 年的春天不是读书天在结果里」16。
端到端测试再进一步:整个应用、真实 API、一个 mock 都没有。 反直觉的是,它是最好写的——单元测试的难点全在「纯粹性」上(要确保只测 被测件),端到端恰恰不在乎,只问「整条系统干它该干的事了吗」17。 代价也明白:依赖外部服务,慢、有调用限额、可能花钱。所以三层测试的运维结论: 单元测试常跑(快、定位准),集成/E2E 不必每次全跑——pytest 可以给测试打标签 按组过滤18。
6. 核心原理(五):测试与代码一起演化¶
测试不是写完就有的资产,它会腐烂。改代码后测试挂了,只有两种解释: 代码改错了,或测试的期望过时了——两张图说清这个循环:改代码 → 跑测试 → 绿则继续;红则判断「代码错」还是「测试错」,修对应那一边;新功能要有新测试。 代码和测试是同一个系统的两半,互为校验19。
自动化运行的两条路:个人的——Git pre-commit hook(commit 前自动 pytest, 挂了就不让提交,书里给了 4 行脚本);团队的——CI(GitHub Actions 之类), push 时自动跑、失败通知全员20。
7. 作者的判断与证据¶
判断:自下而上的测试顺序(先单元后集成后 E2E)。 依据是失败定位的精确性21。 我们认可,但补注:实践中很多团队自上而下调试(E2E 先红再往下钻)——两者是 「写测试」与「查 bug」的不同阶段,不矛盾。
判断:「写多少测试才算够」的标准是「说服自己这段代码没缺陷」。 原文 "write as many tests as required to convince ourselves"22——这是工程判断题 而非数学题,我们同意,并附实操版:每个分支、每个默认值兜底、每条错误路径, 至少各一个测试。
8. 边界与局限¶
- E2E 测试的稳定性依赖外部服务:TMDB 改版、限流、宕机都会让测试无辜变红; 生产实践通常给 E2E 单独打标、 nightly 跑,而不是挡在每次提交路上。
- AppTest 模拟的是单轮同步执行:真实浏览器的并发会话、websocket 行为、 组件的精细样式都不在它的视野里;它测「逻辑」,不测「观感」。
- 本章没有碰 LLM 应用的测试(第 09、10 章的应用怎么测?)——LLM 输出 不确定,断言怎么写是个真问题(评估集、语义断言、mock 模型),全书留白。
- mock 的维护成本被低估:
@patch('requests.get')绑的是实现细节, 重构后端内部(比如换用 httpx)会让一堆测试集体变红——这是 mock 的原罪, 本章未讨论缓解手段(如按接口抽象)。 - 覆盖率数字本章没有出现:10 个测试不代表覆盖 10 个分支;覆盖率工具 (pytest-cov)是下一个该装的工具。
判断(我们的,不是书里的): 本章是全书最「反 Streamlit 流行文化」的一章—— Streamlit 生态的真实状况是大量应用零测试,靠「重跑看一眼」交付; 而这章示范了把正统软件工程测试学落到声明式 UI 框架上的完整路径, 其中 AppTest 的存在本身就是 Streamlit 团队对工程化的回答: 「我们的执行模型怪(全脚本重跑),所以我们给你一个能模拟重跑的测试器」。 rating bug 一段是全章的点睛:最值钱的测试不是防崩溃的,是防「静默错误」的。 如果错,会错在: 本章的测试对象选得太干净(一个纯查询应用)。 写操作、多用户状态、对话框、fragment 局部重跑——第 07–10 章的那些真实复杂处, 才是 AppTest 和 mock 策略真正吃力的战场;在 MovieScout 上全绿, 不等于在那些应用上有现成的答案。
9. 可带走的¶
- 测试三层:单元(隔离 + mock)→ 集成(两两组合)→ 端到端(全链真实),自下而上写,按频率跑。
- 单元测试第一问:被测件「自己不做什么」?那些就是外部依赖,全部 mock。
- pytest 三件套:fixture(setup 复用)、@patch(换假依赖)、assert(比对 + 自定义报错)。
- 两类断言:对 mock 断言(调用方式正确)+ 对结果断言(处理逻辑正确)。
- 前端测试用 AppTest:from_file 建实例、点号路径取元素列表、set_value/click 模拟交互、每个动作后链 .run()。
- 前端视角下后端也是外部依赖——mock 的圆心永远是被测件。
- 断言要查内容、不只数个数;带默认值的 .get 必须有「真值出现」的断言。
- 集成测试 = 单元测试积木的重组;E2E 是最好写的测试,因为不要求真隔离。
- 测试挂了就两种解释:代码错或测试过时;新功能配新测试,两 halves 一起演化。
- pre-commit hook 守住个人,CI 守住团队;E2E 打标少跑。
- E2E 依赖外部服务,会无辜变红——成本与稳定性要预先设计。
10. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| MovieScout 设计与实现 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:36搜「MovieScout」) |
| 测试分类(手动/单元/集成/E2E) | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:264搜「Different kinds of tests」) |
| 自下而上的测试路线 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:281搜「bottom-up」) |
| pytest 最小例子与运行 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:301搜「quick introduction」) |
| 外部依赖与 mock 的理由 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:361搜「external dependencies」) |
| 单元测试结构(setup/testing/teardown) | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:400搜「mental model」) |
| mock_data 与后端测试 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:461搜「mock_data.py」) |
| fixture / @patch / assert 详解 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:528搜「pytest.fixture」) |
| 失败演示与 diff 输出 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:710搜「What would it look like」) |
| AppTest 介绍 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:289搜「AppTest class」) |
| MockBackend 与前端的依赖视角 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:822搜「Mocking the backend」) |
| 交互模拟(set_value/click/run) | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:937搜「Testing user interactions」) |
| rating vs vote_average 的真 bug | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:1140搜「non-existent rating key」) |
| 前端+后端集成测试 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:1163搜「integration between components」) |
| 后端+真 API 集成测试 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:1245搜「test_backend_api_integration」) |
| 端到端测试 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:1298搜「end-to-end flow」) |
| 代码与测试共同演化 | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:1384搜「Evolving code and tests」) |
| pre-commit hook 与 CI | 11 Testing Streamlit apps | text/20-ch11-11-testing-streamlit-apps.txt:1429搜「Automating test runs」) |
-
出处:「11 Testing Streamlit apps」第 36–51 段(
text/20-ch11-11-testing-streamlit-apps.txt:36,搜「MovieScout」)。TMDB API 注册与响应格式见第 53–141 段。 ↩ -
出处:「11 Testing Streamlit apps」第 253–262 段(
text/20-ch11-11-testing-streamlit-apps.txt:254,搜「cornerstone」)。 ↩ -
出处:「11 Testing Streamlit apps」第 264–278 段(
text/20-ch11-11-testing-streamlit-apps.txt:266,搜「Manual testing」)。 ↩ -
出处:「11 Testing Streamlit apps」第 280–299 段(
text/20-ch11-11-testing-streamlit-apps.txt:281,搜「bottom-up」)。 ↩ -
出处:「11 Testing Streamlit apps」第 344–382 段(
text/20-ch11-11-testing-streamlit-apps.txt:366,搜「what does this method not do」)。 ↩ -
出处:「11 Testing Streamlit apps」第 388–399 段(
text/20-ch11-11-testing-streamlit-apps.txt:390,搜「Mocking is the process」)。 ↩ -
出处:「11 Testing Streamlit apps」第 458–613 段(
text/20-ch11-11-testing-streamlit-apps.txt:494,搜「test_backend.py」)。listing 11.5 在 498–524 段。 ↩ -
出处:「11 Testing Streamlit apps」第 590–596 段(
text/20-ch11-11-testing-streamlit-apps.txt:595,搜「pikachu」)。 ↩ -
出处:「11 Testing Streamlit apps」第 690–708 段(
text/20-ch11-11-testing-streamlit-apps.txt:695,搜「pytest -v」)。 ↩ -
出处:「11 Testing Streamlit apps」第 759–771 段(
text/20-ch11-11-testing-streamlit-apps.txt:769,搜「simulates a running Streamlit app」)。 ↩ -
出处:「11 Testing Streamlit apps」第 780–936 段(
text/20-ch11-11-testing-streamlit-apps.txt:778,搜「test_frontend.py」)。at.secrets 的便利见第 860–864 段。 ↩ -
出处:「11 Testing Streamlit apps」第 972–1054 段(
text/20-ch11-11-testing-streamlit-apps.txt:973,搜「test_movie_details_display」)。 ↩ -
出处:「11 Testing Streamlit apps」第 1121–1146 段(
text/20-ch11-11-testing-streamlit-apps.txt:1140,搜「non-existent rating key」)。 ↩ -
出处:「11 Testing Streamlit apps」第 1141–1145 段(
text/20-ch11-11-testing-streamlit-apps.txt:1143,搜「fails silently」)。 ↩ -
出处:「11 Testing Streamlit apps」第 1163–1229 段(
text/20-ch11-11-testing-streamlit-apps.txt:1229,搜「harmony」)。 ↩ -
出处:「11 Testing Streamlit apps」第 1245–1278 段(
text/20-ch11-11-testing-streamlit-apps.txt:1253,搜「Ferris Bueller's Day Off」)。 ↩ -
出处:「11 Testing Streamlit apps」第 1298–1364 段(
text/20-ch11-11-testing-streamlit-apps.txt:1303,搜「purity」)。 ↩ -
出处:「11 Testing Streamlit apps」第 1422–1428 段(
text/20-ch11-11-testing-streamlit-apps.txt:1426,搜「tag and filter」)。 ↩ -
出处:「11 Testing Streamlit apps」第 1384–1420 段(
text/20-ch11-11-testing-streamlit-apps.txt:1419,搜「two halves」)。 ↩ -
出处:「11 Testing Streamlit apps」第 1429–1451 段(
text/20-ch11-11-testing-streamlit-apps.txt:1432,搜「pre-commit」)。 ↩ -
出处:「11 Testing Streamlit apps」第 280–299 段(
text/20-ch11-11-testing-streamlit-apps.txt:1422,搜「pinpoint」)。 ↩ -
出处:「11 Testing Streamlit apps」第 454–456 段(
text/20-ch11-11-testing-streamlit-apps.txt:454,搜「convince ourselves」)。 ↩ -
出处:「11 Testing Streamlit apps」第 214–217 段(
text/20-ch11-11-testing-streamlit-apps.txt:216,搜「st.image(poster_url」)。原文:expander 卡片里调 Backend.get_poster_url 取海报完整 URL,下一行st.image(poster_url, width=250)。 ↩ -
出处:「11 Testing Streamlit apps」第 228–233 段(
text/20-ch11-11-testing-streamlit-apps.txt:228,搜「st.image(poster_url, width=250)」)。原文:取 URL 渲染到屏幕,固定宽 250 像素;caption 参数可在图下显示说明;第一个参数传 URL 列表可显示一系列图片排成一行。 ↩ -
出处:「11 Testing Streamlit apps」第 1461–1468 段(
text/20-ch11-11-testing-streamlit-apps.txt:1464,搜「st.image to render」)。原文:章末总结要点「Use st.image to render images in a Streamlit app」。 ↩