☰
Python Unittest单元测试实战:给代码上保险的完整指南
2026/10/4 13:21:11 网站建设 项目流程

做了快十年的Python开发,要说哪件事能让我“晚上睡觉都踏实”,单元测试绝对排前三。这两天在写99天精通Python的连载,到今天刚好第34天,主题就是Python自带的单元测试框架Unittest。标题里那句“给代码上个保险”我觉得特别贴切——测试这玩意就是代码的意外险,平时可能看不见收益,一旦出险,它能帮你少掉几层皮。

这篇文章我会从一个真实的项目角度把Unittest讲透,包括为什么写测试、怎么写、怎么组织、常见坑怎么排。无论你是刚入门Python的初学者,还是写过一阵子脚本但还没系统接触过测试的人,这篇都能直接用。

1. 为什么要给代码上“保险”

1.1 没有测试的代码,就像没系安全带的驾驶

先说个我自己的经历。早几年在一家小公司,我负责一个数据清洗脚本,每天凌晨定时跑,处理几万行Excel然后导入数据库。功能很简单,我也写得很自信,直到某天客户反馈某批数据金额翻了一倍。我打开代码盯了半个小时,最后发现是一个if分支里少写了取反操作,而这个分支只在某个特定条件下才会触发。因为没有测试,这个错误静默运行了整整三周。

后来我把那个脚本补上了几个Unittest用例,核心逻辑覆盖几十个边界条件。从那以后,每次改那个脚本,跑一遍测试,几秒钟就知道改挂了什么。那种安全感,不是靠“我这次一定仔细”能换来的,人脑在复杂逻辑面前真的不靠谱。

单元测试的本质,就是把“代码逻辑是否符合预期”这件事从凭感觉变成可自动验证。你写一个函数,顺手写几个断言,之后每次改动代码、每次重构、每次升级依赖,都能快速验证旧功能没被破坏。这就是回归测试的价值。

1.2 单元测试到底测什么

很多人一开始把单元测试想得太复杂,其实核心就一句话:对一个函数或一个类的方法,输入已知数据,检查输出是否符合预期。它不是端到端测试,不需要启动整个系统、不需要连数据库、不需要真实调用第三方接口,就是对着最小代码单元进行验证。

举个例子,如果你写了一个calculate_discount(price, level)函数,单元测试要验证的就是:

  • 普通用户无折扣;
  • 会员打九折;
  • 价格传负数时是否抛异常;
  • 折扣率精度是否正确;
  • level传空字符串时是否返回默认行为。

把这些情况写成一个个测试方法,之后任何人都可以放心改函数内部实现,只要测试全绿,就说明对外行为没变。这比写一大段注释“请不要改动本函数逻辑”有用得多。

当然,单元测试也不是万能的。它测不了UI交互、测不了跨系统集成、也测不了性能问题。它能保证的是“地基稳”,至于装修好不好看,那是另一码事。

2. 环境准备与Unittest基础用法

2.1 一套干净的Python环境

先说环境。如果你还没装好Python,先别急着写测试,把解释器装好。我这里用的版本是Python 3.11,但Unittest从Python 2.1时代就存在了,所以3.x任何版本都没问题。比较推荐的方式是用venv建一个独立环境:

python -m venv .venv source .venv/bin/activate # Windows下执行 .venv\Scripts\activate

然后用pip install pytest装一下pytest?不,这节我们只讲标准库Unittest,不需要任何第三方依赖。装好Python就等于装好了unittest,这也是它最大的优势之一。

在IDE方面,PyCharm和VS Code都内置了测试运行器。VS Code里装好Python扩展后,左侧栏会出现一个“测试”图标,可以自动发现并运行所有测试方法。不过为了理解运行机制,我建议你至少先用命令行跑一次:

python -m unittest test_module.py -v

这里-v的意思是verbose,也就是详细输出,会列出每个用例执行结果和耗时。个人习惯是开发阶段必须-v,看到每个用例的明细心里才有底。

2.2 第一个测试用例:三步上手

Unittest的用法非常固定,就三步:写一个继承unittest.TestCase的类,类里定义test_开头的方法,方法里用self.assertXxx做断言。

我写一个最简单的例子,目标是测试下面这个字符串处理函数:

def split_words(text): """把一句话按空格和逗号拆成单词列表""" if not isinstance(text, str): raise TypeError("text 必须是字符串") text = text.replace(",", " ") return [w for w in text.split() if w]

接下来写测试:

import unittest from my_module import split_words class TestSplitWords(unittest.TestCase): def test_normal_text(self): result = split_words("hello world, python") self.assertEqual(result, ["hello", "world", "python"]) def test_empty_string(self): result = split_words("") self.assertEqual(result, []) def test_commas_and_spaces_mixed(self): result = split_words("a,,b, c") self.assertEqual(result, ["a", "b", "c"]) def test_non_string_raises(self): with self.assertRaises(TypeError): split_words(123) if __name__ == "__main__": unittest.main()

运行后输出大概是这样:

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

四个点代表四个测试全部通过。如果有失败,那个点会变成F,错误会显示E。看到这个结果,我就可以说split_words这函数在当前预期场景下是安全的。

在这里顺便说一个新手最容易犯的错:测试方法一定要以test_开头,否则Unittest不会执行它。我见过有人写了个check_normal_text方法,怎么看逻辑都对,但就是不跑,沮丧了半天,原因就是名字里没有test前缀。

3. 常用断言与测试的组织方式

3.1 一套够用的断言方法

Unittest提供了非常丰富的断言方法,不用全部记住,但下面这张表我建议你烂熟于心:

断言方法检查内容
assertEqual(a, b)a == b
assertNotEqual(a, b)a != b
assertTrue(x)x为真
assertFalse(x)x为假
assertIsNone(x)x是 None
assertIn(item, container)item 在容器中
assertNotIn(item, container)item 不在容器中
assertAlmostEqual(a, b)浮点数近似相等
assertRaises(Exc, func, *args)调用时抛出指定异常
assertIsInstance(obj, cls)obj 是 cls 的实例
assertGreater(a, b)a 大于 b
assertRegex(text, regex)text 匹配正则

这里我重点强调两个坑。第一个是assertEqual对列表的对比没问题,但对dict的顺序不敏感,这通常是我们想要的。第二个是浮点数比较,直接assertEqual(0.1 + 0.2, 0.3)必挂,因为浮点数精度问题,此时必须用assertAlmostEqual(0.1 + 0.2, 0.3, places=7)。这个坑几乎每个数值计算的项目都会踩,提前记住能省不少时间。

另外如果你想一次验证多个条件,用assertMultiLineEqual对比大段文本,或者用assertCountEqual对比两个序列的元素是否相同且数量一致,都比较实用。

3.2 setUp与tearDown:每个用例的公共初始化

写测试时候,你会发现很多用例都需要先创建同一个对象、准备同一批数据。比如你测试一个订单类,几乎每个用例都要先来一句order = Order(...)。把这些公共操作放到setUp方法里,Unittest会保证每个测试方法执行前都先执行一次setUp。

class TestOrder(unittest.TestCase): def setUp(self): self.order = Order(order_id="A001", items=[ {"name": "书", "price": 50.0, "count": 2}, {"name": "笔", "price": 5.0, "count": 10}, ]) def test_total_amount(self): self.assertEqual(self.order.total(), 150.0) def test_add_item_updates_total(self): self.order.add_item({"name": "本子", "price": 10.0, "count": 1}) self.assertEqual(self.order.total(), 160.0)

之所以强调“每个用例都执行一次”,是因为测试之间不能相互影响。如果setUp只在最开始执行一次,那第二个用例看到的可能已经被第一个用例修改过的状态,结果就很难排查。每次创建全新对象,才是真正的隔离。

tearDown则负责清理资源,适合用来关闭文件、断开网络连接、删除临时文件。它的调用时机是在每个测试方法结束后。对于绝大多数纯逻辑测试,tearDown可有可无,但如果你的用例里面真的打开了文件或连接了数据库,一定要在tearDown里关掉,否则测试越多,文件描述符泄漏越严重,最后整个测试进程都会崩掉。

我在实际项目中还会在setUp里设置一些日志开关,比如把日志级别调高,避免测试时刷屏影响看结果。这块属于个人习惯,但确实能显著提升调试体验。

3.3 测试套件:把多个模块的测试集中起来

当测试文件变多以后,每次都python -m unittest test_a.py再python -m unittest test_b.py就太蠢了。Unittest提供了测试发现机制,在项目根目录执行一条命令就可以自动找到所有测试:

python -m unittest discover -s tests -p "test_*.py"

这个命令表示:在tests目录下寻找所有以test_开头的文件,逐个加载其中TestCase子类的测试方法并执行。前提是这些测试文件可以被导入,也就是说tests目录下要有__init__.py文件,或者测试文件里的import路径能正确解析。

如果你有需要特殊组合的用例,可以用TestSuite手动组装:

suite = unittest.TestSuite() suite.addTest(TestSplitWords("test_normal_text")) suite.addTest(TestOrder("test_total_amount")) unittest.TextTestRunner().run(suite)

这套写法的优势是可以定义多个测试等级,比如“冒烟测试集”“核心回归集”“全量测试集”。我在维护一个比较老的金融计算模块时,就是这样把最核心的几十个用例抽出来单独跑,每次发布前先跑冒烟集,全绿再跑全量,速度快且不焦虑。

4. 实战案例:给一个下单折扣模块写测试

4.1 需求与代码设计

介绍完了基础用法,这里我用一个贴近业务场景的例子串一遍。假设后端有一个根据用户会员等级和订单金额计算实付金额的模块,需求大概是这样:

  • 订单满100元,普通用户打95折;
  • 会员用户打9折;
  • 超级会员打85折;
  • 折扣后金额保留两位小数;
  • 订单金额必须是正数,否则抛ValueError;
  • 会员等级不合法时,当作普通用户处理。

先写出业务代码:

class DiscountService: DISCOUNT_RULES = { "normal": 0.95, "member": 0.90, "super_member": 0.85, } def calculate(self, amount: float, level: str) -> float: if not isinstance(amount, (int, float)) or amount <= 0: raise ValueError("订单金额必须为正数") rate = self.DISCOUNT_RULES.get(level, 0.95) final_amount = amount * rate return round(final_amount, 2)

然后针对这个DiscountService写测试用例。这里有几个值得注意的测试点:正常打折、折扣精度、异常输入、非法等级、边界金额。

4.2 完整测试代码与解析

import unittest from discount_service import DiscountService class TestDiscountService(unittest.TestCase): def setUp(self): self.service = DiscountService() def test_normal_user_with_300(self): result = self.service.calculate(300, "normal") self.assertEqual(result, 285.00) def test_member_with_100(self): result = self.service.calculate(100, "member") self.assertEqual(result, 90.00) def test_super_member_with_1000(self): result = self.service.calculate(1000, "super_member") self.assertEqual(result, 850.00) def test_round_to_two_decimal(self): result = self.service.calculate(99.9, "member") self.assertEqual(result, 89.91) def test_invalid_level_treated_as_normal(self): result = self.service.calculate(200, "vip_unknown") self.assertEqual(result, 190.00) def test_zero_amount_raises(self): with self.assertRaises(ValueError): self.service.calculate(0, "member") def test_negative_amount_raises(self): with self.assertRaises(ValueError): self.service.calculate(-5, "member") def test_string_amount_raises(self): with self.assertRaises(ValueError): self.service.calculate("100", "member") if __name__ == "__main__": unittest.main()

这段代码里我特意把test_round_to_two_decimal这案例放进来,是为了强调精度陷阱。99.9 * 0.9 = 89.91,直接等于看起来没问题,但如果你算出89.91000000000001的时候就要用assertAlmostEqual。这里用了round之后刚好能得到89.91,所以assertEqual能过。这提醒我们:业务代码里如果需要固定小数位,round要谨慎用,因为Python的round是银行家舍入规则,建议在保留两位小数的场景改用Decimal。

4.3 测试驱动的小习惯

我写代码的习惯是,函数写完后第一件事不是立刻集成联调,而是花几分钟把上面的测试用例敲出来。可能看起来浪费了10分钟,但正是这10分钟让我躲过了无数个“数据结构看走眼”“边界条件没想到”的雷。特别是0和负数、空字符串、None这种脏数据,写测试的时候逼着你思考,不写测试的时候很容易直接忽略。

如果你完全没写过测试,建议从最简单的工具函数开始练手,比如日期格式转换、字符串清洗、价格计算。这类函数输入输出明确,是最容易上手也最能立刻感受到测试价值的地方。不要一开始就想着给完整项目写全量测试,那样很容易被各种依赖绑架,最后写不下去。

5. 测试隔离、Mock与运行效率

5.1 让测试不依赖网络、数据库和时间

前面说单元测试不需要真实调用第三方服务,那如果有函数内部确实调了HTTP接口怎么办?这时候就需要Mock。

from unittest.mock import patch import requests def fetch_user_name(user_id): resp = requests.get(f"https://api.example.com/users/{user_id}") return resp.json()["name"]

如果直接测这个函数,每次测试都会真实发起HTTP请求,慢不说,万一接口挂了测试就跟着挂了。用patch可以模拟出响应:

from unittest.mock import Mock, patch class TestFetchUser(unittest.TestCase): @patch("requests.get") def test_fetch_user_name(self, mock_get): mock_resp = Mock() mock_resp.json.return_value = {"name": "张三"} mock_get.return_value = mock_resp from user_service import fetch_user_name self.assertEqual(fetch_user_name(1), "张三") mock_get.assert_called_once()

这样测试跑得飞快,还不用纠结网络环境。Mock的核心思想是“替身”,测试验证的是你的函数在外部依赖假设正常时是否正确处理了返回值。

不过Mock也要用对地方。我踩过一个坑:模拟外部接口时,把返回的json结构模拟得跟真实接口不一致,导致测试全绿,但联调直接报错。所以Mock的返回结构必须严格对照真实接口文档,至少在字段层面不能省略或改名。

5.2 跳过用例与预期失败

有时候你会遇到一些暂时无法修复的失败用例,或者某些用例依赖特定操作系统才可运行。Unittest提供了跳过机制:

import sys class TestPlatformSpecific(unittest.TestCase): @unittest.skip("Windows下的行为尚未实现") def test_windows_behavior(self): pass @unittest.skipIf(sys.platform.startswith("win"), "该用例只用于非Windows平台") def test_linux_behavior(self): pass

skipIf比skip更常用,因为它会根据条件自动决定要不要跳过。这种机制在跨平台项目中特别好用,比如某个用例需要Linux的/proc文件系统,在Windows上跑就直接跳过,而不是报错影响整个测试套件的判断。

还有@unittest.expectedFailure装饰器,标记某个用例预期会失败。适用场景是已知bug但还没修完。注意,这个装饰器用过之后,测试报告里会显示expected failure,但不是红色失败。它有存在的意义,但也要克制,不要让预期失败的用例积累太多,否则说明测试失去了约束力。

5.3 测试文件与目录结构

项目稍微大一点之后,测试代码的组织就和业务代码一样重要。我比较推荐这套结构:

project/ ├── app/ │ ├── __init__.py │ ├── services/ │ │ ├── __init__.py │ │ └── discount_service.py │ └── utils/ │ ├── __init__.py │ └── text_utils.py ├── tests/ │ ├── __init__.py │ ├── test_discount_service.py │ └── test_text_utils.py └── run_tests.py

在项目根目录执行:

python -m unittest discover -s tests -v

这要求你要在tests目录下加__init__.py,并且测试文件内部使用from app.services.discount_service import DiscountService这样以项目根目录为基准的导入路径。跑测试时的工作目录应该是项目根目录,这样导入和相对路径都干净。

我在命令行跑测试很多年,但真实工作里大部分时间是让VS Code帮我跑。配置方式是在.vscode/settings.json里加上:

{ "python.testing.unittestEnabled": true, "python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test_*.py"] }

之后左侧测试栏可以看到每个用例的绿勾红叉,比命令行可读性高一个档次。如果你用PyCharm,右键tests目录选“Run pytest in tests”,不过要先把默认测试运行器改成unittest。

6. 常见问题与排查技巧实录

6.1 测试不执行或显示0个用例

最常见原因无非三个:测试文件名不以test_开头、测试类没有继承TestCase、测试方法没有test_前缀。先用python -m unittest discover -s tests -v跑一次看输出,如果显示“Ran 0 tests”,就挨个排查这三个点。

另一个隐蔽原因是测试类里写了__init__方法但没有调用父类的__init__。如果你自定义类初始化方法,一定要写成:

def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs)

否则Unittest内部逻辑会直接被打乱,测试加载时可能报错甚至静默不加载。这个坑我当年找了半天才发现。

6.2 测试之间互相污染

这在做文件操作或数据库相关的测试中特别明显。比如一个用例创建了临时文件没删,另一个用例读取目录时多了一个文件,断言就挂了。解决办法是把清理动作放进tearDown,或者更推荐用tempfile.TemporaryDirectory,它会在目录对象被销毁时自动清理:

import os import tempfile class TestFileOperation(unittest.TestCase): def test_write_and_read(self): with tempfile.TemporaryDirectory() as tmpdir: file_path = os.path.join(tmpdir, "data.txt") with open(file_path, "w", encoding="utf-8") as f: f.write("hello") with open(file_path, "r", encoding="utf-8") as f: self.assertEqual(f.read(), "hello")

TemporaryDirectory上下文退出后,目录连同文件都会被删除。这比手动在tearDown里删干净得多,也避免了用例挂掉后残留垃圾文件。

还有状态污染的问题。如果你被测代码里有一个全局变量或单例,一个用例修改了它,另一个用例可能会读到脏数据。处理方式是在setUp里重置状态,或者在测试结束后用addCleanup注册恢复函数。addCleanup和setUp/tearDown相比,即使测试中途断言失败也会被调用,更可靠。

6.3 断言失败但功能看起来没问题

这种情况多半是测试理解错了业务规则,而不是代码真的坏了。我以前在测试货币格式化函数时,总是忘了不同地区的千分位分隔符不一样,导致测试在本地能过,在同事的机器上就挂。后来把预期结果参数化、写清楚用例注释,这类问题就少了很多。

还有一个很常见的场景是时间相关。函数内部用了datetime.now()来计算结果,看起来每次运行都正常,但是测试结果却时好时坏。解决办法是用freezegun库冻结时间,或者设计函数时把时间作为参数传入,方便测试时控制。后者是更好的设计,但如果不方便改接口,Mock掉datetime也很有效。

6.4 测试执行太慢,怎么提速

单元测试应该是秒级完成才对。如果发现跑一遍测试要好几分钟,大概率是测试里做了真实IO操作,比如连数据库、调HTTP接口、读写大量文件。建议先用Mock把这些外部依赖全部替换掉,确认核心逻辑无误后,再单独安排一层数量很少的集成测试去验证真实依赖。

我维护过的系统里,有一个旧的测试套件就是每个用例都新建数据库连接,跑一次要20分钟,后来大家都不跑了,测试形同虚设。把它们改成Mock之后,30秒以内跑完全部几千个用例,效率提升了不止一个量级。测试这套东西,只有被持续执行才有价值,做成摆设不如不做。

7. 从“会写测试”到“测试思维融入日常”

当初我学Unittest的时候,觉得它就是写几个断言,没什么了不起的。但用到现在,我最大的体会其实是:写测试这个动作本身会倒逼你重新审视代码设计。因为要给函数写测试,你就得把函数边界弄清楚,把输入输出定义清楚,把异常情况想清楚。这些工作在没写测试的时候,经常会偷懒忽略。

后来我试过把单元测试和ChatGPT这类工具配合使用,让AI生成一批初步测试用例,然后我再手工补充边界条件和修正断言。效率确实高,能省下不少写重复模板的时间。但要注意,AI生成的测试经常存在同义反复的问题——测试里复制的逻辑和被测代码逻辑一致,导致真正的bug测不出来。所以无论用不用AI,最终把关的还是你对业务需求的理解。

另外说一点经验之谈:测试用例数量不是越多越好。我见过有人给一个三行函数写了四十多个用例,每个都是排列组合出来的等价类,价值很低。好的测试应该在关键逻辑、边界条件、异常路径这三个维度做到覆盖即可,与其追求数量,不如保证每一个断言都代表一条明确的要求。

如果你按照这篇文章的思路,从今天开始给自己的函数补上Unittest测试,那么下次改代码的时候,你会第一次感受到什么叫胸有成竹。我自己最直观的变化是:以前最怕别人跟我说“帮忙改一下那个计算逻辑,加个字段加个条件”,现在我基本是先加测试、再改逻辑、最后跑一遍全绿再交付。这套流程一旦跑顺,就再也回不到从前那种裸奔写代码的状态了。这就是“给代码上保险”的真正含义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询