ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python unitttest单元测试实战:从核心概念到工程实践

Python unitttest单元测试实战:从核心概念到工程实践 写代码这么多年我越来越觉得单元测试不是“额外的工作量”而是给代码上的“安全保险”。尤其是用 Python 写业务逻辑动态语言太灵活今天改一个工具函数明天动一个接口返回结构稍不留神就把线上搞挂了。而 unittest 作为 Python 标准库自带的测试框架虽然看起来朴素但架不住它稳定、零依赖、和官方生态深度绑定。这篇文章我就想把我在实际项目里用 unittest 做单元测试的完整经验梳理一遍从核心概念到实战技巧从踩坑实录到覆盖率分析尽量让刚入门的朋友也能照着上手让已经写过一些测试的同行也能有点新收获。这个内容适合谁看刚学完 Python 基础语法想知道怎么给代码写测试的初学者写业务代码多年但几乎没写过自动化测试、想补齐这块短板的开发团队想建立单元测试规范准备从 unittest 起步做技术选型的朋友先说明一点这篇文章讨论的是标准的unittest库不涉及 pytest 的高级插件生态但我会在文末专门说说两者怎么选。后面所有代码都在 Python 3.8 环境里验证过操作系统用 Windows 和 Linux 都试了没遇到兼容问题。1. 为什么说单元测试是代码的“安全网”很多刚接触测试的人会问“我代码写得没问题为什么要花时间写测试”这个问题我听了太多次。你当然可以靠打印日志和print排查问题但那只在代码量小的时候有效。项目一复杂函数之间互相调用改一个底层模块可能影响五六个上层功能。这时候如果没有自动化测试兜底你根本不知道哪次改动把某个角落的逻辑给改坏了。单元测试的本质是把代码拆成最小的可验证单元针对每个单元编写自动化验证脚本确保它的行为符合预期。所谓“最小单元”大多数时候指的是函数或类的方法。在 Python 里更精准一点描述——一个不依赖外部 IO数据库、网络、文件系统的纯逻辑方法是最理想的测试单元。1.1 单元测试解决的核心问题回归保护改完代码后一键执行所有测试挂掉的地方会立刻暴露不用靠人工手工回归。设计约束为了“可测试”你不得不把大函数拆小、把依赖注入进去这天然会倒逼你写出高内聚低耦合的代码。文档注释写好的测试用例本身就是活的文档别人看你的测试能快速知道每个函数该传什么、该返回什么。定位效率测试失败会直接告诉你哪个用例哪一行挂了比线上报错后再去翻日志高效得多。有人会问“业务代码太多了每个函数都写测试成本会不会太高”这里我分享一个判断标准写过一次就基本不会变的配置代码、初始化代码可以策略性放弃凡是涉及计算、判断、条件分支、数据转换的地方尽量覆盖。你用这个标准筛一遍项目会发现值得测的代码比想象中多但并没有多到写不完的地步。1.2 什么时候写单元测试写新功能时推荐先写测试再写实现的“测试先行”方式。这个习惯初期会觉得别扭坚持两周后效率反而更高因为测试把需求边界定义清楚了。修 bug 时先写一个能复现 bug 的失败测试再去修代码。修完再跑测试测试通过了说明 bug 真的被你修掉了。这个操作用途极大。重构时先用测试把老代码的行为锁住然后再动手改实现。我不敢说每次重构都绝对安全但有了这层网至少可以在重构后快速发现行为变化。这套逻辑不是我编的是很多工程团队验证多年的方法论。你可能不完全认同“测试先行”但至少要有“测试后补”的行动。最怕的是压根不写等项目做大了才后悔。2. unittest 的核心概念与运行机制unittest是 Python 从 2.1 版本就开始内置的测试框架它的设计灵感来自 Java 的 JUnit所以如果你以前接触过 Java 测试看 unittest 会觉得很亲切。整个框架围绕四个核心概念展开TestCase、TestSuite、TestRunner、TestFixture。2.1 四个核心组件TestCase测试用例这是最核心的类你写的所有测试都要继承它。一个TestCase子类里以test_开头的方法就是一条测试用例。unittest在运行时会把每个test_方法当成独立用例去执行。TestSuite测试套件用来把多个测试用例聚合在一起的容器。默认情况下unittest会通过测试发现机制Test Discovery自动收集测试用例但你也可以手动创建TestSuite按自己的顺序去加载用例。这个方法在需要自定义执行顺序时非常有用。TestRunner测试执行器负责执行测试用例并输出结果。标准库提供了TextTestRunner默认输出到控制台。你可以通过继承它来定制报告的显示格式甚至可以把它接到 CI 系统里。TestFixture测试夹具这个名词不太好理解我一般把它翻译成“测试脚手架”。它用来在测试前后准备资源和清理资源对应到代码层就是setUp()和tearDown()方法。每个测试方法执行前setUp都会被执行一遍每个测试方法结束后tearDown都会被执行一遍。这样保证了每条用例之间的隔离性。把四个组件串起来的执行过程是TestRunner拿到TestSuite里的TestCase逐个执行执行每条用例前先跑setUp做准备工作用例结束后跑tearDown做清理。整个过程可以用一个生活的类比来理解你把一批测试用例想象成一堆需要加工的水果TestSuite是装水果的篮子TestCase是每颗水果setUp和tearDown是清洗和收纳操作台TestRunner就是那个负责按流程操作的工人。2.2 断言方法测试的语言测试用例之所以能“判断对错”全靠断言方法。unittest里有一整套assert*方法我挑几个高频的讲断言方法作用assertEqual(a, b)判断 a 和 b 是否相等assertNotEqual(a, b)判断 a 和 b 是否不相等assertTrue(x)/assertFalse(x)判断 x 是否为真/假assertIsNone(x)/assertIsNotNone(x)判断 x 是否为 NoneassertIn(a, b)判断 a 是否在 b 中assertRaises(SomeException)判断代码块是否抛出指定异常assertAlmostEqual(a, b, places7)浮点数近似相等指定精确到小数点后几位assertIsInstance(obj, cls)判断对象类型有个新手常犯的错误是用assertEqual(a, b)判断浮点数时因为浮点精度问题导致测试不稳定。比如0.1 0.2 0.3在 Python 里是False如果你直接断言相等测试必挂。这时候必须用assertAlmostEqual或者自己指定一个误差范围。这就是为什么assertAlmostEqual这个看起来不起眼的断言方法在实际项目中用得非常频繁。2.3 测试夹具setUp 与 tearDown 的妙用我在真实项目中写过大量测试深刻地体会到setUp和tearDown的分工价值。这两种方法把“测试准备”和“测试清理”从用例逻辑里剥离出来避免每条用例里重复写初始化的样板代码。import unittest class TestDatabase(unittest.TestCase): def setUp(self): # 模拟一个临时数据库连接 self.conn create_mock_connection() self.conn.connect() self.conn.create_tables() def tearDown(self): # 释放资源清空数据表 self.conn.drop_tables() self.conn.close() def test_insert(self): self.conn.insert({name: 张三}) self.assertEqual(self.conn.count(), 1) def test_query(self): self.conn.insert({name: 李四}) result self.conn.query(name李四) self.assertEqual(result[name], 李四)注意setUp和tearDown在每条测试执行前后都会被调用。也就是说上面这个类里两个测试方法setUp会被执行两次每次都是全新的数据环境。这样做的好处是每条用例之间完全隔离不会因为上一个用例残留的数据而影响下一个用例的判断。除了实例级的setUp/tearDown还有类级的方法setUpClass和tearDownClass它们只在类中所有用例执行前和执行后各调用一次。如果需要创建整个测试类共用的重量级资源比如初始化一个耗时比较长的模拟服务就用类级方法。注意setUpClass是类方法需要加classmethod装饰器。3. 从零到一写出你的第一个单元测试光看理论太干巴了现在我就带大家动手写一个完整的实操案例。假设我们要开发一个简单的购物车计算模块支持添加商品、计算总价、应用折扣。我把整个文件结构、实现代码、测试代码都列出来你可以直接照着操作。3.1 项目结构设计shopping_cart/ ├── cart.py └── tests/ └── test_cart.py建议项目里把测试代码独立放在tests/目录下不要和业务代码混在一起。这样的结构在工程上比较清晰也方便后面用unittest discover自动寻找测试。3.2 被测试的模块 cart.pyclass Product: def __init__(self, name, price): self.name name self.price price class ShoppingCart: def __init__(self): self.items {} def add_item(self, product, quantity1): if quantity 0: raise ValueError(quantity must be positive) if product.name in self.items: self.items[product.name][quantity] quantity else: self.items[product.name] {product: product, quantity: quantity} def remove_item(self, product_name): if product_name not in self.items: raise KeyError(f{product_name} not in cart) del self.items[product_name] def get_total_price(self): total 0 for item in self.items.values(): total item[product].price * item[quantity] return total def apply_discount(self, discount_rate): if not 0 discount_rate 1: raise ValueError(discount_rate must be between 0 and 1) total self.get_total_price() return total * (1 - discount_rate)这段代码的逻辑不算复杂但里面藏着好几个需要测试的场景正常添加商品、添加重复商品时数量合并、移除不存在的商品要抛异常、折扣率的边界值处理等等。如果没有测试这些边界情况很容易在后续改动中被忽略。3.3 编写测试用例 test_cart.pyimport unittest from cart import Product, ShoppingCart class TestShoppingCart(unittest.TestCase): def setUp(self): self.cart ShoppingCart() self.apple Product(apple, 5.0) self.banana Product(banana, 3.0) def test_add_item_new_product(self): self.cart.add_item(self.apple, 2) self.assertEqual(self.cart.items[apple][quantity], 2) def test_add_item_duplicate_product_merges_quantity(self): self.cart.add_item(self.apple, 1) self.cart.add_item(self.apple, 3) self.assertEqual(self.cart.items[apple][quantity], 4) def test_add_item_invalid_quantity_raises_value_error(self): with self.assertRaises(ValueError): self.cart.add_item(self.apple, 0) def test_remove_item_exists(self): self.cart.add_item(self.banana, 1) self.cart.remove_item(banana) self.assertNotIn(banana, self.cart.items) def test_remove_item_not_exists_raises_key_error(self): with self.assertRaises(KeyError): self.cart.remove_item(nonexistent) def test_get_total_price_mix_items(self): self.cart.add_item(self.apple, 2) self.cart.add_item(self.banana, 3) self.assertEqual(self.cart.get_total_price(), 2 * 5.0 3 * 3.0) def test_apply_discount_normal(self): self.cart.add_item(self.apple, 2) self.assertEqual(self.cart.apply_discount(0.1), 9.0) def test_apply_discount_zero_and_one_boundary(self): self.cart.add_item(self.apple, 2) self.assertEqual(self.cart.apply_discount(0), 10.0) self.assertEqual(self.cart.apply_discount(1), 0.0) def test_apply_discount_invalid_rate_raises_value_error(self): self.cart.add_item(self.apple, 1) with self.assertRaises(ValueError): self.cart.apply_discount(1.5) with self.assertRaises(ValueError): self.cart.apply_discount(-0.1) if __name__ __main__: unittest.main()在这个测试文件里我特别注意了几个细节setUp里统一定义了 cart 和两个测试商品所有用例可以复用减少重复代码。每个test_方法只测试一个行为比如专门测“添加新商品”、专门测“重复商品合并数量”。如果一个测试里塞太多断言某个断言挂了你要花时间去猜到底是哪一环出了问题。测试方法命名用描述性语言test_add_item_invalid_quantity_raises_value_error这种名字虽然长但一眼就能看出这个用例在验证什么行为、预期得到什么结果。3.4 执行测试的三种方式写完之后怎么跑测试我用的最多的有三种方式在命令行里直接指定文件执行python -m unittest tests/test_cart.py使用测试发现机制自动搜索整个项目里所有符合test*.py模式的测试文件python -m unittest discover -s tests -p test*.py-s指定测试文件所在目录-p指定文件匹配模式。这种方式非常省事新增测试文件后不用手动修改执行命令。在文件内运行unittest.main()适合直接调试单文件python tests/test_cart.py执行上面任意一条命令控制台会输出类似这样的结果......... ---------------------------------------------------------------------- Ran 9 tests in 0.002s OK每一行输出里的点号代表一个通过的测试。如果测试失败点号会变成F出错会变成E同时会在底部打印详细的失败信息和堆栈。OK表示全部通过。4. Mock 技术给代码“装个替身”写测试时最难处理的一类场景是被测函数根本没法在单元测试环境里正常运行比如它要发 HTTP 请求、要连数据库、要读取当前时间。对于这种情况正确的做法不是去搭建真实环境而是用 Mock 技术——用假对象替换真实依赖让被测函数能跑起来同时你可以精确控制“环境变量”的值。4.1 为什么要用 Mock我举个实际例子。假设我有一个get_user_info函数它会请求远程 API 拿用户信息import requests def get_user_info(user_id): resp requests.get(fhttps://api.example.com/users/{user_id}) if resp.status_code ! 200: raise RuntimeError(API request failed) data resp.json() return {name: data[name], email: data[email]}如果你在单元测试里真的去请求这个 API会带来三个问题测试结果依赖网络环境网络一不稳定测试就挂。真实的 API 服务不是每次调用都返回相同的数据测试不稳定。过度消耗外部服务资源可能还会被限流。用 Mock 之后你在测试中把requests.get替换成一个假对象预先设置好它返回的状态码和 JSON 内容就可以专心验证业务逻辑了根本不用管真实网络环境。4.2 unittest.mock 基础用法unittest.mock是 Python 3.3 之后内置的模块主要用MagicMock和patch两个利器来实现 Mock。import unittest from unittest.mock import patch, MagicMock from myapi import get_user_info class TestGetUserInfo(unittest.TestCase): patch(myapi.requests.get) def test_get_user_info_success(self, mock_get): fake_resp MagicMock() fake_resp.status_code 200 fake_resp.json.return_value {name: Alice, email: aliceexample.com} mock_get.return_value fake_resp result get_user_info(123) mock_get.assert_called_once_with(https://api.example.com/users/123) self.assertEqual(result[name], Alice) self.assertEqual(result[email], aliceexample.com)拆开解释一下patch(myapi.requests.get)这个装饰器会把myapi模块里的requests.get替换成一个MagicMock。装饰器的参数myapi.requests.get是完整路径注意不是requests.get因为你要 patch 的是被测模块里实际引用的位置。fake_resp.status_code 200是给 stub 设置一个属性值。fake_resp.json.return_value {...}是设置该方法的返回值。MagicMock的一个特点是你调用任何方法、设置任何属性都不会报错全都被“魔法”地接收下来。mock_get.assert_called_once_with(...)断言 Mock 被调用了一次且参数正确这是一条非常有用的校验逻辑能确保你没有传错参数。4.3 Mock 的常见陷阱Mock 虽然好用但我也踩过不少坑。最大的坑就是过度 Mock。如果你把被测函数内部所有的依赖都 Mock 掉了最后测试变成纯粹验证“Mock 有没有被调用”这跟被测代码的逻辑几乎没有任何关系测试的价值基本为零。比如你 Mock 掉了requests.get、Mock 掉了json.loads、Mock 掉了返回值的处理那这个测试其实啥也没验证。判断 Mock 是否过度有个简单的标准Mock 应该只“替换外部依赖”不应该“替换核心逻辑”。能 Mock 掉数据库连接对象但不要 Mock 掉calculate_discount函数本身的内部计算。核心计算逻辑必须用真实代码来跑否则你的测试没有意义。另一个坑是没注意 patch 的路径。新手经常直接patch(requests.get)但被测模块里写的是from requests import get或者import requests两种写法 patch 路径完全不同。记住一个铁律永远 patch 被测模块里引用的名字而不是定义实体的原始位置。5. 提高测试质量的几个关键工具写完基础单元测试之后接下来该做的是提升测试的质量和产出效率。这个环节我常配三个“斜杠工具”参数化测试、测试覆盖率coverage、以及把测试接入持续集成CI流程。5.1 参数化测试一份用例跑多组数据上面购物车例子里的测试很多时候参数的取值是有限的几组边界值。如果每组值我都要写一个test_方法代码会很冗余。unittest标准库用subTest来支持参数化import unittest class TestDiscountBoundary(unittest.TestCase): def test_apply_discount_valid_rate(self): for rate in [0, 0.1, 0.5, 0.99, 1]: with self.subTest(raterate): self.assertTrue(0 rate 1)subTest的好处是某个参数组合失败时其他组合的测试不会被终止报告里会精确定位到哪组参数出错了。subTest是标准库自带的功能如果你不排斥第三方库也可以直接用parameterized或pytest的parametrize用法类似但更简洁。我在标准 unittest 环境里更倾向于用subTest少一个依赖就少一份环境问题。5.2 测试覆盖率别让漏测成为盲区覆盖率是衡量测试“测了多少代码”的指标一般用coverage.py这个工具来统计。安装方式很简单pip install coverage跑覆盖率的基本姿势是coverage run -m unittest discover -s tests coverage report -m第二条命令会输出一个表格显示每个模块的覆盖率百分比。如果你觉得终端表格不够直观还可以生成 HTML 报告coverage html打开生成的htmlcov/index.html你能看到哪些行被标红、没被执行会清晰地暴露你测试的盲区。我说说我的建议不要把覆盖率当成 KPI 来追别盲目追求 100%。很多团队为了凑 100% 覆盖率写了一堆没有实际保护的测试纯属自欺欺人。更合理的做法是核心业务模块覆盖率不低于 80%关键的判断分支必测。在此基础上用覆盖率报告寻找那些逻辑复杂但完全没测到的地方。5.3 接入 CI让测试自动跑起来本地手动跑测试还不够因为人总会偷懒。最稳妥的做法是把测试接入持续集成CI流程每次提交代码、合并请求时自动跑全部测试。我常用的是 GitHub Actions 的简单配置核心步骤就三步name: Python Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - uses: actions/setup-pythonv4 with: python-version: 3.11 - run: pip install -r requirements.txt - run: python -m unittest discover -s tests这样配置好之后每次 push 代码或者提 PR云端都会自动跑测。一旦测试红掉说明你或你的同事把某些功能改坏了不用等到生产环境出事才暴露。6. 常见问题与排查技巧实录写测试的时间久了积累了不少“看起来很怪但特常见”的问题。这个章节我按自己的经验整理出来很多是踩坑踩出来的结论。6.1 测试互相影响用例之间不隔离这个问题我在初学阶段经常遇到。现象是单个文件单独跑全绿整个测试套件一起跑就挂。根源多半是测试之间共享了某些状态最常见的就是操作了全局变量、环境变量、数据库表。解决办法用setUp和tearDown把每个用例的前后状态都“重置”干净。如果测试操作了环境变量你可以在setUp里保存旧值在tearDown里恢复旧值。如果测试操作了文件系统把创建的文件在tearDown里删掉。import os import unittest class TestEnvVar(unittest.TestCase): def setUp(self): self.old_value os.environ.get(MY_FLAG) def tearDown(self): if self.old_value is None: os.environ.pop(MY_FLAG, None) else: os.environ[MY_FLAG] self.old_value def test_something(self): os.environ[MY_FLAG] true # 你的测试逻辑6.2 浮点数精度问题前面提到过0.1 0.2 ! 0.3在单元测试里这绝对是个“高频刺客”。我见过很多新人写断言时直接self.assertEqual(0.1 0.2, 0.3)然后怎么跑都是AssertionError。记住涉及小数计算的场景一律用assertAlmostEqual或者明确写误差范围self.assertAlmostEqual(0.1 0.2, 0.3, places7)places7表示保留到小数点后 7 位进行比较。如果涉及金额计算也可以把单位从元换算成“分”整数避免浮点数参与计算这是金融领域常见的做法。6.3 测试方法命名必须是 test_ 开头吗默认情况下只有以test开头的方法才会被识别为测试用例。严格来说默认匹配规则是test*也就是说test_user、test_get_user_info都可以但check_user不会被执行。很多人因为命名不规范自以为写了测试运行的时候少了几个用例都不知道。如果确实想自定义测试方法名的前缀可以通过指定测试加载器的testMethodPrefix参数来实现但我不建议这么做默认的test_已经是整个社区最通用的规范没必要特殊化。6.4 相对路径问题测试里经常需要读取测试数据文件比如 JSON 配置、CSV 数据等。如果直接用相对路径会受“当前工作目录”影响。在命令行里从项目根目录跑和从 tests 目录跑同一个测试的执行结果可能不一样因为相对路径的基准点不同。最稳妥的写法是用os.path.dirname(__file__)获取当前测试文件所在目录然后拼出绝对路径import os import unittest TEST_DATA_DIR os.path.join(os.path.dirname(__file__), data) class TestReadData(unittest.TestCase): def test_load_data(self): data_path os.path.join(TEST_DATA_DIR, sample.json) with open(data_path, encodingutf-8) as f: data f.read() self.assertTrue(len(data) 0)这样无论你从哪里执行测试找到的文件都是同一个不会因为执行目录变了就找不到资源。6.5 中文编码问题写测试断言中文字符串或者在 Windows 命令行下跑测试偶尔会遇到编码问题比如UnicodeEncodeError: gbk codec cant encode character。解决方式很简单在测试文件顶部加一行编码声明或者在整个脚本运行时加环境变量PYTHONIOENCODINGutf-8 python -m unittest tests/test_cart.py另外在实际的项目代码里读取文件时也尽量显式指定encodingutf-8。Python 3 虽然默认源码是 UTF-8但文件读取操作在 Windows 上默认编码并不是 UTF-8所以很多中文乱码问题就是这么来的。6.6 测试执行随机顺序的问题unittest的执行顺序默认是按照测试方法名排序的而不是文件里编写的顺序。所以你不能依赖测试用例之间的执行先后关系。比如你写了一个测试给db插了数据然后另一个测试直接使用这个数据这种测试顺序强依赖的做法非常危险。只要测试方法名一变动顺序就变了测试就挂了。正确的做法依然是每个用例都通过setUp准备自己的数据不要依赖其他用例的执行结果。7. 谈谈 unittest 和 pytest 怎么选这个问题几乎每次技术分享都会被人问。pytest现在的社区热度确实比 unittest 高插件生态也丰富但这不意味着 unittest 就过时了。我的使用体感是这样的如果你的项目是纯标准库环境不希望引入任何额外依赖unittest 是最稳妥的选择因为它是官方标准库零安装成本。如果你的团队已经用了 pytest那也没必要非要转回 unittest两者都能完成单元测试的目标。如果要做一个大型项目并依赖非常丰富的测试插件比如 pytest-xdist 分布式执行、pytest-cov 覆盖率、pytest-mockpytest 用起来确实更顺手。事实上pytest 也支持直接运行 unittest 写的测试用例它内部做了一个兼容层能把unittest.TestCase的测试自动收集起来。所以如果你已经在用 unittest后面想平滑迁移到 pytest代价并不大同一个文件可以直接被 pytest 执行。这也是我把 unittest 放在优先推荐位置的原因入门成本低后期转型也不受限。我在实际项目里的做法是中小型工具库、脚本类项目直接上 unittest大型业务系统、需要大量集成测试和插件支持的场景用 pytest。技术选型没有绝对的好坏只有合不合适。8. 最后的实践心得回到开头的观点单元测试最直接的价值是让你“敢改代码”。我在一个业务系统里维护过一份特别复杂的订单计算逻辑没有测试之前每次改动都要叫上测试同事手工回归一遍耗时又费神。后来花了半天时间把订单拆成几个函数逐个补上 unittest 测试之后改动计算规则就变成了“改代码 跑测试 看结果”三个简单动作。写 unittest 这件事最忌讳的就是追求“量大管饱”。我见过一些项目里测试文件数量很多但每个用例都是抄来抄去的模板断言写得很空洞跑起来全绿但什么都不验证。那样的测试不仅没用还会给人一种虚假的安全感反而更危险。如果你现在项目里一行测试都没有别焦虑从下一个改动的函数开始先给它补一个测试。哪怕只是一个简单的入参校验测试也比没有强。积累是一个滚雪球的过程测试多了你写代码的胆量和效率也都会跟着涨。到那时候你回头看大概率会感谢早早就开始写测试的自己。
返回列表