Python unittest使用指南 - 实践

Python unittest 使用指南

在软件开发中,“保证代码正确性”是核心需求之一。当我们编写函数、类或模块后,如何验证其行为符合预期?如何在修改代码后快速排查问题?单元测试是解决这些问题的关键技术,而 Python 内置的 unittest 模块(基于 xUnit 框架),正是实现单元测试的强大工具——无需额外安装,语法规范,支持自动化测试、用例组织、断言校验等核心功能。

一、什么是单元测试与 unittest?

1.1 单元测试的核心价值

单元测试是对软件中最小可测试单元(如函数、方法、类)的独立验证,核心目标是:

  • 验证代码行为符合预期(输入特定参数时,输出是否正确);
  • 快速定位问题(修改代码后,若测试失败,可精准定位改动影响的范围);
  • 支持安全重构(重构代码逻辑时,通过单元测试确保功能不退化);
  • 提升代码质量(编写测试时,会倒逼开发者设计更易测试、低耦合的代码)。

1.2 unittest 模块简介

unittest 是 Python 标准库内置的单元测试框架,无需额外安装,核心特性包括:

  • 提供 TestCase 基类,用于封装测试用例;
  • 丰富的断言方法(如验证相等、包含、异常等);
  • 支持用例组织(TestSuite)和批量执行;
  • 提供 setUp()/tearDown() 等钩子方法,简化测试资源的初始化与清理;
  • 支持生成测试报告(可结合第三方库优化格式)。

二、unittest 快速入门:编写第一个测试用例

我们从一个简单场景入手:编写一个计算工具函数,并用 unittest 验证其正确性。

2.1 步骤 1:编写待测试代码

首先创建一个待测试的模块 calculator.py,包含加法、减法两个函数:

# calculator.py
def add(a, b):
"""加法函数:返回 a + b 的结果"""
return a + b
def subtract(a, b):
"""减法函数:返回 a - b 的结果"""
return a - b

2.2 步骤 2:编写 unittest 测试用例

创建测试文件 test_calculator.py(测试文件建议以 test_ 开头,便于识别),继承 unittest.TestCase 编写测试类:

# test_calculator.py
import unittest
from calculator import add, subtract  # 导入待测试函数
# 测试类必须继承 unittest.TestCase
class TestCalculator(unittest.TestCase):
# 测试方法必须以 test_ 开头(unittest 会自动识别)
def test_add(self):
"""测试加法函数:正常场景、边界值、异常场景"""
# 断言:验证 add(1, 2) 的结果是否等于 3
self.assertEqual(add(1, 2), 3)
# 测试负数相加
self.assertEqual(add(-1, -2), -3)
# 测试浮点数相加
self.assertAlmostEqual(add(0.1, 0.2), 0.3)  # 浮点数精度问题用 assertAlmostEqual
def test_subtract(self):
"""测试减法函数"""
self.assertEqual(subtract(5, 3), 2)
self.assertEqual(subtract(3, 5), -2)
self.assertEqual(subtract(0, 0), 0)
# 运行测试(直接执行该文件时触发)
if __name__ == '__main__':
unittest.main()

2.3 步骤 3:执行测试与查看结果

方式 1:直接运行测试文件

在终端执行 python test_calculator.py,输出如下:

..
----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK
  • 输出解读:
    • .. 表示 2 个测试用例都通过(每个 . 对应一个通过的测试方法);
    • Ran 2 tests in 0.001s 表示执行了 2 个测试,耗时 0.001 秒;
    • OK 表示所有测试用例通过。
方式 2:命令行指定测试模块/类/方法
# 运行指定测试模块
python -m unittest test_calculator.py
# 运行指定测试类
python -m unittest test_calculator.TestCalculator
# 运行指定测试方法
python -m unittest test_calculator.TestCalculator.test_add
方式 3:带详细日志执行(-v 参数)
python -m unittest test_calculator.py -v

输出如下(更清晰展示每个测试方法的执行结果):

test_add (test_calculator.TestCalculator)
测试加法函数:正常场景、边界值、异常场景 ... ok
test_subtract (test_calculator.TestCalculator)
测试减法函数 ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK

三、unittest 核心语法:断言方法

断言是单元测试的核心——通过断言判断代码实际输出是否与预期一致。unittest.TestCase 提供了丰富的断言方法,以下是最常用的类别:

3.1 equality 断言(验证相等/不等)

断言方法作用示例
assertEqual(a, b, msg=None)验证 a == b,msg 为自定义失败提示self.assertEqual(add(2,3),5, "加法结果错误")
assertNotEqual(a, b)验证 a != bself.assertNotEqual(subtract(5,3), 3)
assertAlmostEqual(a, b, places=7)验证浮点数近似相等(places 为保留小数位)self.assertAlmostEqual(0.1+0.2, 0.3, places=1)

3.2 布尔值断言(验证 True/False)

断言方法作用示例
assertTrue(x)验证 x 为 Trueself.assertTrue(10 > 5)
assertFalse(x)验证 x 为 Falseself.assertFalse(10 < 5)

3.3 包含关系断言

断言方法作用示例
assertIn(x, container)验证 x 在 container 中self.assertIn(3, [1,2,3])
assertNotIn(x, container)验证 x 不在 container 中self.assertNotIn(4, [1,2,3])

3.4 异常断言(验证函数抛出指定异常)

断言方法作用示例
assertRaises(exception, callable, *args, **kwargs)验证调用函数时抛出指定异常self.assertRaises(ZeroDivisionError, divide, 5, 0)

3.5 其他常用断言

断言方法作用示例
assertIs(a, b)验证 a is b(身份相等)self.assertIsNone(None)
assertIsNone(x)验证 x 是 Noneself.assertIsNone(get_user(999))
assertGreater(a, b)验证 a > bself.assertGreater(10, 5)

代码示例:异常断言实战

calculator.py 添加除法函数(包含除以 0 的异常场景):

# calculator.py
def divide(a, b):
"""除法函数:b 不能为 0,否则抛出 ZeroDivisionError"""
if b == 0:
raise ZeroDivisionError("除数不能为 0")
return a / b

在测试类中添加异常测试方法:

# test_calculator.py
def test_divide(self):
"""测试除法函数:正常场景 + 异常场景"""
# 正常除法
self.assertEqual(divide(10, 2), 5)
self.assertAlmostEqual(divide(3, 2), 1.5)
# 测试除以 0 抛出异常(两种写法)
# 写法 1:使用 assertRaises 作为上下文管理器
with self.assertRaises(ZeroDivisionError) as ctx:
divide(5, 0)
# 验证异常信息
self.assertEqual(str(ctx.exception), "除数不能为 0")
# 写法 2:直接传入函数和参数
self.assertRaises(ZeroDivisionError, divide, 5, 0)

四、用例组织:setUp/tearDown 与测试套件

当测试用例增多时,需要高效组织用例(如共享资源初始化、批量执行指定用例),unittest 提供了完善的支持。

4.1 setUp() 与 tearDown():用例级资源管理

如果多个测试方法需要使用相同的资源(如创建对象、连接数据库),可通过 setUp()tearDown() 简化代码:

  • setUp():每个测试方法执行自动调用(初始化资源);
  • tearDown():每个测试方法执行自动调用(清理资源)。
代码示例:
# test_calculator.py
class TestCalculator(unittest.TestCase):
# 每个测试方法执行前调用
def setUp(self):
"""初始化测试资源:创建计算器实例(此处模拟,实际可用于数据库连接等)"""
print("===== 执行 setUp(),初始化资源 =====")
self.calc = {
"add": add,
"subtract": subtract,
"divide": divide
}
# 每个测试方法执行后调用
def tearDown(self):
"""清理测试资源:此处无实际操作,仅作示例"""
print("===== 执行 tearDown(),清理资源 =====")
def test_add(self):
self.assertEqual(self.calc["add"](1,2), 3)
def test_subtract(self):
self.assertEqual(self.calc["subtract"](5,3), 2)

执行后输出(可见 setUptearDown 被自动调用):

===== 执行 setUp(),初始化资源 =====
===== 执行 tearDown(),清理资源 =====
.===== 执行 setUp(),初始化资源 =====
===== 执行 tearDown(),清理资源 =====
.
----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK

4.2 setUpClass() 与 tearDownClass():类级资源管理

如果资源只需初始化一次(如启动服务器、创建数据库连接池),可使用类级别的钩子方法(需配合 @classmethod 装饰器):

class TestCalculator(unittest.TestCase):
# 整个测试类执行前调用一次(类级别初始化)
@classmethod
def setUpClass(cls):
print("===== 执行 setUpClass(),初始化类级资源 =====")
cls.db_conn = "模拟数据库连接"  # 示例:共享数据库连接
# 整个测试类执行后调用一次(类级别清理)
@classmethod
def tearDownClass(cls):
print("===== 执行 tearDownClass(),清理类级资源 =====")
cls.db_conn = None  # 关闭连接

4.3 TestSuite:手动组织测试用例

当需要批量执行指定的测试用例(而非全部)时,可通过 TestSuite 手动组合:

# test_suite.py
import unittest
from test_calculator import TestCalculator
def create_suite():
# 1. 创建测试套件
suite = unittest.TestSuite()
# 2. 向套件中添加测试用例(多种方式)
# 方式 1:添加单个测试方法
suite.addTest(TestCalculator("test_add"))
suite.addTest(TestCalculator("test_divide"))
# 方式 2:添加多个测试方法(列表形式)
tests = [TestCalculator("test_subtract"), TestCalculator("test_divide")]
suite.addTests(tests)
return suite
if __name__ == '__main__':
# 3. 创建测试运行器并执行套件
runner = unittest.TextTestRunner(verbosity=2)  # verbosity=2 显示详细日志
runner.run(create_suite())

执行 python test_suite.py,会只运行套件中指定的测试方法。

4.4 自动发现测试用例

如果项目中有多个测试文件(如 test_calculator.pytest_string.py),可通过 unittest.defaultTestLoader.discover() 自动发现并执行所有测试:

# run_all_tests.py
import unittest
if __name__ == '__main__':
# 发现当前目录下所有以 test_ 开头的文件中的测试用例
suite = unittest.defaultTestLoader.discover(
start_dir='.',  # 搜索目录
pattern='test_*.py',  # 测试文件匹配规则
top_level_dir=None  # 顶级目录(默认 None 表示 start_dir)
)
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)

五、unittest 进阶用法

5.1 跳过测试用例(skip 装饰器)

实际开发中,可能需要跳过某些测试(如功能未完成、环境不支持),unittest 提供了 3 个常用装饰器:

装饰器作用示例
@unittest.skip(reason)无条件跳过测试@unittest.skip("功能未完成,暂不测试")
@unittest.skipIf(condition, reason)条件为 True 时跳过@unittest.skipIf(sys.version_info < (3.8), "Python3.8+ 才支持")
@unittest.skipUnless(condition, reason)条件为 False 时跳过@unittest.skipUnless(os.name == "posix", "仅 Linux/Mac 环境测试")
代码示例:
import sys
import unittest
from calculator import add
class TestSkipExample(unittest.TestCase):
@unittest.skip("临时跳过该测试")
def test_add_1(self):
self.assertEqual(add(1,2), 3)
@unittest.skipIf(sys.version_info < (3, 9), "Python3.9+ 才支持该特性")
def test_add_2(self):
self.assertEqual(add(0.1, 0.2), 0.3)
@unittest.skipUnless(sys.platform == "win32", "仅 Windows 环境测试")
def test_add_3(self):
self.assertEqual(add(-1, -1), -2)

执行后,跳过的测试会显示 s 标记:

s.s
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK (skipped=3)

5.2 预期失败(expectedFailure)

如果某个测试用例已知会失败(如已知 bug 未修复),可使用 @unittest.expectedFailure 装饰器,标记为“预期失败”——执行失败时不会计入错误统计:

class TestExpectedFailure(unittest.TestCase):
@unittest.expectedFailure
def test_divide_by_zero(self):
# 已知该用例当前会失败(假设 bug 未修复)
self.assertEqual(divide(5, 0), 0)  # 实际会抛出异常,预期失败

执行后,该用例会显示 x 标记,不会导致测试整体失败。

5.3 参数化测试(结合 ddt 库)

unittest 原生不支持参数化测试(即同一测试逻辑用多组参数重复执行),但可通过第三方库 ddt(Data-Driven Tests)实现。

步骤 1:安装 ddt
pip install ddt
步骤 2:参数化测试示例
# test_parametrize.py
import unittest
from ddt import ddt, data, unpack  # 导入 ddt 相关装饰器
from calculator import add
# 1. 用 @ddt 装饰测试类
@ddt
class TestAddParametrize(unittest.TestCase):
# 2. 用 @data 传入多组测试数据(元组形式)
@data(
(1, 2, 3),    # 输入 1+2,预期输出 3
(-1, -2, -3), # 输入 -1+-2,预期输出 -3
(0.1, 0.2, 0.3),  # 浮点数测试
(100, 200, 300)   # 大数测试
)
# 3. 用 @unpack 解包元组(让参数与测试方法参数一一对应)
@unpack
def test_add_param(self, a, b, expected):
"""参数化测试加法函数"""
self.assertAlmostEqual(add(a, b), expected)
if __name__ == '__main__':
unittest.main()

执行后,会自动为每组数据生成一个测试用例,输出如下:

....
----------------------------------------------------------------------
Ran 4 tests in 0.001s
OK

5.4 生成 HTML 测试报告

unittest 默认生成文本报告,可读性较差。可通过第三方库 unittest-html-reporting 生成美观的 HTML 报告。

步骤 1:安装库
pip install unittest-html-reporting
步骤 2:生成 HTML 报告示例
# test_html_report.py
import unittest
from htmltestreport import HTMLTestReport  # 注意导入方式(不同库可能不同)
if __name__ == '__main__':
# 1. 发现所有测试用例
suite = unittest.defaultTestLoader.discover(start_dir='.', pattern='test_*.py')
# 2. 生成 HTML 报告
with open('test_report.html', 'w', encoding='utf-8') as f:
runner = HTMLTestReport(
stream=f,
title='计算器模块单元测试报告',
description='测试加法、减法、除法函数的正确性',
tester='开发者'
)
runner.run(suite)

执行后,会在当前目录生成 test_report.html 文件,打开后可看到测试通过率、用例详情、失败原因等信息,适合团队协作与汇报。

5.5 测试覆盖率(结合 coverage 库)

测试覆盖率用于统计“被测试代码的行数占总代码行数的比例”,帮助发现未被测试的代码。

步骤 1:安装 coverage
pip install coverage
步骤 2:统计测试覆盖率
# 1. 执行测试并收集覆盖率数据(--source 指定待统计的模块)
coverage run --source=calculator.py -m unittest test_calculator.py
# 2. 查看文本格式的覆盖率报告
coverage report
# 3. 生成 HTML 格式的覆盖率报告(更直观)
coverage html
输出解读(文本报告):
Name             Stmts   Miss  Cover
------------------------------------
calculator.py        8      0   100%
------------------------------------
TOTAL                8      0   100%
  • Stmts:总代码行数;
  • Miss:未被测试覆盖的行数;
  • Cover:覆盖率(100% 表示所有代码都被测试覆盖)。

执行 coverage html 后,会生成 htmlcov 目录,打开 index.html 可查看详细的覆盖率报告(红色表示未覆盖,绿色表示已覆盖)。

六、实战案例:测试一个用户管理模块

我们模拟一个简单的用户管理模块,包含“新增用户”“查询用户”“删除用户”功能,并用 unittest 设计完整的测试用例。

6.1 待测试模块:user_manager.py

# user_manager.py
class UserManager:
def __init__(self):
self.users = {}  # 存储用户:key=用户ID,value=用户名
def add_user(self, user_id, username):
"""新增用户:user_id 已存在则抛出 ValueError"""
if not isinstance(user_id, int) or user_id <= 0:
raise ValueError("用户ID必须是正整数")
if user_id in self.users:
raise ValueError(f"用户ID {user_id} 已存在")
self.users[user_id] = username
return True
def get_user(self, user_id):
"""查询用户:返回用户名,不存在则返回 None"""
return self.users.get(user_id)
def delete_user(self, user_id):
"""删除用户:返回是否删除成功"""
if user_id not in self.users:
return False
del self.users[user_id]
return True

6.2 测试模块:test_user_manager.py

# test_user_manager.py
import unittest
from user_manager import UserManager
class TestUserManager(unittest.TestCase):
def setUp(self):
"""每个测试用例前初始化:创建用户管理器实例"""
self.manager = UserManager()
def test_add_user_success(self):
"""测试新增用户成功"""
result = self.manager.add_user(1, "张三")
self.assertTrue(result)
self.assertEqual(self.manager.get_user(1), "张三")
def test_add_user_duplicate_id(self):
"""测试新增重复用户ID"""
self.manager.add_user(1, "张三")
with self.assertRaises(ValueError) as ctx:
self.manager.add_user(1, "李四")
self.assertEqual(str(ctx.exception), "用户ID 1 已存在")
def test_add_user_invalid_id(self):
"""测试新增用户时传入无效ID(非整数、负数)"""
# 非整数ID
with self.assertRaises(ValueError) as ctx:
self.manager.add_user("a", "张三")
self.assertEqual(str(ctx.exception), "用户ID必须是正整数")
# 负数ID
with self.assertRaises(ValueError) as ctx:
self.manager.add_user(-2, "李四")
self.assertEqual(str(ctx.exception), "用户ID必须是正整数")
def test_get_user_exist(self):
"""测试查询存在的用户"""
self.manager.add_user(2, "李四")
self.assertEqual(self.manager.get_user(2), "李四")
def test_get_user_not_exist(self):
"""测试查询不存在的用户"""
self.assertIsNone(self.manager.get_user(999))
def test_delete_user_success(self):
"""测试删除存在的用户"""
self.manager.add_user(3, "王五")
result = self.manager.delete_user(3)
self.assertTrue(result)
self.assertIsNone(self.manager.get_user(3))
def test_delete_user_not_exist(self):
"""测试删除不存在的用户"""
result = self.manager.delete_user(999)
self.assertFalse(result)
if __name__ == '__main__':
unittest.main(verbosity=2)

6.3 执行测试与查看结果

python test_user_manager.py -v

输出如下(所有测试用例通过):

test_add_user_invalid_id (test_user_manager.TestUserManager)
测试新增用户时传入无效ID(非整数、负数) ... ok
test_add_user_duplicate_id (test_user_manager.TestUserManager)
测试新增重复用户ID ... ok
test_add_user_success (test_user_manager.TestUserManager)
测试新增用户成功 ... ok
test_delete_user_not_exist (test_user_manager.TestUserManager)
测试删除不存在的用户 ... ok
test_delete_user_success (test_user_manager.TestUserManager)
测试删除存在的用户 ... ok
test_get_user_exist (test_user_manager.TestUserManager)
测试查询存在的用户 ... ok
test_get_user_not_exist (test_user_manager.TestUserManager)
测试查询不存在的用户 ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.001s
OK

七、unittest 常见问题与最佳实践

7.1 常见问题

问题 1:测试方法未执行
  • 原因:测试方法未以 test_ 开头(unittest 只识别以 test_ 开头的方法);
  • 解决:将方法名改为 test_xxx 格式(如 test_add 而非 add_test)。
问题 2:测试用例依赖顺序
  • 现象:某个测试方法执行失败,因为它依赖前一个测试方法的执行结果;
  • 原则:测试用例必须独立(互不依赖),每个用例应能单独运行;
  • 解决:使用 setUp() 为每个用例重新初始化资源,避免依赖其他用例的执行结果。
问题 3:浮点数断言失败
  • 现象:assertEqual(0.1+0.2, 0.3) 失败(浮点数精度问题);
  • 解决:使用 assertAlmostEqual(a, b, places=1)assertEqual(round(a+b, 1), 0.3)

7.2 最佳实践

  1. 测试用例设计原则

    • 覆盖核心场景(正常输入、边界值、异常输入);
    • 一个测试方法只验证一个核心逻辑(避免“大而全”的测试);
    • 测试方法命名清晰(如 test_add_user_duplicate_id 而非 test_add_2)。
  2. 代码组织

    • 测试文件与源码文件分离(如 src/ 存源码,tests/ 存测试文件);
    • 测试文件名与源码文件名对应(如 calculator.pytest_calculator.py)。
  3. 自动化集成

    • 将单元测试集成到 CI/CD 流程(如 GitHub Actions、Jenkins),每次提交代码自动执行测试;
    • 要求测试覆盖率达到一定阈值(如 80%),避免未测试代码上线。
  4. 避免过度测试

    • 不测试第三方库或标准库的功能;
    • 不测试实现细节(只测试接口行为,如函数输入输出,而非内部变量)。

八、unittest 与 pytest 对比

unittest 是 Python 内置框架,稳定可靠,但语法较繁琐(需继承类、方法名固定);而 pytest 是第三方框架,语法更简洁(无需继承类、支持函数式测试),且兼容 unittest 用例。

特性unittestpytest
安装内置,无需安装pip install pytest
语法必须继承 TestCase,方法名以 test_ 开头支持函数式测试、类测试,更灵活
断言只能用 self.assertEqual() 等方法支持原生 == 断言,也兼容 unittest 断言
参数化需第三方库(ddt)内置 @pytest.mark.parametrize
插件生态较少丰富(如 pytest-html、pytest-cov)

如果是新手入门或开发标准库项目,unittest 足够使用;如果追求更高效率和灵活性,可尝试 pytest(兼容已有 unittest 用例,迁移成本低)。

若需进一步深入,可参考 Python 官方文档:unittest — 单元测试框架

posted @ 2025-12-21 11:42  yangykaifa  阅读(19)  评论(0)    收藏  举报