3. unittest进阶:setUp与tearDown方法、setUpClass与tearDownClass、测试夹具(Fixture)管理、测试用例的组织与发现

好,咱们接着往下聊。上一章我们把 unittest 的基本用法过了一遍,你大概已经能写出简单的测试用例了。但说实话,真正写项目测试的时候,你会发现事情没那么简单——每个测试用例都要创建数据库连接、加载配置文件、初始化一些对象……如果每个用例都重复写一遍,那代码就太丑了。

这一章,我们就来解决这个问题。我会带你看看 unittest 里那些「让测试代码更干净」的利器。

3.1 setUp 与 tearDown:每个用例的前后处理

先说说最常用的两个方法:setUptearDown

说白了,setUp 就是在每个测试用例执行之前自动运行的方法。tearDown 呢,就是每个用例执行之后自动运行的方法。它们俩就像一对门神,帮你把测试环境收拾得干干净净。

我举个例子你就明白了。假设我们要测试一个用户服务,每个测试用例都需要先创建一个数据库连接:

import unittest

class TestUserService(unittest.TestCase):
    
    def setUp(self):
        # 每个测试用例执行前都会调用
        print("正在建立数据库连接...")
        self.db = create_database_connection()
        self.db.clear_all_data()
        
    def tearDown(self):
        # 每个测试用例执行后都会调用
        print("正在关闭数据库连接...")
        self.db.close()
        
    def test_create_user(self):
        user = self.db.create_user("张三", "zhangsan@example.com")
        self.assertIsNotNone(user.id)
        
    def test_delete_user(self):
        self.db.create_user("李四", "lisi@example.com")
        result = self.db.delete_user("lisi@example.com")
        self.assertTrue(result)

你看,setUp 里做了两件事:建立连接、清空数据。tearDown 里关闭连接。这样每个测试用例开始的时候,数据库都是干干净净的,不会互相影响。

我的经验:我个人习惯在 setUp 里只做「每个用例都需要」的事情。如果某个用例有特殊的前置条件,我会单独在用例里处理,而不是塞进 setUp 里。不然 setUp 会越来越臃肿,最后变成一个大泥球。

3.2 setUpClass 与 tearDownClass:类级别的前后处理

好,问题来了。如果每个测试用例都要创建数据库连接,那 100 个用例就要创建 100 次连接。这效率也太低了,对吧?

这时候就需要 setUpClasstearDownClass 出场了。它们只在测试类开始前执行一次,结束后执行一次。注意,它们是类方法,需要用 @classmethod 装饰。

import unittest

class TestUserService(unittest.TestCase):
    
    @classmethod
    def setUpClass(cls):
        # 整个测试类开始前执行一次
        print("初始化数据库连接池...")
        cls.db_pool = create_connection_pool()
        
    @classmethod
    def tearDownClass(cls):
        # 整个测试类结束后执行一次
        print("关闭数据库连接池...")
        cls.db_pool.close_all()
        
    def setUp(self):
        # 每个用例前执行
        self.conn = self.db_pool.get_connection()
        self.conn.begin_transaction()
        
    def tearDown(self):
        # 每个用例后执行
        self.conn.rollback()
        self.conn.close()
        
    def test_create_user(self):
        user = self.conn.create_user("张三", "zhangsan@example.com")
        self.assertIsNotNone(user.id)

这个模式在实际项目中非常常见。连接池只创建一次,每个用例拿一个连接,用完就还回去。我曾经在一个项目中看到有人把数据库连接创建写在 setUp 里,跑 500 个测试用例花了 3 分钟。改成 setUpClass 后,直接降到 10 秒。

注意:setUpClass 和 tearDownClass 里操作的是类属性(cls.xxx),不是实例属性(self.xxx)。这个细节很容易搞混,我刚开始写的时候也踩过这个坑。

3.3 测试夹具(Fixture)管理

聊到这儿,咱们得正式认识一下「测试夹具」这个概念。其实你刚才看到的 setUptearDownsetUpClasstearDownClass,它们都属于测试夹具的范畴。

测试夹具,说白了就是「为了让测试能跑起来,需要提前准备好的东西」。比如:

  • 数据库里的测试数据
  • 临时文件
  • 模拟的网络服务
  • 环境变量
  • 缓存数据

在 unittest 里,夹具的管理主要靠上面那四个方法。但实际项目中,我们还会遇到更复杂的情况。比如,有些夹具只在某些测试用例中需要,有些夹具需要嵌套使用。

我分享一个我常用的模式:

class TestOrderService(unittest.TestCase):
    
    @classmethod
    def setUpClass(cls):
        # 全局夹具:数据库连接
        cls.db = Database.connect()
        
    def setUp(self):
        # 每个用例的夹具:清空数据 + 创建基础数据
        self.db.clear()
        self.db.create_user("test_user")
        self.db.create_product("test_product", price=100)
        
    def test_create_order(self):
        # 这个用例需要额外的夹具
        self.db.add_balance("test_user", 500)
        order = self.db.create_order("test_user", "test_product")
        self.assertEqual(order.status, "paid")
        
    def test_cancel_order(self):
        # 先创建一个订单作为夹具
        order = self.db.create_order("test_user", "test_product")
        result = self.db.cancel_order(order.id)
        self.assertTrue(result)
核心原则:夹具的管理要遵循「最小化原则」——只准备当前测试真正需要的东西。准备太多,测试会变慢;准备太少,测试可能因为缺少依赖而失败。

3.4 测试用例的组织与发现

好了,现在你有了很多测试用例。怎么组织它们?怎么让 unittest 自动找到它们并运行?

先说组织。我一般按模块来组织测试文件:

project/
├── src/
│   ├── user_service.py
│   ├── order_service.py
│   └── payment_service.py
└── tests/
    ├── __init__.py
    ├── test_user_service.py
    ├── test_order_service.py
    └── test_payment_service.py

每个测试文件对应一个模块。文件名以 test_ 开头,这是 unittest 的默认发现规则。

再说发现。unittest 提供了 TestLoader 来自动发现测试用例:

# 方式一:命令行
# python -m unittest discover tests/

# 方式二:代码中手动加载
import unittest

loader = unittest.TestLoader()
suite = loader.discover('tests', pattern='test_*.py')

runner = unittest.TextTestRunner()
runner.run(suite)

discover 方法会递归查找 tests/ 目录下所有以 test_ 开头的文件,然后自动加载里面的测试类和方法。

这里有几个小细节要注意:

  • 测试类必须继承 unittest.TestCase
  • 测试方法必须以 test 开头
  • 每个测试目录下最好放一个 __init__.py 文件(可以是空的)
避坑指南:我曾经遇到过一个情况,测试文件明明写好了,但 discover 就是找不到。查了半天,发现是文件名写成了 test-users.py,中间有个横线。unittest 默认只识别 test_*.py 这种下划线格式。嗯,这种小细节真的很坑。

3.5 综合示例:一个完整的测试夹具管理

最后,我给你看一个稍微完整点的例子。假设我们要测试一个博客系统的文章服务:

import unittest
import tempfile
import os

class TestArticleService(unittest.TestCase):
    
    @classmethod
    def setUpClass(cls):
        # 创建临时目录作为测试存储
        cls.temp_dir = tempfile.mkdtemp()
        cls.service = ArticleService(storage_path=cls.temp_dir)
        
    @classmethod
    def tearDownClass(cls):
        # 清理临时目录
        import shutil
        shutil.rmtree(cls.temp_dir)
        
    def setUp(self):
        # 每个用例前创建一篇默认文章
        self.article = self.service.create_article(
            title="测试文章",
            content="这是测试内容"
        )
        
    def tearDown(self):
        # 每个用例后清理所有文章
        self.service.delete_all()
        
    def test_update_article(self):
        updated = self.service.update_article(
            self.article.id,
            title="更新后的标题"
        )
        self.assertEqual(updated.title, "更新后的标题")
        
    def test_delete_article(self):
        self.service.delete_article(self.article.id)
        result = self.service.get_article(self.article.id)
        self.assertIsNone(result)

这个例子把咱们今天讲的东西都用上了:

  • setUpClass 创建临时目录(只做一次)
  • tearDownClass 清理临时目录(只做一次)
  • setUp 创建默认文章(每个用例都做)
  • tearDown 清理数据(每个用例都做)

这样设计的好处是:测试之间完全隔离,互不干扰。而且因为连接和目录只创建一次,性能也还不错。

好了,这一章的内容就到这儿。下一章我们会聊聊 mock 和 patch,那是处理外部依赖的利器。到时候你会发现,有了 mock,测试那些调第三方 API 的代码会变得特别轻松。