杰瑞科技汇

Python unittest高级技巧有哪些实用场景?

  1. setUptearDown 的增强版:setUpClass, tearDownClass, setUpModule, tearDownModule
  2. 测试固件:TestCaseTestSuite 的组合艺术
  3. 参数化测试:用数据驱动测试
  4. Mocking(模拟):unittest.mock 的强大威力
  5. 测试跳过与预期失败:skipexpectedFailure
  6. 加载与发现测试:TestLoader 的自动化
  7. 测试运行器:TextTestRunner 与自定义输出
  8. 最佳实践与设计模式

setUptearDown 的增强版

标准的 setUptearDown 在每个测试方法执行前后都会运行,这在某些场景下效率不高。

Python unittest高级技巧有哪些实用场景?-图1
(图片来源网络,侵删)

setUpClasstearDownClass

  • 作用:在测试类的第一个测试方法运行前执行 setUpClass,在最后一个测试方法运行后执行 tearDownClass
  • 用途:用于执行开销很大的操作,例如启动数据库连接、加载大型模型文件、创建临时服务器等,这样可以避免在每个测试中都重复这些操作。
  • 注意:这两个方法必须是类方法,需要使用 @classmethod 装饰器,并且第一个参数通常是 cls
import unittest
import time
class TestDatabaseConnection(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        print("\n[Class] setUpClass: 正在建立数据库连接...")
        # 模拟一个耗时的连接操作
        time.sleep(1)
        cls.db_connection = "Database Connection Established"
        print("[Class] setUpClass: 数据库连接已建立。")
    @classmethod
    def tearDownClass(cls):
        print("\n[Class] tearDownClass: 正在关闭数据库连接...")
        # 模拟关闭连接
        time.sleep(0.5)
        cls.db_connection = None
        print("[Class] tearDownClass: 数据库连接已关闭。")
    def setUp(self):
        print("\n[Method] setUp: 每个测试方法前准备测试数据...")
        self.user_data = {"id": 1, "name": "Alice"}
    def tearDown(self):
        print("[Method] tearDown: 每个测试方法后清理测试数据...")
        self.user_data = None
    def test_create_user(self):
        print("测试: 创建用户")
        self.assertIsNotNone(self.db_connection)
        self.assertEqual(self.db_connection, "Database Connection Established")
        self.assertIn("name", self.user_data)
    def test_get_user(self):
        print("测试: 获取用户")
        self.assertIsNotNone(self.db_connection)
        self.assertEqual(self.user_data["name"], "Alice")
if __name__ == '__main__':
    unittest.main()

setUpModuletearDownModule

  • 作用:在整个模块的所有测试开始前执行 setUpModule,在所有测试结束后执行 tearDownModule
  • 用途:用于模块级别的设置和清理,例如创建和删除整个测试专用的临时目录。
# 在 test_example.py 文件的开头
def setUpModule():
    print("\n[Module] setUpModule: 创建临时测试目录...")
    # import os
    # os.makedirs("temp_test_dir")
def tearDownModule():
    print("\n[Module] tearDownModule: 删除临时测试目录...")
    # import os
    # import shutil
    # shutil.rmtree("temp_test_dir")
# ... 你的 TestCase 类 ...

测试固件:TestCaseTestSuite 的组合艺术

当测试变多时,直接用 unittest.main() 会显得混乱。TestSuite 允许你将测试用例和测试套件组织起来,实现更精细的控制。

  • TestSuite:一个或多个测试用例的集合。
  • TestLoader:用于从模块、类或文件中自动发现和加载测试,并构建 TestSuite

手动创建测试套件

# 假设你有两个测试类
class TestMathOperations(unittest.TestCase):
    # ... 测试加法、减法等方法 ...
class TestStringOperations(unittest.TestCase):
    # ... 测试拼接、分割等方法 ...
if __name__ == '__main__':
    # 1. 创建一个测试套件
    suite = unittest.TestSuite()
    # 2. 将测试类或单个测试方法添加到套件中
    # 添加整个测试类
    suite.addTest(unittest.makeSuite(TestMathOperations))
    # 添加单个测试方法
    suite.addTest(TestStringOperations('test_concatenate'))
    # 3. 创建一个测试运行器来运行套件
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(suite)

自动发现测试

更常见的方式是让 TestLoader 自动发现你项目中的所有测试。

if __name__ == '__main__':
    # 从当前目录开始,递归查找所有 test*.py 文件
    loader = unittest.TestLoader()
    suite = loader.discover(start_dir='.', pattern='test_*.py')
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(suite)

最佳实践:将测试代码放在 tests 目录下,文件名以 test_ 开头,然后在项目根目录创建一个 run_tests.py 文件,内容如上所示,方便一键运行所有测试。


参数化测试

当一个测试逻辑相同,只是输入数据不同时,为了避免代码重复,我们可以使用参数化测试。

Python unittest高级技巧有哪些实用场景?-图2
(图片来源网络,侵删)

unittest 本身不直接支持参数化,但可以通过第三方库或一些技巧实现,这里介绍两种流行的方式。

使用 subTest (标准库,简单场景)

subTest 允许你在一个循环中运行多个子测试,如果其中一个失败,其他子测试仍会继续执行,并且报告会清晰地指出是哪个子测试失败了。

class TestListOperations(unittest.TestCase):
    def test_sum_of_list_elements(self):
        test_data = [
            ([1, 2, 3], 6),
            ([], 0),
            ([-1, 0, 1], 0),
            ([10, 20, 30], 60)
        ]
        for input_list, expected_sum in test_data:
            with self.subTest(input_list=input_list, expected_sum=expected_sum):
                # 每次循环都会作为一个独立的测试用例来报告
                self.assertEqual(sum(input_list), expected_sum)
# 运行时输出会类似这样:
# test_sum_of_list_elements (__main__.TestListOperations) ... ok
# test_sum_of_list_elements (__main__.TestListOperations) ... ok
# test_sum_of_list_elements (__main__.TestListOperations) ... ok
# test_sum_of_list_elements (__main__.TestListOperations) ... ok

使用 parameterized 库 (更强大)

parameterized 库是专门为参数化测试设计的,语法更简洁。

安装: pip install parameterized

Python unittest高级技巧有哪些实用场景?-图3
(图片来源网络,侵删)

使用:

from parameterized import parameterized
import unittest
class TestStringMethods(unittest.TestCase):
    @parameterized.expand([
        ("hello world", "hello", True),
        ("hello world", "hi", False),
        ("", "a", False),
        ("python", "PYTHON", False),
    ])
    def test_substring(self, input_string, substring, expected):
        self.assertEqual(substring in input_string, expected)
if __name__ == '__main__':
    unittest.main(verbosity=2)

@parameterized.expand 会将列表中的每个元组转换成一个独立的测试方法,测试名称会包含参数信息,非常易于调试。


Mocking(模拟):unittest.mock 的强大威力

Mocking 是单元测试的核心技术之一,它的核心思想是:用一个“假”的对象(Mock Object)来替换掉被测代码所依赖的真实对象,这样,你可以:

  1. 隔离被测代码:测试 A 时,不需要真的去连接数据库或调用外部 API,只需模拟数据库或 API 的行为即可。
  2. 控制测试环境:可以模拟各种边界条件和异常情况(如网络超时、API 返回 500 错误等)。
  3. 提升测试速度:避免了 I/O 操作(如网络请求、磁盘读写),测试运行得更快。

unittest.mock 模块提供了强大的 Mocking 工具,主要是 MagicMockpatch

MagicMock

MagicMock 是一个通用的 Mock 对象,它会为你返回新的 MagicMock 实例作为任何属性的访问结果。

from unittest.mock import MagicMock
# 创建一个模拟对象
mock_connection = MagicMock()
# 模拟对象的方法调用
mock_connection.connect.return_value = "Connected!"
mock_connection.execute.return_value = {"id": 1, "name": "Mocked User"}
# 模拟对象的属性访问
mock_connection.timeout = 30
# 在你的代码中使用这个模拟对象
# db = get_db_connection() # 假设这返回了我们的 mock_connection
result = mock_connection.execute("SELECT * FROM users")
print(result)  # 输出: {'id': 1, 'name': 'Mocked User'}
# 验证方法是否被以特定参数调用过
mock_connection.execute.assert_called_once_with("SELECT * FROM users")
# 验证方法是否被调用过
mock_connection.connect.assert_called()

patch (装饰器或上下文管理器)

patch 是最强大的工具,它可以在一个代码块内(通常是测试方法)临时替换一个对象(变量、方法、类等)。

语法: patch('target.object', new_mock_object)

最佳实践: target 应该是从被测模块的视角能够看到的导入路径。

示例: 假设我们有一个 emailer.py 模块和一个 app.py 模块。

emailer.py:

import smtplib
def send_email(to, subject, body):
    with smtplib.SMTP('smtp.example.com', 587) as server:
        server.sendmail('noreply@example.com', to, f"Subject: {subject}\n\n{body}")
    print(f"Email sent to {to}")

app.py:

from emailer import send_email
def notify_user(user_email, message):
    send_email(to=user_email, subject="You've got a message!", body=message)

我们想测试 app.py 中的 notify_user 函数,但不真的发送邮件。

test_app.py:

import unittest
from unittest.mock import patch, MagicMock
from app import notify_user
class TestApp(unittest.TestCase):
    @patch('app.send_email')  # 关键!从 app.py 的视角去 patch 'send_email'
    def test_notify_user(self, mock_send_email):
        # 1. 准备测试数据
        test_email = "test@example.com"
        test_message = "Hello, this is a test."
        # 2. 调用被测函数
        notify_user(test_email, test_message)
        # 3. 断言
        # 验证 send_email 是否被正确调用
        mock_send_email.assert_called_once_with(
            to=test_email,
            subject="You've got a message!",
            body=test_message
        )
        # 验证它没有被调用超过一次
        mock_send_email.assert_called_once()
        # 验证它没有被调用两次
        # mock_send_email.assert_called_once() # 如果调用两次,这里会失败
if __name__ == '__main__':
    unittest.main()

在这个例子中,@patch('app.send_email') 告诉 unittest:“在 test_notify_user 方法执行期间,把 app 模块里的 send_email 函数替换成一个 MagicMock 实例,并把那个实例作为参数 mock_send_email 传入。” 这样,notify_user 调用的 send_email 实际上就是我们的 mock_send_email,而不会真的发邮件。


测试跳过与预期失败

有时测试会因为某些原因无法运行,或者我们明确知道某个测试会失败,但希望它能被标记而不是直接导致测试套件失败。

  • @unittest.skip(reason): 无条件跳过测试。
  • @unittest.skipIf(condition, reason): 如果条件为真,则跳过测试。
  • @unittest.skipUnless(condition, reason): 除非条件为真,否则跳过测试。
  • @unittest.expectedFailure: 标记一个测试为“预期失败”,如果测试真的失败了,它会被记录为“通过”(XPASS, expected pass);如果测试意外地通过了,它会被记录为“失败”(UPASS, unexpected pass)。
import sys
import unittest
class ConditionalTests(unittest.TestCase):
    @unittest.skip("这个测试暂时不需要了")
    def test_simple_skip(self):
        self.fail("这个测试不应该被执行")
    @unittest.skipIf(sys.version_info < (3, 7), "需要 Python 3.7 或更高版本")
    def test_skip_on_version(self):
        self.assertEqual("hello".upper(), "HELLO")
    @unittest.skipUnless(sys.platform.startswith("win"), "仅适用于 Windows")
    def test_windows_specific(self):
        self.assertTrue(True) # 假设这是一个 Windows 特有的测试
    @unittest.expectedFailure
    def test_expected_failure(self):
        self.assertEqual(1 + 1, 3) # 我们知道这个断言会失败
if __name__ == '__main__':
    unittest.main()

加载与发现测试

这部分在 第2节 中已经有所涉及,但可以更深入。

unittest.TestLoader 负责加载测试。

  • loader.loadTestsFromName(name): 从一个给定的名称(如 module.Class.test_method)加载测试。
  • loader.loadTestsFromNames(names): 从多个名称加载测试。
  • loader.loadTestsFromModule(module): 从一个模块中加载所有测试。
  • loader.loadTestsFromTestCase(test_case_class):从一个测试类中加载所有测试。
  • loader.discover(start_dir, pattern='test*.py'): 这是命令行 python -m unittest 背后使用的主要方法,它会递归地从 start_dir 目录下查找所有匹配 pattern 的文件,并从中加载测试。

测试运行器

unittest.TextTestRunner 是默认的文本运行器,你可以自定义它来改变输出格式或添加额外功能。

import unittest
from io import StringIO
# 自定义一个运行器,将输出重定向到 StringIO
class CustomTestRunner(unittest.TextTestRunner):
    def __init__(self, stream=None, **kwargs):
        if stream is None:
            stream = StringIO() # 输出到内存,而不是控制台
        super().__init__(stream=stream, **kwargs)
    def get_output(self):
        return self.stream.getvalue()
if __name__ == '__main__':
    suite = unittest.TestLoader().loadTestsFromTestCase(TestListOperations)
    runner = CustomTestRunner(verbosity=2)
    result = runner.run(suite)
    # 获取测试的文本输出
    output = runner.get_output()
    print("\n--- 获取到的测试输出 ---")
    print(output)

更高级的用法是继承 unittest.TestRunner 来创建完全自定义的运行器,例如生成 HTML 报告、集成到 CI/CD 系统中并发送结果邮件等,对于 HTML 报告,推荐使用 pytest-htmlunittest-xml-reporting 等第三方库。


最佳实践与设计模式

  1. 保持测试的独立性:每个测试都应该是独立的,不应该依赖于其他测试的执行顺序或结果。setUptearDown 是保证独立性的关键。
  2. AAA 模式 (Arrange, Act, Assert):
    • Arrange (准备): 设置测试环境和数据。
    • Act (执行): 调用被测试的方法或函数。
    • Assert (断言): 验证结果是否符合预期。
  3. 测试应该快速:单元测试应该在几秒钟内完成,避免进行 I/O 操作,使用 Mock 来替代。
  4. 测试要具有描述性:测试方法的名称应该清晰地描述它正在测试什么。test_user_login_with_valid_credentialstest_login 更好。
  5. 不要测试私有方法:单元测试应该测试类的公共接口,测试私有方法通常意味着你的类设计可能存在问题(职责不够单一),如果确实需要,可以考虑重构代码或使用 @property 将其变为公共方法。
  6. F.I.R.S.T. 原则:
    • Fast (快速)
    • Independent (独立)
    • Repeatable (可重复)
    • Self-Validating (自验证,不需要人工检查结果)
    • Timely (及时,在编写代码的同时或之后立即编写测试)

通过掌握这些高级技巧,你的 unittest 测试将不再是简单的脚本,而是一个健壮、高效、易于维护的质量保障体系。

分享:
扫描分享到社交APP
上一篇
下一篇