跳转至

测试 — 单元、集成、端到端,和一个真实抓到的 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. 可带走的

  1. 测试三层:单元(隔离 + mock)→ 集成(两两组合)→ 端到端(全链真实),自下而上写,按频率跑。
  2. 单元测试第一问:被测件「自己不做什么」?那些就是外部依赖,全部 mock。
  3. pytest 三件套:fixture(setup 复用)、@patch(换假依赖)、assert(比对 + 自定义报错)。
  4. 两类断言:对 mock 断言(调用方式正确)+ 对结果断言(处理逻辑正确)。
  5. 前端测试用 AppTest:from_file 建实例、点号路径取元素列表、set_value/click 模拟交互、每个动作后链 .run()。
  6. 前端视角下后端也是外部依赖——mock 的圆心永远是被测件。
  7. 断言要查内容、不只数个数;带默认值的 .get 必须有「真值出现」的断言。
  8. 集成测试 = 单元测试积木的重组;E2E 是最好写的测试,因为不要求真隔离。
  9. 测试挂了就两种解释:代码错或测试过时;新功能配新测试,两 halves 一起演化。
  10. pre-commit hook 守住个人,CI 守住团队;E2E 打标少跑。
  11. 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」)


  1. 出处:「11 Testing Streamlit apps」第 36–51 段(text/20-ch11-11-testing-streamlit-apps.txt:36,搜「MovieScout」)。TMDB API 注册与响应格式见第 53–141 段。 ↩

  2. 出处:「11 Testing Streamlit apps」第 253–262 段(text/20-ch11-11-testing-streamlit-apps.txt:254,搜「cornerstone」)。 ↩

  3. 出处:「11 Testing Streamlit apps」第 264–278 段(text/20-ch11-11-testing-streamlit-apps.txt:266,搜「Manual testing」)。 ↩

  4. 出处:「11 Testing Streamlit apps」第 280–299 段(text/20-ch11-11-testing-streamlit-apps.txt:281,搜「bottom-up」)。 ↩

  5. 出处:「11 Testing Streamlit apps」第 344–382 段(text/20-ch11-11-testing-streamlit-apps.txt:366,搜「what does this method not do」)。 ↩

  6. 出处:「11 Testing Streamlit apps」第 388–399 段(text/20-ch11-11-testing-streamlit-apps.txt:390,搜「Mocking is the process」)。 ↩

  7. 出处:「11 Testing Streamlit apps」第 458–613 段(text/20-ch11-11-testing-streamlit-apps.txt:494,搜「test_backend.py」)。listing 11.5 在 498–524 段。 ↩

  8. 出处:「11 Testing Streamlit apps」第 590–596 段(text/20-ch11-11-testing-streamlit-apps.txt:595,搜「pikachu」)。 ↩

  9. 出处:「11 Testing Streamlit apps」第 690–708 段(text/20-ch11-11-testing-streamlit-apps.txt:695,搜「pytest -v」)。 ↩

  10. 出处:「11 Testing Streamlit apps」第 759–771 段(text/20-ch11-11-testing-streamlit-apps.txt:769,搜「simulates a running Streamlit app」)。 ↩

  11. 出处:「11 Testing Streamlit apps」第 780–936 段(text/20-ch11-11-testing-streamlit-apps.txt:778,搜「test_frontend.py」)。at.secrets 的便利见第 860–864 段。 ↩

  12. 出处:「11 Testing Streamlit apps」第 972–1054 段(text/20-ch11-11-testing-streamlit-apps.txt:973,搜「test_movie_details_display」)。 ↩

  13. 出处:「11 Testing Streamlit apps」第 1121–1146 段(text/20-ch11-11-testing-streamlit-apps.txt:1140,搜「non-existent rating key」)。 ↩

  14. 出处:「11 Testing Streamlit apps」第 1141–1145 段(text/20-ch11-11-testing-streamlit-apps.txt:1143,搜「fails silently」)。 ↩

  15. 出处:「11 Testing Streamlit apps」第 1163–1229 段(text/20-ch11-11-testing-streamlit-apps.txt:1229,搜「harmony」)。 ↩

  16. 出处:「11 Testing Streamlit apps」第 1245–1278 段(text/20-ch11-11-testing-streamlit-apps.txt:1253,搜「Ferris Bueller's Day Off」)。 ↩

  17. 出处:「11 Testing Streamlit apps」第 1298–1364 段(text/20-ch11-11-testing-streamlit-apps.txt:1303,搜「purity」)。 ↩

  18. 出处:「11 Testing Streamlit apps」第 1422–1428 段(text/20-ch11-11-testing-streamlit-apps.txt:1426,搜「tag and filter」)。 ↩

  19. 出处:「11 Testing Streamlit apps」第 1384–1420 段(text/20-ch11-11-testing-streamlit-apps.txt:1419,搜「two halves」)。 ↩

  20. 出处:「11 Testing Streamlit apps」第 1429–1451 段(text/20-ch11-11-testing-streamlit-apps.txt:1432,搜「pre-commit」)。 ↩

  21. 出处:「11 Testing Streamlit apps」第 280–299 段(text/20-ch11-11-testing-streamlit-apps.txt:1422,搜「pinpoint」)。 ↩

  22. 出处:「11 Testing Streamlit apps」第 454–456 段(text/20-ch11-11-testing-streamlit-apps.txt:454,搜「convince ourselves」)。 ↩

  23. 出处:「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)。 ↩

  24. 出处:「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 列表可显示一系列图片排成一行。 ↩

  25. 出处:「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」。 ↩