文章目录
- Python 测试用例设计指南:从入门到最佳实践
- 一、为什么需要写测试?
- 二、Python 测试工具概览
- 三、第一个测试用例
- 3.1 被测函数
- 3.2 使用 pytest 编写测试
- 3.3 使用 unittest 编写测试
- 四、测试用例设计的核心原则
- 4.1 FIRST 原则
- 4.2 AAA 模式
- 4.3 测试命名规范
- 五、测试用例设计方法
- 5.1 等价类划分
- 5.2 边界值分析
- 5.3 状态转换测试
- 六、进阶技巧
- 6.1 参数化测试(Parametrize)
- 6.2 Fixture(测试夹具)
- 6.3 Mock 与 Patch
- 代码解释
- 1. 核心机制:Mock(模拟/打桩)
- 2. 预设返回值
- 3. 验证行为而非结果
- 为什么要这样做?
- ⚠️ 注意事项
- 6.4 测试覆盖率
- 七、项目测试结构
- 八、常见反模式(要避免的做法)
- 九、总结
Python 测试用例设计指南:从入门到最佳实践
“任何未经测试的代码,都是不可靠的代码。”
在软件开发中,测试是保障代码质量的关键环节。Python 作为一门简洁而强大的语言,拥有丰富的测试生态。本文将从零开始,带你掌握 Python 测试用例的设计方法与最佳实践。
一、为什么需要写测试?
很多开发者觉得写测试"浪费时间",但事实上,测试能帮你:
- 尽早发现 Bug:在代码上线前捕获问题,修复成本远低于线上排错。
- 重构的信心:有了测试的保护伞,你可以大胆重构而不必担心引入新问题。
- 活的文档:好的测试用例本身就是代码行为的最佳说明。
- 团队协作:测试让新成员更快理解模块的预期行为。
二、Python 测试工具概览
| 工具 | 特点 |
|---|---|
unittest | Python 标准库,开箱即用,xUnit 风格 |
pytest | 第三方框架,语法简洁,插件生态丰富,推荐首选 |
doctest | 基于文档字符串的测试,适合简单示例 |
nose2 | unittest 的扩展,社区活跃度下降 |
本文以pytest为主进行讲解,同时也会涉及unittest的基本用法。
三、第一个测试用例
3.1 被测函数
# calculator.pydefadd(a:float,b:float)->float:returna+bdefdivide(a:float,b:float)->float:ifb==0:raiseValueError("除数不能为零")returna/b3.2 使用 pytest 编写测试
# test_calculator.pyimportpytestfromcalculatorimportadd,dividedeftest_add_positive_numbers():assertadd(2,3)==5deftest_add_negative_numbers():assertadd(-1,-1)==-2deftest_add_mixed_numbers():assertadd(-1,1)==0deftest_divide_normal():assertdivide(10,2)==5.0deftest_divide_by_zero():withpytest.raises(ValueError,match="除数不能为零"):divide(10,0)运行测试:
$ pytest test_calculator.py-v3.3 使用 unittest 编写测试
# test_calculator_unittest.pyimportunittestfromcalculatorimportadd,divideclassTestCalculator(unittest.TestCase):deftest_add_positive_numbers(self):self.assertEqual(add(2,3),5)deftest_divide_by_zero(self):withself.assertRaises(ValueError):divide(10,0)if__name__=="__main__":unittest.main()四、测试用例设计的核心原则
4.1 FIRST 原则
| 原则 | 说明 |
|---|---|
| Fast(快速) | 测试必须运行得快,否则你会越来越不愿意运行它 |
| Isolated(隔离) | 每个测试应独立运行,不依赖其他测试的执行顺序或结果 |
| Repeatable(可重复) | 在任何环境下运行,结果都应一致 |
| Self-Validating(自验证) | 测试结果应由断言自动判断,不需要人工检查 |
| Timely(及时) | 测试应在编写代码的同时(或之前)编写 |
4.2 AAA 模式
每个测试用例应遵循Arrange-Act-Assert结构:
deftest_shopping_cart_total():# Arrange(准备)cart=ShoppingCart()cart.add_item("Python书",price=59.0)cart.add_item("键盘",price=199.0)# Act(执行)total=cart.get_total()# Assert(断言)asserttotal==258.04.3 测试命名规范
好的测试名称应该清晰表达测试意图:
# ❌ 不好的命名deftest_1():...deftest_divide():...# ✅ 好的命名deftest_divide_returns_correct_quotient():...deftest_divide_raises_error_when_divisor_is_zero():...五、测试用例设计方法
5.1 等价类划分
将输入数据划分为若干"等价类",每个类中取一个代表值进行测试:
# 判断年龄阶段defclassify_age(age:int)->str:ifage<0:raiseValueError("年龄不能为负数")elifage<18:return"未成年"elifage<60:return"成年人"else:return"老年人"等价类划分:
| 等价类 | 代表值 | 预期结果 |
|---|---|---|
| 负数(无效) | -1 | ValueError |
| 0~17(未成年) | 10 | “未成年” |
| 18~59(成年人) | 30 | “成年人” |
| 60+(老年人) | 70 | “老年人” |
| 边界值 0 | 0 | “未成年” |
| 边界值 18 | 18 | “成年人” |
| 边界值 60 | 60 | “老年人” |
5.2 边界值分析
Bug 往往隐藏在边界条件中,重点测试边界值:
@pytest.mark.parametrize("age, expected",[(-1,"error"),# 无效下界(0,"未成年"),# 有效下界(17,"未成年"),# 区间上界(18,"成年人"),# 新区间下界(59,"成年人"),# 区间上界(60,"老年人"),# 新区间下界(150,"老年人"),# 极端值])deftest_classify_age(age,expected):ifexpected=="error":withpytest.raises(ValueError):classify_age(age)else:assertclassify_age(age)==expected5.3 状态转换测试
对于有状态的对象,测试不同状态之间的转换:
classOrder:def__init__(self):self.status="created"defpay(self):ifself.status!="created":raiseRuntimeError("只有已创建的订单可以支付")self.status="paid"defship(self):ifself.status!="paid":raiseRuntimeError("只有已支付的订单可以发货")self.status="shipped"defcancel(self):ifself.status=="shipped":raiseRuntimeError("已发货的订单不能取消")self.status="cancelled"deftest_order_lifecycle_happy_path():"""正常流程:创建 → 支付 → 发货"""order=Order()order.pay()assertorder.status=="paid"order.ship()assertorder.status=="shipped"deftest_order_cannot_ship_before_payment():"""未支付不能发货"""order=Order()withpytest.raises(RuntimeError):order.ship()deftest_order_cannot_cancel_after_shipping():"""已发货不能取消"""order=Order()order.pay()order.ship()withpytest.raises(RuntimeError):order.cancel()六、进阶技巧
6.1 参数化测试(Parametrize)
避免重复代码,用一组数据驱动多个测试:
@pytest.mark.parametrize("input_str, expected",[("hello","HELLO"),("World","WORLD"),("123abc","123ABC"),("",""),])deftest_to_uppercase(input_str,expected):assertinput_str.upper()==expected6.2 Fixture(测试夹具)
用 Fixture 管理测试的前置条件和资源清理:
importpytestimporttempfileimportos@pytest.fixturedeftemp_file():"""创建一个临时文件,测试结束后自动清理"""fd,path=tempfile.mkstemp()withopen(path,"w")asf:f.write("hello world")yieldpath# 将路径传递给测试函数# 清理os.close(fd)os.unlink(path)deftest_read_file(temp_file):withopen(temp_file)asf:content=f.read()assertcontent=="hello world"6.3 Mock 与 Patch
当测试涉及外部依赖(数据库、API、文件系统等)时,使用 Mock 隔离:
fromunittest.mockimportpatch,MagicMockfrommy_serviceimportUserServicedeftest_get_user_from_api():withpatch("my_service.requests.get")asmock_get:# 模拟 API 返回mock_response=MagicMock()mock_response.json.return_value={"name":"Alice","age":30}mock_response.status_code=200mock_get.return_value=mock_response# 执行测试service=UserService()user=service.get_user(1)# 断言assertuser["name"]=="Alice"mock_get.assert_called_once_with("https://api.example.com/users/1")代码解释
这段代码没有发送真正的网络请求。
这是 Python 单元测试中unittest.mock库的核心用法,具体解释如下:
1. 核心机制:Mock(模拟/打桩)
withpatch("my_service.requests.get")asmock_get:这行代码做了两件事:
- 拦截:在
with块的作用域内,将my_service模块中引用的requests.get临时替换为一个MagicMock对象。 - 隔离:当
UserService.get_user()内部调用requests.get(...)时,它调用的其实是这个 Mock 对象,而不是真正的 HTTP 客户端。with块结束后,原始的requests.get会自动恢复。
2. 预设返回值
mock_response.json.return_value={"name":"Alice","age":30}mock_response.status_code=200mock_get.return_value=mock_response这里手动构造了一个"假响应",告诉 Mock 对象:
“当别人调用你时,返回这个我预先设定好的 response 对象”
所以service.get_user(1)拿到的数据完全是你编造的,不依赖任何外部服务。
3. 验证行为而非结果
mock_get.assert_called_once_with("https://api.example.com/users/1")这行断言验证的是:你的业务代码是否以正确的参数调用了 API。即使没有真实请求,也能确认 URL 拼接逻辑是否正确。
为什么要这样做?
| 真实请求的问题 | Mock 的优势 |
|---|---|
| 依赖外部服务可用性 | ✅ 完全离线运行 |
| 网络延迟导致测试慢 | ✅ 毫秒级执行 |
| API 返回数据可能变化 | ✅ 结果确定、可重复 |
| 难以覆盖异常/边界场景 | ✅ 可轻松模拟 404、超时等 |
| 可能产生真实副作用 | ✅ 零副作用 |
⚠️ 注意事项
- Patch 路径必须是"被测模块中的引用路径",即
"my_service.requests.get"而非"requests.get"。如果写错路径,Mock 不会生效,可能会意外发出真实请求。 - Mock 只验证了集成点的调用方式,不能替代对真实 API 的集成测试/契约测试。建议单元测试用 Mock,另设少量集成测试验证真实连通性。
总结:这段代码是一个标准的纯单元测试,通过 Mock 将外部依赖替换为可控的假对象,从而快速、可靠地验证UserService自身的业务逻辑。
6.4 测试覆盖率
使用pytest-cov插件检查测试覆盖率:
$ pytest--cov=my_module --cov-report=term-missing---------- coverage: platform linux, python 3.11 ---------- Name Stmts Miss Cover Missing ---------------------------------------------------- calculator.py 8 1 88% 7 test_calculator.py 12 0 100% ---------------------------------------------------- TOTAL 20 1 95%💡建议:不要盲目追求 100% 覆盖率,而应关注关键路径和边界条件是否被充分测试。一般来说,80% 以上的覆盖率是一个合理的目标。
七、项目测试结构
推荐的项目目录结构:
my_project/ ├── src/ │ └── my_module/ │ ├── __init__.py │ ├── core.py │ └── utils.py ├── tests/ │ ├── conftest.py # 共享的 Fixture │ ├── test_core.py │ └── test_utils.py ├── pyproject.toml # 配置 pytest └── README.md在pyproject.toml中配置 pytest:
[tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --tb=short"八、常见反模式(要避免的做法)
| ❌ 反模式 | 说明 | ✅ 正确做法 |
|---|---|---|
| 测试之间共享状态 | 导致测试相互依赖,顺序敏感 | 每个测试独立准备数据 |
| 测试中包含硬编码路径 | 在不同环境无法运行 | 使用 Fixture 或临时文件 |
| 过度使用 Mock | 测试脱离了真实行为 | 只在必要时 Mock 外部依赖 |
| 一个测试测太多东西 | 失败时难以定位问题 | 每个测试只验证一个行为 |
| 只测 Happy Path | 忽略了异常和边界情况 | 同时覆盖正常和异常路径 |
九、总结
设计优秀的 Python 测试用例,核心在于:
- 选对工具:推荐 pytest,简洁高效。
- 遵循原则:FIRST 原则 + AAA 模式。
- 运用方法:等价类划分、边界值分析、状态转换。
- 善用技巧:参数化、Fixture、Mock、覆盖率分析。
- 避开陷阱:保持测试独立、简洁、可维护。
记住,测试不是负担,而是你对代码质量的投资。今天多花 10 分钟写测试,明天就少花 2 小时排查 Bug。
Happy Testing! 🧪✨