26、面向对象测试:单元测试基础(unittest/pytest),Mock对象,测试驱动开发(TDD)入门
说实话,很多写面向对象代码的同学,一听到「测试」两个字就头大。我当年也是这样。总觉得写代码已经够累了,还要写测试?这不是给自己找事吗?直到有一次,我改了一个看似无关紧要的父类方法,结果导致三个子模块同时崩溃……那天晚上我加班到凌晨两点。嗯,从那以后,我再也不敢不写单元测试了。
今天我们就来聊聊面向对象中的测试。说白了,测试就是给你的代码买一份保险。你想想看,买保险的时候觉得浪费钱,出事的时候才知道真香。测试也是一样的道理。
单元测试到底测什么?
单元测试,测的是「单元」。在面向对象里,这个单元通常是一个类,或者一个方法。我个人习惯,一个类对应一个测试类,一个公开方法对应一个测试方法。
举个例子,假设你有一个 Calculator 类:
class Calculator:
def add(self, a, b):
return a + b
def divide(self, a, b):
if b == 0:
raise ValueError("除数不能为0")
return a / b
单元测试要测什么?不是测你代码能不能跑,而是测它的行为是否符合预期。比如:
add(1, 2)是不是返回 3?divide(10, 2)是不是返回 5?divide(1, 0)是不是抛出了异常?
这些看起来很简单对吧?但就是这些简单的东西,能帮你拦住 90% 的回归 bug。
unittest vs pytest:我该选哪个?
Python 里有两个主流的测试框架:unittest 和 pytest。我两个都用过,说说我的感受。
| 特性 | unittest | pytest |
|---|---|---|
| 风格 | Java 风格,类 + 方法 | 函数式,更 Pythonic |
| 断言 | self.assertEqual() |
assert 原生语句 |
| fixture | setUp() / tearDown() |
@pytest.fixture |
| 参数化 | 需要 subTest 或第三方库 |
内置 @pytest.mark.parametrize |
| 插件生态 | 一般 | 非常丰富 |
我个人更推荐 pytest。为什么?因为它的写法更简洁,出错信息也更友好。你看下面这个对比就明白了:
# unittest 写法
import unittest
class TestCalc(unittest.TestCase):
def test_add(self):
self.assertEqual(Calculator().add(1, 2), 3)
# pytest 写法
def test_add():
assert Calculator().add(1, 2) == 3
看到了吗?pytest 直接用 assert,少写一堆样板代码。我在项目中带新人时,pytest 的上手速度明显比 unittest 快。
Mock对象:让测试不再依赖外部
写面向对象测试时,最头疼的就是「依赖」。比如你的类里调用了数据库、调用了第三方 API、或者调用了另一个复杂的服务。这时候怎么办?
Mock 对象就是干这个的。它模拟一个假的对象,让你控制它的行为。
我曾经遇到过一个场景:一个订单服务类,内部调用了支付网关。每次跑测试都要真的扣钱……这谁受得了?后来我用 Mock 把支付网关替换掉,测试瞬间变快,而且再也不用担心测试环境把生产环境的钱扣了。
看个例子:
from unittest.mock import Mock
# 假设这是你的支付网关
class PaymentGateway:
def charge(self, amount):
# 真的去扣钱
pass
class OrderService:
def __init__(self, gateway):
self.gateway = gateway
def process_order(self, amount):
result = self.gateway.charge(amount)
if result['status'] == 'success':
return "订单成功"
return "订单失败"
# 测试时,用 Mock 替换真实的网关
def test_process_order_success():
mock_gateway = Mock()
mock_gateway.charge.return_value = {'status': 'success'}
service = OrderService(mock_gateway)
result = service.process_order(100)
assert result == "订单成功"
mock_gateway.charge.assert_called_once_with(100)
这里的关键点:
Mock()创建了一个假对象return_value指定了调用方法时的返回值assert_called_once_with验证了方法是否被正确调用
测试驱动开发(TDD)入门
TDD,全称 Test-Driven Development。说白了就是「先写测试,再写代码」。流程很简单:
- 红:写一个会失败的测试
- 绿:写最少的代码让测试通过
- 重构:优化代码,保持测试通过
我第一次接触 TDD 时觉得这简直是反人类。代码都没写,我怎么知道要测什么?但后来我硬着头皮试了一个月,发现真香。为什么?
因为 TDD 逼着你在写代码之前先想清楚「这个类到底要干什么」。你想想看,如果你连测试都写不出来,说明你根本就没想清楚需求。
来看一个 TDD 的实战例子。假设我们要开发一个 UserValidator 类,用来验证用户信息。
第一步:写一个失败的测试
def test_valid_user():
validator = UserValidator()
result = validator.validate("alice", "alice@example.com")
assert result is True
这时候 UserValidator 还不存在,测试肯定失败。好,红牌。
第二步:写最少的代码让测试通过
class UserValidator:
def validate(self, name, email):
return True # 先硬编码通过
测试通过了。但显然这个实现是假的。没关系,TDD 允许你「作弊」,只要测试通过就行。
第三步:写第二个测试,让代码变真实
def test_invalid_email():
validator = UserValidator()
result = validator.validate("alice", "not-an-email")
assert result is False
这个测试会失败,因为我们的 validate 永远返回 True。好,现在我们需要写真正的逻辑了:
class UserValidator:
def validate(self, name, email):
if not name or not email:
return False
if "@" not in email:
return False
return True
两个测试都通过了。你看,代码是「被测试逼出来的」,而不是你凭空想出来的。
面向对象测试的常见陷阱
- 测试内部实现细节:不要测试私有方法,只测公开接口。否则改一次实现,测试就要全改。
- 测试太脆弱:比如断言了某个方法的调用次数,其实这个次数根本不重要。我见过有人因为加了一行日志,测试就挂了……
- 忽略边界条件:空值、负数、超大值、并发调用。这些才是 bug 的高发区。
本章知识体系
下面这张图总结了本章的核心逻辑,从测试框架到 Mock 再到 TDD,是一条完整的链路:
好了,关于面向对象测试的基础就聊到这里。记住:测试不是写代码的敌人,而是你的盟友。刚开始可能会觉得慢,但坚持一个月,你会发现代码质量明显提升,改代码时也更有底气了。