Python / 命令文档
Python 测试核心(面试向)
概述
本篇是「Python 面试核心」系列第四篇,讲测试:单元测试、pytest、mock、集成测试与覆盖率。测试能力是高级工程师的分水岭 —— 不是会不会写测试,而是设计出「可测试的代码」。
所有示例均在 CentOS 测试机(Python 3.10.12 + pytest 7.4.4 + fastapi 0.141)实测通过。
测试金字塔:单元测试(多、快、便宜)打底,集成测试(中)验证模块协作,端到端(少、慢、贵)保关键路径。面试答题先亮金字塔,再谈具体技术。
单元测试
单元测试验证「最小可测单元」在给定输入下输出正确。Python 内置 unittest,了解它有助于读懂存量代码:
import unittest
def add(x, y):
return x + y
class TestAdd(unittest.TestCase):
def test_normal(self):
self.assertEqual(add(1, 2), 3)
def test_type_error(self):
with self.assertRaises(TypeError): # 断言抛出指定异常
add(1, "a")
unittest.main(argv=["x", "-v"], exit=False) # 脚本内手动运行
# test_normal ... ok / test_type_error ... ok / OK (ran 2 tests)面试要点:
- 一个测试只验证一个行为;命名要能当文档读(test_被测对象_场景_预期)
- AAA 结构:Arrange 准备数据、Act 调用被测代码、Assert 断言结果
- 单元测试必须不依赖网络/数据库/文件 —— 那是集成测试的事,隔离靠 mock
- 测试之间绝不共享可变状态,执行顺序无关(pytest 默认按代码顺序,别依赖它)
pytest 基础
pytest 用裸 assert + 命名约定取代类与断言方法,是当前事实标准:
import pathlib, textwrap, pytest
# pytest 的测试文件:test_ 前缀函数,裸 assert,失败自动显示两侧值
test_file = pathlib.Path("/tmp/test_demo.py")
test_file.write_text(textwrap.dedent('''
def add(x, y):
return x + y
def test_add_normal():
assert add(1, 2) == 3
def test_add_msg():
assert add(1, 1) == 2, "1+1 应该等于 2"
''').strip(), encoding="utf-8")
rc = pytest.main(["-q", str(test_file)])
print("pytest 退出码:", int(rc)) # 0 = 全部通过
# .. [100%] 2 passed / pytest 退出码: 0面试要点:
- 断言失败时 pytest 自动做 introspection,显示表达式中每个变量的值,不用手写提示
- 运行入口:
pytest(扫 test_*.py)、pytest tests/test_a.py::test_x(精确到函数)、pytest -k add(按名过滤) pytest.main可编程调用,CI/工具链集成常用;退出码 0 通过、1 有失败- fixture 与参数化(下一节)是 pytest 真正的杀手锏
pytest 进阶:fixture 与参数化
import pathlib, textwrap, pytest
test_file = pathlib.Path("/tmp/test_adv.py")
test_file.write_text(textwrap.dedent('''
import pytest
@pytest.fixture # 夹具:准备数据/环境,按名注入
def data():
return {"db": "connected"}
def test_with_fixture(data):
assert data["db"] == "connected"
@pytest.mark.parametrize("a,b,out", [ # 参数化:一次实现跑多组用例
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add(a, b, out):
assert a + b == out
''').strip(), encoding="utf-8")
rc = pytest.main(["-q", str(test_file)])
print("pytest 退出码:", int(rc)) # 4 passed面试要点:
- fixture 解决「多个测试要同一份前置数据」的复制粘贴问题;
yield写法支持清理(yield 后写收尾) - fixture 作用域:function(默认)/ class / module / session,session 级只建一次(如数据库连接池)
conftest.py放共享 fixture,同目录及子目录测试自动可见,无需 import- 参数化让「同一个逻辑 × 多组边界值」一次覆盖:空列表、0、负数、超长串都是必测边界
mock
mock 的本质:用可控替身替换真实依赖,让单元测试不碰网络、数据库、时间:
from unittest.mock import MagicMock, patch
import os
# MagicMock:替身对象,自动记录调用、方法可编排返回值
client = MagicMock()
client.get("/price/a").json.return_value = {"price": 100}
print(client.get("/price/a").json()) # {'price': 100}
print(client.get.call_count) # 1
client.get.assert_called_with("/price/a") # 断言调用方式,不抛错即通过
# patch.dict:临时替换环境变量,with 结束自动还原
def get_mode():
return os.environ.get("KB_MODE", "dev")
with patch.dict(os.environ, {"KB_MODE": "prod"}):
print(get_mode()) # prod
print(get_mode()) # dev —— 环境已还原面试要点:
- mock 什么:外部 IO(HTTP、DB)、不确定源(时间、随机数)、昂贵的依赖
patch("模块路径.名字")要替换「被使用处」而不是「定义处」,这是 patch 路径最常见的坑assert_called_with/call_count不仅能替身返回值,还能验证「交互行为」—— 这是行为驱动测试的关键- mock 过度的信号:mock 了被测函数内部所有东西,说明该函数该拆分了
集成测试
集成测试验证多个真实组件协作(路由 → 校验 → 业务 → 存储)。FastAPI 的 TestClient 是经典范式 —— 不起端口、不进网络栈,直接驱动应用:
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
DB = {"items": []} # 演示用内存存储(真实项目换测试库/临时文件)
@app.post("/items", status_code=201)
def create(body: dict):
DB["items"].append(body)
return body
@app.get("/items")
def listing():
return DB["items"]
# TestClient 直接驱动 ASGI 应用:真实走完整请求管线(路由/校验/序列化)
c = TestClient(app)
print(c.post("/items", json={"name": "服务器"}).status_code) # 201
print(c.get("/items").json()) # [{'name': '服务器'}]面试要点:
- 集成测试与单元测试的分界:是否有真实外部组件协作;TestClient 模拟 HTTP 语义但仍在进程内
- 测试数据要隔离:每个测试用独立 DB/事务回滚/临时目录,测试完不污染下个测试
- 外部依赖用「测试替身服务」或 mock server,而不是打真实第三方接口(慢、贵、不稳定)
- 验证的不只是返回值:状态码、响应结构、副作用(DB 落库)、日志都要覆盖
覆盖率与工程化
覆盖率衡量「测试执行到的代码比例」,是门槛指标不是目标指标:
# 安装 pip install pytest pytest-cov # 跑测试 + 覆盖率报告(缺哪些行一目了然) pytest --cov=myapp --cov-report=term-missing -q # CI 里的阈值门槛:低于 80% 直接失败 pytest --cov=myapp --cov-fail-under=80 -q
工程化检查单:
- 测试目录 tests/ 与源码分离,conftest.py 管共享夹具,CI 每次提交全量跑
- 提交前本地跑:
pytest -q快速反馈;合并前 CI 跑全量 + 覆盖率门槛 - 变异测试(mutation testing)可检验「覆盖率是真是假」:改一行代码看测试会不会挂
面试要点:
- 100% 覆盖率不等于没有 bug:覆盖率只说明「执行过」,不说明「断言过」
- 覆盖率的价值在于发现盲区(没被任何测试碰到的分支),而非凑数字
- 面试答法:「我以覆盖率为健康度参考,重点保证核心业务路径的分支全覆盖 + CI 门槛」
高频面试题速答
| 问题 | 一句话答案 |
|---|---|
| 单元测试与集成测试区别 | 单元隔离单个函数/类(依赖全 mock),集成验证多组件真实协作 |
| mock 是什么,为什么需要 | 用可控替身替换真实依赖,让测试不碰网络/DB/时间,快且确定 |
| fixture 的作用 | 集中管理测试前置与清理,按名注入复用,scope 控制创建次数 |
| 参数化的价值 | 一份测试逻辑跑多组输入,边界值覆盖成本降到最低 |
| patch 路径怎么写 | 替换「被使用处」的引用(where it is used),不是定义处 |
| 覆盖率 100% 就没 bug 吗 | 不,覆盖率只证明执行过,不证明断言正确,需配合断言审查与变异测试 |
| 测试金字塔是什么 | 单元测试多而快在底层,集成居中,端到端少而慢在顶层,比例约 70/20/10 |
| 怎么测一个依赖当前时间的函数 | 把时间注入(参数或 fixture)或 mock 时间源,绝不 sleep 等真实时间 |