一、unittest概述
unittest是Python标准库中内置的单元测试框架,灵感来源于Java的JUnit,属于经典的xUnit体系结构。它提供了一套完整的测试工具:测试用例组织、测试执行调度、断言方法、测试夹具(fixture)以及测试运行器,使开发者能够系统化地进行自动化测试。
单元测试(Unit Testing)是针对程序中最小的可测试单元(通常是函数或方法)进行正确性验证的实践。其核心理念是:将代码分解为独立、可重复的测试用例,每次运行都能可靠地给出通过或失败的结果。TDD(Test-Driven Development,测试驱动开发)更是将单元测试作为开发流程的核心环节:先编写测试用例,再编写实现代码使测试通过,最后重构优化——即"红-绿-重构"循环。
xUnit体系是一套通用的单元测试框架架构模式,最早起源于Smalltalk语言的SUnit,后由Kent Beck和Erich Gamma将其移植到Java成为JUnit。该体系的核心概念包括:TestCase(测试用例)、TestSuite(测试套件)、TestRunner(测试运行器)、TestFixture(测试夹具)和Assertions(断言)。Python的unittest完全遵循这一体系,使得熟悉其他xUnit框架的开发者能快速上手。
在Python生态中,unittest是第一方测试框架,无需额外安装。它适合从简单函数测试到大型项目测试套件的各种场景。此外,第三方框架如pytest也兼容unittest的TestCase类,使得基于unittest编写的测试用例可以无缝迁移到更高级的测试平台。
核心理念:单元测试的目标不是证明代码没有Bug,而是帮助开发者建立对代码行为的信心,并在重构时快速捕获回归错误。"测试不是质量保证的手段,而是设计过程的反馈。"
二、TestCase编写
TestCase是unittest框架中最核心的类,所有测试用例都需要继承它。编写测试用例时,需要定义一个继承自unittest.TestCase的子类,并在其中编写以 test_ 开头的方法——这些方法会被测试运行器自动识别和执行。
2.1 基本结构
一个标准的TestCase子类包含多个测试方法,每个方法测试一个独立的行为或功能点。测试方法内部通过调用self.assert*系列断言方法来验证实际输出与期望值是否一致。
import unittest
def add(a, b):
return a + b
class TestMathOperations(unittest.TestCase):
def test_add_positive(self):
result = add(3, 5)
self.assertEqual(result, 8)
def test_add_negative(self):
result = add(-2, -3)
self.assertEqual(result, -5)
def test_add_zero(self):
result = add(0, 0)
self.assertEqual(result, 0)
if __name__ == '__main__':
unittest.main()
运行上述脚本时,unittest.main() 会自动查找当前模块中所有继承TestCase的类,收集以 test_ 开头的方法作为测试用例并依次执行。每个测试方法独立运行,互不干扰——一个测试失败不会影响其他测试的执行。
2.2 断言方法全家桶
unittest提供了一套丰富的断言方法,覆盖了大多数测试场景。与Python原生的 assert 语句相比,unittest的断言方法在测试失败时会生成详细的错误信息,明确展示期望值和实际值,极大方便了问题定位。
# 基本值断言
self.assertEqual(a, b) # a == b
self.assertNotEqual(a, b) # a != b
self.assertTrue(x) # bool(x) is True
self.assertFalse(x) # bool(x) is False
self.assertIs(a, b) # a is b
self.assertIsNot(a, b) # a is not b
self.assertIsNone(x) # x is None
self.assertIsNotNone(x) # x is not None
self.assertIn(a, b) # a in b
self.assertNotIn(a, b) # a not in b
# 比较断言
self.assertGreater(a, b) # a > b
self.assertGreaterEqual(a,b) # a >= b
self.assertLess(a, b) # a < b
self.assertLessEqual(a, b) # a <= b
self.assertAlmostEqual(a,b) # 浮点数近似相等
self.assertNotAlmostEqual(a,b)# 浮点数不近似相等
# 集合断言
self.assertListEqual(a, b) # 列表相等
self.assertTupleEqual(a, b) # 元组相等
self.assertSetEqual(a, b) # 集合相等
self.assertDictEqual(a, b) # 字典相等
self.assertSequenceEqual(a,b)# 序列相等
# 异常断言
self.assertRaises(ValueError, func, arg)
self.assertRaisesRegex(ValueError, r'pattern', func, arg)
# 警告断言
self.assertWarns(UserWarning, func, arg)
self.assertWarnsRegex(UserWarning, r'pattern', func, arg)
# 日志断言
with self.assertLogs('foo', level='INFO') as log:
foo()
self.assertIn('bar', log.output[0])
assertRaises是异常测试的核心工具,它有多种使用方式。最灵活的是上下文管理器形式,可以在with块中编写触发异常的代码:
# 上下文管理器形式(推荐)
def divide(a, b):
if b == 0:
raise ValueError("除数不能为零")
return a / b
class TestDivide(unittest.TestCase):
def test_divide_by_zero(self):
with self.assertRaises(ValueError) as cm:
divide(10, 0)
self.assertEqual(str(cm.exception), "除数不能为零")
# 装饰器形式
def test_divide_by_zero_deco(self):
self.assertRaises(ValueError, divide, 10, 0)
编写测试方法时,建议遵循以下命名规范:test_后跟被测试的功能名称和测试场景描述,如 test_addition_with_positive_numbers、test_login_with_invalid_password。清晰的方法名本身就是测试文档,能快速告知读者该测试覆盖的场景。
三、测试夹具(Test Fixture)
测试夹具(Fixture)是指在执行测试前后需要完成的准备和清理工作,包括创建测试数据、初始化资源、建立数据库连接、清理临时文件等。unittest提供了三组夹具方法,分别作用于不同粒度:测试方法级别、类级别和模块级别。
3.1 方法级别:setUp / tearDown
setUp方法在每个测试方法执行前被调用,tearDown方法在每个测试方法执行后被调用。无论测试是否通过,tearDown都会被执行,确保清理工作不会因测试失败而跳过。
class TestDatabase(unittest.TestCase):
def setUp(self):
"""每个测试前执行:创建数据库连接和初始数据"""
self.conn = create_database_connection()
self.conn.execute("INSERT INTO users (name) VALUES ('Alice')")
self.conn.execute("INSERT INTO users (name) VALUES ('Bob')")
def tearDown(self):
"""每个测试后执行:清理数据并关闭连接"""
self.conn.execute("DELETE FROM users")
self.conn.close()
def test_count_users(self):
count = self.conn.query("SELECT COUNT(*) FROM users")
self.assertEqual(count, 2)
def test_insert_user(self):
self.conn.execute("INSERT INTO users (name) VALUES ('Charlie')")
count = self.conn.query("SELECT COUNT(*) FROM users")
self.assertEqual(count, 3)
3.2 类级别:setUpClass / tearDownClass
setUpClass在整个测试类中的所有测试方法执行之前运行一次,tearDownClass在所有测试方法执行完毕后运行一次。它们被定义为类方法(@classmethod),适用于一次性的昂贵操作,如建立数据库连接池、启动外部服务等。
class TestExpensiveOperation(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""整个测试类运行前执行一次"""
cls.client = create_api_client()
cls.client.login("test_user", "test_password")
@classmethod
def tearDownClass(cls):
"""整个测试类运行后执行一次"""
cls.client.logout()
cls.client.close()
def test_get_user_profile(self):
profile = self.client.get("/api/profile")
self.assertIn("username", profile)
def test_update_settings(self):
result = self.client.post("/api/settings", {"theme": "dark"})
self.assertEqual(result.status_code, 200)
3.3 模块级别:setUpModule / tearDownModule
setUpModule和tearDownModule是在模块级别定义的普通函数,分别在模块中所有测试类执行之前和之后执行一次。适用于全局级别的设置,如环境变量配置、日志级别设置、外部资源分配等。
import unittest
import os
def setUpModule():
"""模块中所有测试执行前运行一次"""
os.environ['TEST_MODE'] = 'true'
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
def tearDownModule():
"""模块中所有测试执行后运行一次"""
os.environ.pop('TEST_MODE', None)
os.environ.pop('DATABASE_URL', None)
class TestModuleA(unittest.TestCase):
def test_something(self):
self.assertEqual(os.environ['TEST_MODE'], 'true')
class TestModuleB(unittest.TestCase):
def test_another(self):
self.assertTrue('DATABASE_URL' in os.environ)
3.4 执行顺序与异常处理
夹具方法的完整执行顺序为:setUpModule → setUpClass → setUp → test_method → tearDown → tearDownClass → tearDownModule。当setUp方法抛出异常时,对应的测试方法和tearDown都不会执行;但当tearDown抛出异常时,会作为测试错误(而非失败)被记录。setUpClass和setUpModule中的异常会导致所属范围内的所有测试被跳过。
四、TestSuite与TestRunner
虽然unittest.main()可以自动发现和执行测试,但在大型项目中,我们通常需要更精细地控制测试的组织和执行方式。TestSuite(测试套件)允许将多个测试用例或测试类组合在一起,而TestRunner(测试运行器)则负责执行这些测试并输出结果。
4.1 使用TestSuite组合测试
TestSuite是一个容器,可以添加单个测试方法、整个TestCase类或其他TestSuite实例。通过组合,可以构建出层次化的测试集合,按功能模块、优先级或执行速度分组。
import unittest
# 创建测试套件
suite = unittest.TestSuite()
# 添加单个测试方法
suite.addTest(TestMathOperations('test_add_positive'))
# 添加整个测试类
suite.addTest(unittest.makeSuite(TestMathOperations))
# 使用TestLoader加载
loader = unittest.TestLoader()
suite.addTest(loader.loadTestsFromTestCase(TestMathOperations))
suite.addTest(loader.loadTestsFromModule(test_module))
suite.addTest(loader.discover('tests'))
4.2 TestLoader加载策略
TestLoader提供了多种加载策略,可以从不同来源收集测试用例:loadTestsFromTestCase从TestCase子类加载、loadTestsFromModule从模块加载、loadTestsFromName从点分隔名称加载。TestLoader还支持按名称模式筛选测试,通过设置testMethodPrefix(默认'test')和sortTestMethodsUsing自定义排序函数,实现灵活的选择性测试。
loader = unittest.TestLoader()
loader.testMethodPrefix = 'should_' # 匹配以should_开头的方法
loader.sortTestMethodsUsing = None # 禁止排序,保持定义顺序
# 从点分隔名称加载
suite = loader.loadTestsFromName(
'tests.test_math.TestMathOperations.test_add_positive'
)
# 批量加载多个模块
test_modules = ['tests.test_a', 'tests.test_b', 'tests.test_c']
suites = [loader.loadTestsFromName(m) for m in test_modules]
combined_suite = unittest.TestSuite(suites)
4.3 TextTestRunner与自定义运行器
TextTestRunner是unittest默认的测试运行器,它将测试结果以文本形式输出到控制台。通过配置verbosity参数可以控制输出详细程度:0表示不输出,1表示简洁输出(默认),2表示详细输出(显示每个测试方法名)。
# 基本用法
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
# 自定义输出流
import sys
runner = unittest.TextTestRunner(
stream=sys.stderr,
verbosity=2,
failfast=True, # 遇到第一个失败即停止
buffer=True, # 缓冲测试输出(stdout/stderr)
tb_locals=True # 在回溯中显示局部变量(Python 3.12+)
)
# 分析测试结果
print(f"运行测试数: {result.testsRun}")
print(f"失败数: {len(result.failures)}")
print(f"错误数: {len(result.errors)}")
print(f"跳过数: {len(result.skipped)}")
print(f"期望失败数: {len(result.expectedFailures)}")
# 查看失败详情
for test_case, traceback in result.failures:
print(f"FAIL: {test_case}")
print(traceback)
TestResult对象包含了测试执行的完整状态,除了failures和errors外,还包括successful()方法判断是否有失败,以及wasSuccessful()检查整体结果。自定义TestRunner可以继承TextTestRunner并重写相关方法,实现定制化的报告输出,如生成XML报告、HTML报告或集成到CI/CD流水线。
五、测试发现(Test Discovery)
随着项目规模增长,手动维护TestSuite变得繁琐。unittest提供了自动测试发现机制——test discovery,它可以递归扫描指定目录,自动查找并加载所有匹配的测试模块,无需显式列出每个测试类。
5.1 程序化发现:discover方法
TestLoader.discover方法从给定目录开始,递归扫描所有匹配模式的文件,自动加载其中的测试用例。默认模式为 test*.py,即匹配所有以 test 开头的Python文件。
loader = unittest.TestLoader()
# 基本发现:扫描当前目录下的tests目录
suite = loader.discover('tests')
# 自定义模式:匹配以_unittest结尾的文件
suite = loader.discover('tests', pattern='*_unittest.py')
# 指定顶级目录(用于正确处理包导入)
suite = loader.discover(
start_dir='tests',
pattern='test*.py',
top_level_dir='.'
)
# 运行发现的测试
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)
5.2 命令行运行
unittest支持通过python -m unittest命令直接从命令行发现和运行测试,这是最便捷的使用方式。该命令会自动扫描当前目录下的测试文件并执行。
# 发现并运行当前目录下的所有测试
python -m unittest
# 详细输出模式
python -m unittest -v
# 运行指定模块
python -m unittest tests.test_math
# 运行指定测试类
python -m unittest tests.test_math.TestMathOperations
# 运行指定测试方法
python -m unittest tests.test_math.TestMathOperations.test_add
# 使用模式发现
python -m unittest discover -s tests -p '*_test.py' -t .
# 失败即停止
python -m unittest -f
# 更多命令行选项
python -m unittest -h
常用命令行选项包括:-v(详细输出)、-q(静默输出)、-f(failfast,首次失败即停止)、-c(catch,捕获Ctrl+C并显示已运行测试数)、-b(buffer,缓冲测试输出)。这些选项可以组合使用,例如 python -m unittest -vfb 在CI环境中最常见——详细输出、快速失败且不混淆测试输出。
5.3 目录结构最佳实践
合理的测试目录结构是测试可维护性的基础。推荐将测试代码放在与源代码平行的tests目录中,每个测试文件对应一个源模块,命名格式为 test_<模块名>.py。
project/
├── myapp/
│ ├── __init__.py
│ ├── math_utils.py
│ ├── user_auth.py
│ └── data_processor.py
├── tests/
│ ├── __init__.py
│ ├── test_math_utils.py
│ ├── test_user_auth.py
│ └── test_data_processor.py
└── run_tests.py
这种结构的优势在于:测试与源码一一对应,易于导航;测试发现无需额外配置;可以独立运行单个模块的测试,也可以并行运行全部测试。
六、参数化测试
当需要针对同一功能测试多组不同的输入数据时,如果为每组数据编写一个独立的测试方法,会导致大量重复代码。参数化测试允许使用不同的参数多次执行同一个测试方法,显著减少代码冗余。
6.1 subTest上下文管理器
unittest内置的subTest上下文管理器是最轻量级的参数化方案。在一个测试方法中使用for循环遍历参数列表,并在循环体内使用with self.subTest()包裹验证逻辑。当某个参数组合导致断言失败时,它会报告具体的参数值,并继续执行后续参数,而非中断整个测试方法。
class TestSquare(unittest.TestCase):
def test_square_values(self):
test_cases = [
(0, 0),
(1, 1),
(2, 4),
(3, 9),
(10, 100),
(-1, 1),
(-5, 25),
]
for input_val, expected in test_cases:
with self.subTest(input=input_val, expected=expected):
result = square(input_val)
self.assertEqual(result, expected)
def test_square_errors(self):
invalid_inputs = [None, "string", [1, 2], {"key": "val"}]
for inp in invalid_inputs:
with self.subTest(invalid_input=inp):
with self.assertRaises(TypeError):
square(inp)
当使用subTest时,如果某个子测试失败,unittest会输出类似以下的错误信息,精确标识失败的具体参数:
======================================================================
FAIL: test_square_values (__main__.TestSquare) (input=3, expected=9)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AssertionError: 9 != 10
6.2 ddt库(Data-Driven Tests)
ddt是第三方库(data-driven-tests),通过装饰器的方式实现参数化测试,语法更加简洁优雅。需要先安装:pip install ddt。
from ddt import ddt, data, unpack
import unittest
@ddt
class TestMultiply(unittest.TestCase):
@data((2, 3, 6), (0, 5, 0), (-1, 5, -5), (4, 0.5, 2.0))
@unpack
def test_multiply(self, a, b, expected):
result = multiply(a, b)
self.assertEqual(result, expected)
@data(
(2, 3, 6),
(0, 5, 0),
(-1, 5, -5),
(4, 0.5, 2.0),
)
def test_multiply_tuple(self, case):
a, b, expected = case
result = multiply(a, b)
self.assertEqual(result, expected)
@data(
{"a": 2, "b": 3, "expected": 6},
{"a": 0, "b": 5, "expected": 0},
{"a": -1, "b": 5, "expected": -5},
)
def test_multiply_dict(self, case):
result = multiply(case["a"], case["b"])
self.assertEqual(result, case["expected"])
@data装饰器接受一个可迭代对象(列表、元组等),为每组参数生成一个独立的测试方法。@unpack装饰器自动将元组或列表解包为方法参数。ddt生成的测试方法在测试报告中以 test_multiply_1、test_multiply_2 等形式显示,指示参数编号。
6.3 高级参数化模式
除了基本的数据驱动,还可以结合文件读取实现从外部数据源加载测试参数,适用于需要大量测试数据的场景。
import json
from ddt import ddt, data
def load_test_data_from_json(file_path):
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
@ddt
class TestFromFile(unittest.TestCase):
@data(*load_test_data_from_json('test_cases.json'))
def test_from_json(self, case):
result = my_function(case['input'])
self.assertEqual(result, case['expected'])
测试数据文件 test_cases.json 的格式为JSON数组,每个元素是一个包含input和expected字段的对象。这种模式使得测试数据与测试代码分离,非技术人员也可以维护测试数据。
七、测试跳过与预期失败
在实际开发中,有些测试在特定条件下不应执行(如平台相关、依赖未安装),有些测试已知失败需待后续修复。unittest提供了跳过和预期失败机制来优雅处理这些场景。
7.1 无条件跳过
unittest.skip装饰器和skipTest方法用于无条件跳过测试,适用于尚未实现的测试或暂时不需要运行的测试。
class TestSkipDemo(unittest.TestCase):
@unittest.skip("此功能尚未实现")
def test_not_implemented(self):
# 测试代码尚未编写
pass
def test_skip_in_body(self):
if not external_service_available():
self.skipTest("外部服务不可用,跳过此测试")
# 以下是实际测试逻辑
result = call_external_service()
self.assertTrue(result)
7.2 条件跳过
skipIf和skipUnless装饰器根据条件表达式决定是否跳过测试。skipIf在条件为True时跳过,skipUnless在条件为False时跳过。它们广泛用于平台兼容性测试和版本依赖测试。
import sys
import os
class TestConditionalSkip(unittest.TestCase):
@unittest.skipIf(sys.platform == 'win32', "此测试不在Windows上运行")
def test_unix_only_feature(self):
# Unix特有的功能测试
result = os.system('grep --version')
self.assertEqual(result, 0)
@unittest.skipUnless(sys.platform == 'darwin', "仅在macOS上运行")
def test_macos_only(self):
# macOS特有的功能测试
self.assertTrue(True)
@unittest.skipIf(
sys.version_info < (3, 10),
"需要Python 3.10+的match语句"
)
def test_match_statement(self):
# Python 3.10的match语句测试
value = 42
match value:
case 42:
self.assertTrue(True)
case _:
self.fail("未匹配到预期值")
@unittest.skipUnless(
importlib.util.find_spec("pandas"),
"需要pandas库"
)
def test_pandas_integration(self):
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3]})
self.assertEqual(len(df), 3)
7.3 预期失败
expectedFailure装饰器标记一个已知会失败的测试。当被标记的测试失败时,它不会被视为测试失败,而是被记录为预期失败(expected failure);如果意外通过了,则会被记录为意外通过(unexpected pass)。这在TDD流程中特别有用:先标记一个预期失败的测试,然后编写实现代码使其变为意外通过,最后移除装饰器确认测试成为真正的通过项。
class TestExpectedFailure(unittest.TestCase):
@unittest.expectedFailure
def test_known_bug(self):
"""已知的Bug,预期失败"""
result = buggy_function(42)
self.assertEqual(result, 100)
# 当buggy_function返回的不是100时,测试失败但被标记为预期失败
@unittest.expectedFailure
def test_known_limitation(self):
"""已知限制,预期失败"""
with self.assertRaises(NotImplementedError):
legacy_function()
class TestWithSkipRegistry(unittest.TestCase):
"""实用的跳过条件管理"""
@classmethod
def setUpClass(cls):
cls.skip_reason = {}
if not db_available():
cls.skip_reason['db'] = "数据库不可用"
if not has_gpu():
cls.skip_reason['gpu'] = "无GPU支持"
def test_db_operation(self):
if 'db' in self.skip_reason:
self.skipTest(self.skip_reason['db'])
# 数据库测试逻辑
self.assertTrue(True)
def test_gpu_compute(self):
if 'gpu' in self.skip_reason:
self.skipTest(self.skip_reason['gpu'])
# GPU计算测试逻辑
self.assertTrue(True)
跳过的测试在测试报告中显示为 s(skipped),预期失败显示为 x(expected failure),意外通过显示为 u(unexpected pass)。
八、unittest.mock集成
unittest.mock是Python 3.3+内置的模拟库,从Python 3.8起成为unittest的一部分。它允许用模拟对象替换系统中的部分组件,隔离被测试代码与外部依赖(如网络请求、数据库、文件系统、时间等),使测试更加快速、可靠和专注。
8.1 Mock基础
Mock是一个灵活的模拟对象,可以模拟任何类或接口。它的核心特性包括:自动创建属性和方法、记录所有调用信息、可配置返回值或抛出异常。
from unittest.mock import Mock, MagicMock, patch
# 创建基本Mock
mock = Mock()
mock.return_value = 42
self.assertEqual(mock(), 42)
# Mock对象自动创建属性和方法
mock.some_method.return_value = "hello"
self.assertEqual(mock.some_method(), "hello")
# 模拟属性
mock.some_property = 100
self.assertEqual(mock.some_property, 100)
# MagicMock: 自动模拟魔术方法
magic = MagicMock()
magic.__len__.return_value = 5
self.assertEqual(len(magic), 5)
magic.__iter__.return_value = iter([1, 2, 3])
self.assertEqual(list(magic), [1, 2, 3])
8.2 patch与patch.object
patch是mock的核心工具,用于在测试期间临时替换对象。它可以作为装饰器使用,也可以作为上下文管理器,在其作用域结束后自动恢复原始对象。patch.object用于替换特定对象的属性,patch用于替换导入路径下的对象。
# 被测试代码(user_service.py)
import requests
def get_user_data(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
if response.status_code == 200:
return response.json()
return None
# 测试代码
from unittest.mock import patch, MagicMock
import unittest
class TestUserService(unittest.TestCase):
# 装饰器形式:注入mock对象
@patch('user_service.requests.get')
def test_get_user_data_success(self, mock_get):
# 配置mock返回值
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": 1,
"name": "Alice",
"email": "alice@example.com"
}
mock_get.return_value = mock_response
# 执行被测试函数
result = get_user_data(1)
# 验证结果
self.assertEqual(result["name"], "Alice")
mock_get.assert_called_once_with(
"https://api.example.com/users/1"
)
# 上下文管理器形式
def test_get_user_data_not_found(self):
with patch('user_service.requests.get') as mock_get:
mock_response = MagicMock()
mock_response.status_code = 404
mock_get.return_value = mock_response
result = get_user_data(999)
self.assertIsNone(result)
mock_get.assert_called_once()
# patch.object示例
@patch.object(requests, 'get')
def test_with_patch_object(self, mock_get):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1}
mock_get.return_value = mock_response
result = get_user_data(1)
self.assertEqual(result["id"], 1)
8.3 Mock断言方法
Mock对象提供了丰富的断言方法,用于验证模拟对象的调用行为。这些断言自动包含清晰的错误信息,展示实际调用与期望调用的差异。
mock = Mock()
# 基本断言
mock.assert_called() # 至少调用一次
mock.assert_called_once() # 恰好调用一次
mock.assert_called_once_with(1, 2, key='val') # 以特定参数恰好调用一次
mock.assert_called_with(1, 2, key='val') # 最近一次调用匹配参数
mock.assert_any_call(1, 2, key='val') # 任意一次调用匹配参数
mock.assert_not_called() # 从未被调用
# 调用信息查询
print(mock.called) # 是否已被调用
print(mock.call_count) # 调用次数
print(mock.call_args) # 最近一次调用的参数
print(mock.call_args_list) # 所有调用的参数列表
# 复杂断言场景
mock(1, foo="bar")
mock(2, foo="baz")
mock(3, foo="qux")
# 按调用顺序精确断言
expected_calls = [
unittest.mock.call(1, foo="bar"),
unittest.mock.call(2, foo="baz"),
unittest.mock.call(3, foo="qux"),
]
mock.assert_has_calls(expected_calls)
# 按任意顺序断言
mock.assert_has_calls(expected_calls, any_order=True)
8.4 side_effect高级应用
side_effect是Mock最强大的功能之一,它可以指定:一个异常(调用时抛出)、一个可迭代对象(每次调用返回下一个值)、或一个函数(根据参数动态计算返回值)。
from unittest.mock import Mock
# 1. side_effect作为异常
mock = Mock()
mock.side_effect = ValueError("模拟错误")
try:
mock()
except ValueError as e:
self.assertEqual(str(e), "模拟错误")
# 2. side_effect作为迭代器(不同调用返回不同值)
mock = Mock()
mock.side_effect = [10, 20, 30, ValueError("结束")]
self.assertEqual(mock(), 10)
self.assertEqual(mock(), 20)
self.assertEqual(mock(), 30)
with self.assertRaises(ValueError):
mock()
# 3. side_effect作为函数(动态返回值)
def dynamic_response(url):
if "user" in url:
return {"type": "user", "id": 1}
elif "post" in url:
return {"type": "post", "content": "hello"}
else:
raise ValueError(f"未知URL: {url}")
mock = Mock()
mock.side_effect = dynamic_response
self.assertEqual(mock("/api/user/1")["type"], "user")
self.assertEqual(mock("/api/post/5")["type"], "post")
with self.assertRaises(ValueError):
mock("/api/unknown")
8.5 实战:完整服务层模拟测试
以下是一个综合示例,展示如何使用mock隔离测试一个依赖于外部API、数据库和时间的服务类。
from unittest.mock import patch, Mock, MagicMock, PropertyMock
from datetime import datetime
import unittest
# 被测试的服务
class OrderService:
def __init__(self):
self.db = Database()
self.email = EmailService()
self.payment = PaymentGateway()
def process_order(self, order_id):
order = self.db.get_order(order_id)
if not order:
raise ValueError(f"订单 {order_id} 不存在")
if order["status"] != "pending":
raise ValueError(f"订单状态不正确: {order['status']}")
payment_result = self.payment.charge(
order["amount"],
order["currency"]
)
if payment_result["success"]:
self.db.update_status(order_id, "paid")
self.email.send_confirmation(order["user_email"], order_id)
return {"status": "success", "payment_id": payment_result["id"]}
else:
return {"status": "failed", "reason": payment_result["error"]}
# 完整测试
class TestOrderService(unittest.TestCase):
@patch('__main__.OrderService.db')
@patch('__main__.OrderService.payment')
@patch('__main__.OrderService.email')
def test_process_order_success(self, mock_email, mock_payment, mock_db):
# 准备测试数据
mock_db.get_order.return_value = {
"id": 123,
"status": "pending",
"amount": 99.99,
"currency": "USD",
"user_email": "alice@example.com",
}
mock_payment.charge.return_value = {
"success": True,
"id": "txn_abc123",
}
# 执行
service = OrderService()
result = service.process_order(123)
# 验证
self.assertEqual(result["status"], "success")
self.assertEqual(result["payment_id"], "txn_abc123")
mock_db.update_status.assert_called_once_with(123, "paid")
mock_email.send_confirmation.assert_called_once_with(
"alice@example.com", 123
)
def test_process_order_not_found(self):
service = OrderService()
service.db = MagicMock()
service.db.get_order.return_value = None
with self.assertRaises(ValueError) as cm:
service.process_order(999)
self.assertIn("不存在", str(cm.exception))
service.db.get_order.assert_called_once_with(999)
@patch('__main__.OrderService.payment')
def test_process_order_payment_failed(self, mock_payment):
mock_payment.charge.return_value = {
"success": False,
"error": "余额不足",
}
service = OrderService()
service.db = MagicMock()
service.db.get_order.return_value = {
"id": 456, "status": "pending",
"amount": 50, "currency": "CNY",
"user_email": "bob@test.com",
}
result = service.process_order(456)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["reason"], "余额不足")
service.db.update_status.assert_not_called()
通过合理使用unittest.mock,开发者可以将测试焦点完全集中到被测试逻辑本身,而不是外部依赖的行为。同时,由于消除了网络延迟、磁盘I/O等不可控因素,测试速度显著提升,可以在毫秒级别完成数百个测试用例的执行。
九、最佳实践与常见陷阱
在长期使用unittest的过程中,以下最佳实践和陷阱值得关注。继承这些经验可以帮助你写出更高质量的测试代码,避免一些常见的错误。
9.1 测试设计原则
好的测试具有确定性:多次运行同一测试应得到相同结果。避免在测试中依赖当前时间、随机值或外部服务的状态。使用mock控制这些不确定性。每个测试应该只测试一个行为点,一个测试方法中不应该有多个不相关的断言。遵循Arrange-Act-Assert(AAA)模式:准备测试数据 → 执行被测代码 → 验证结果。最忌讳的是一大段连续的业务代码嵌套多个副作用,令测试无从下手。
9.2 测试命名与组织
测试方法的命名应当像一句完整的话:test_方法名_场景_期望行为。例如test_discount_calculate_when_loyal_member_returns_20_percent_off。测试文件在项目中的位置应当与源码结构镜像对齐——tests/test_math_utils.py 对应 myapp/math_utils.py。这样无论项目增长到何种规模,测试都能快速定位。
9.3 常见陷阱
陷阱一:在测试之间共享可变状态。即使是类变量也可能导致测试间耦合——一个测试修改的状态会影响另一个测试的行为。解决方案是在setUp中重新创建状态,而不是在类层级共享。陷阱二:过度Mock。mock了太多内部实现细节导致测试与实现高度耦合,重构实现时代价巨大。应mock外部边界(网络、文件系统、数据库),而不是mock内部协作对象。陷阱三:测试不验证副作用。只检查返回值而忽略了方法是否实际调用了必要的外部依赖,导致生产环境中看似正确的代码却没有任何效果。
经验之谈:"编写测试不只是为了验证代码的正确性, 更是为了设计思考——测试迫使你从调用者的角度审视API设计。如果某个函数很难测试, 往往意味着它的设计需要重构。"
9.4 持续集成集成
将测试集成到CI/CD流水线是质量保障的最后一道防线。在CI配置中添加 python -m unittest discover -v 命令,确保每次代码提交都自动运行完整测试套件。结合覆盖率工具(如coverage.py)可以量化测试的覆盖程度,识别未被测试覆盖的代码路径。