单元测试

pytest / unittest / doctest 入门,编写高质量的自动化测试用例

什么是单元测试?

单元测试(Unit Testing)是软件开发中针对最小可测试单元(通常是函数或方法)进行的测试,确保每个代码单元在隔离环境下都能按预期工作。单元测试是自动化测试金字塔的基础,具有快速、稳定、成本低的特点。

为什么要单元测试?
尽早发现 Bug、提高代码质量、便于重构、作为活文档。覆盖率应达到 80% 以上。
单元测试原则(FIRST)
Fast(快速)、Independent(独立)、Repeatable(可重复)、Self-validating(自验证)、Timely(及时)。
常用框架
pytest(推荐)、unittest(标准库)、doctest(文档测试)、nose。pytest 最为灵活强大。
测试覆盖率
行覆盖、分支覆盖、条件覆盖。推荐使用 coverage.py 或 pytest-cov 测量。
Mock 技术
使用 unittest.mock 模拟外部依赖(数据库、网络等),保持测试快速稳定。
常见陷阱
测试之间有依赖、测试真实环境、断言模糊、测试逻辑太复杂。

unittest 标准库入门

unittest 是 Python 标准库自带 的单元测试框架(无需 pip 安装),提供测试固件、测试用例、测试运行器、测试报告四大核心组件。

教学顺序:先学 unittest 理解测试框架的"固件 → 用例 → 运行 → 报告"完整链路,再学 pytest 看它如何把这些环节变得更简洁灵活。

① 四大核心组件

测试固件 Fixture

setUp() 每个用例前执行(准备数据),tearDown() 每个用例后执行(清理资源)

测试用例 TestCase

继承 unittest.TestCase,方法名以 test_ 开头,用 assert* 系列断言

测试运行器 Runner

unittest.TextTestRunner 负责收集并执行所有用例,输出结果到终端

测试报告 Report

终端输出 ./F/E 标记,统计 Run / Failures / Errors / OK

② 测试固件 Fixture 详解

固件(Fixture)就是"为每个测试准备/清理环境"的钩子方法。unittest 提供三级固件:模块级别(整个 .py 文件只跑 1 次)、类级别(每个 TestCase 类只跑 1 次)、方法级别(每个 test_ 方法都跑)。

setUpModule → setUpClass → setUp → test_xxx → tearDown → tearDownClass → tearDownModule
级别方法什么时候调用典型用途
方法级别
(Method)
setUp(self)每个 test_ 方法之前准备数据 / 刷新页面 / 新建连接
tearDown(self)每个 test_ 方法之后清理数据 / 关闭页面 / 断开连接
类级别
(Class)
setUpClass(cls)每个 TestCase 类所有方法之前,只执行 1 次重量级初始化:连一次浏览器 / 加载大 fixture
tearDownClass(cls)每个 TestCase 类所有方法之后,只执行 1 次重量级清理:关闭浏览器 / 释放资源
模块级别
(Module)
setUpModule()整个 .py 文件所有类之前,只执行 1 次全局初始化:读配置 / 连数据库 / 启动 Web 服务
tearDownModule()整个文件所有类之后,只执行 1 次全局清理:关数据库 / 停服务
import unittest # ── 模块级别固件(整个 test_xxx.py 只跑 1 次,不需要 @classmethod!) ── def setUpModule(): print("🟡 setUpModule — 整个 .py 文件最开始") # 全局配置读取 / 数据库连接 / 启动 mock server def tearDownModule(): print("🟡 tearDownModule — 整个 .py 文件最后") # 关闭数据库 / 停服务 class TestLogin(unittest.TestCase): # ── 类级别固件(每个 TestCase 类只跑 1 次) ── @classmethod def setUpClass(cls): print(f" 🟢 setUpClass — 类级别只跑 1 次") cls.driver = webdriver.Chrome() @classmethod def tearDownClass(cls): print(f" 🔴 tearDownClass — 类级别只跑 1 次") cls.driver.quit() # ── 方法级别固件(每个 test_ 方法都跑) ── def setUp(self): print(f" ⏱ setUp — 每个用例前") self.driver.refresh() self.driver.get("/login") def tearDown(self): print(f" ⏹ tearDown — 每个用例后") self.driver.delete_all_cookies() # ── 测试用例 ── def test_login_admin(self): print(" ▶ test_login_admin") pass def test_login_user(self): print(" ▶ test_login_user") pass
⚠️ 关键细节:
① 模块级别 setUpModule/tearDownModule 是模块级函数(不在 class 里),不需要 @classmethod 装饰器;
② 类级别 setUpClass/tearDownClass 是类方法,必须加 @classmethod,参数是 cls 不是 self;
③ 三级固件共享数据时要小心:模块级别用全局变量、类级别用 cls.driver、方法级别用 self.driver——作用域不同容易搞混。
📌 pytest 对应关系:pytest 没有 setUp/tearDown 方法族,用 一个 fixture 函数 + scope 参数 实现三级固件:@pytest.fixture(scope="session") ≈ setUpModule,scope="class" ≈ setUpClass,scope="function" ≈ setUp。更简洁。

📊 unittest 3 级 ↔ pytest 4 级 对照

生命周期unittestpytest scopepytest 写法
整个测试会话
(多个 .py 文件)
❌ 没有session@fixture(scope="session")
单个 .py 文件setUpModule() / tearDownModule()module@fixture(scope="module")
单个 TestCase 类@classmethod setUpClass(cls)class@fixture(scope="class")
每个 test_ 方法setUp(self) / tearDown(self)function
(默认值)
@fixture
💡 关键差异:pytest 多了一个 session 级别——整个 pytest tests/ 跑起来,多个 .py 文件共享一次初始化。unittest 做不到跨文件的 session 级别的固件,要自己写 orchestration 脚本手动调 setUpModule。

③ 测试用例 TestCase 详解

🧪 什么是 TestCase?一个 unittest.TestCase 子类就是一组相关测试的集合。类里每个以 test_ 开头的方法,都是一条独立的测试用例。unittest 会自动发现并运行它们。

📐 TestCase 的最小骨架(Web 测试场景)

import unittest from selenium import webdriver from selenium.webdriver.common.by import By # ① 必须继承 unittest.TestCase class TestLoginPage(unittest.TestCase): def setUp(self): self.driver = webdriver.Chrome() self.driver.get("http://localhost:5000/login") # ② 每个测试方法必须以 test_ 开头 def test_login_success(self): # 输入用户名密码 → 点登录 → 跳到首页 self.driver.find_element(By.ID, "username").send_keys("admin") self.driver.find_element(By.ID, "password").send_keys("123456") self.driver.find_element(By.ID, "login-btn").click() # ③ 用 assert* 方法断言 self.assertIn("首页", self.driver.title) def test_login_empty_password(self): self.driver.find_element(By.ID, "username").send_keys("admin") self.driver.find_element(By.ID, "login-btn").click() error_msg = self.driver.find_element(By.ID, "error").text self.assertEqual(error_msg, "密码不能为空") def not_a_test_method(self): # 没有 test_ 前缀 → 不会被运行! pass def tearDown(self): self.driver.quit() if __name__ == "__main__": unittest.main(verbosity=2)
unittest.TestCase → 继承 class TestXxx → 包含 setUp() + test_xxx() + tearDown()

🏷️ 测试用例命名规范

方法名要让别人看名字就知道测什么。推荐模式:test_被测功能_前置条件_预期结果

❌ 坏命名✅ 好命名为什么好
test1test_login_admin_success一眼看出测"管理员登录成功"
test_oktest_login_empty_password_should_fail前置条件 + 预期结果都写清楚
test_buttontest_login_button_click_navigates_to_home动作 + 导航目标明确
test_2test_divide_by_zero_raises_error边界场景 + 异常类型明确
calctest_add_two_positive_numbers没有 test_ 前缀 → 根本不会被运行
💡 行业常用命名模式:
• test_{功能}_{场景}_{预期}(unittest / pytest 通用)
• test_when_{条件}_then_{结果}(BDD 风格,pytest 更常见)
• 全小写 + 下划线分隔,别用驼峰:test_login_ok ✅ vs testLoginOk ❌

🔄 调用顺序 & 独立性

📌 核心原则:每条用例独立运行。unittest 在每个 test_ 方法前后都会调 setUp/tearDown,目的就是让你可以单独跑某一条用例,不需要先跑其他用例。
class TestLogin(unittest.TestCase): def setUp(self): print(" ⏱ setUp") def test_b(self): # 字母 b 开头 print(" ▶ test_b") def test_a(self): # 字母 a 开头 print(" ▶ test_a") def test_c(self): # 字母 c 开头 print(" ▶ test_c") def tearDown(self): print(" ⏹ tearDown")

运行输出:

$ python test_order.py -v test_a ... ⏱ → ▶ test_a → ⏹ ok test_b ... ⏱ → ▶ test_b → ⏹ ok test_c ... ⏱ → ▶ test_c → ⏹ ok Ran 3 tests in 0.001s OK

📋 TestCase 三大规则

规则正确示例错误示例为什么
必须继承 TestCaseclass TestLogin(unittest.TestCase)class TestLogin:不继承 → 不是测试类,runner 不认识
方法必须 test_ 开头def test_login_ok(self):def login_ok(self):没有 test_ 前缀 → 被当成普通方法跳过
用 assert* 断言self.assertEqual(a, b)assert a == b内置 assert 失败时 unittest 可能不会正确计为 FAILURE
⚠️ setUp/tearDown 陷阱:如果你重写了父类 setUp 但忘了调 super().setUp(),那么父类 TestCase 的初始化逻辑不会执行,可能导致断言方法行为异常。unittest 官方建议里不需要调 super(因为 TestCase 基类的 setUp/tearDown 是空方法),但如果你的 TestCase 有多层继承链(比如自定义 BaseTest),就一定要调。

def setUp(self):
    super().setUp()  # ← 多层继承时必加
    self.driver = webdriver.Chrome()
📌 pytest 对应关系:pytest 不需要继承 TestCase,普通函数 def test_xxx(): 就是一条用例。而且直接写 assert a == b 就行——pytest 会自动做更友好的 diff 输出。少了很多"必须遵守的规则"。

④ 完整代码示例(计算器)

# calc.py —— 被测模块 class Calculator: def add(self, a, b): return a + b def subtract(self, a, b): return a - b def multiply(self, a, b): return a * b def divide(self, a, b): if b == 0: raise ValueError("除数不能为 0") return a / b # test_calc.py —— unittest 测试 import unittest from calc import Calculator class TestCalculator(unittest.TestCase): # ① setUp —— 测试固件:每个用例运行前自动执行 def setUp(self): # 准备一个干净的计算器实例 self.calc = Calculator() self.calls = 0 # 可选:每个用例独立的计数器 # ② tearDown —— 测试固件:每个用例结束后自动执行 def tearDown(self): self.calc = None # 释放资源(实际场景:关闭文件/数据库连接) # ③ 测试用例:方法名必须以 test_ 开头 def test_add(self): self.assertEqual(self.calc.add(2, 3), 5) self.assertEqual(self.calc.add(-1, 1), 0) def test_divide_by_zero(self): with self.assertRaises(ValueError): self.calc.divide(10, 0) def test_multiply(self): self.assertAlmostEqual(self.calc.multiply(0.1, 0.2), 0.02) # ④ 测试运行器:执行入口 if __name__ == "__main__": # TextTestRunner 负责收集用例并输出报告 unittest.main(verbosity=2) # verbosity=2 显示每个测试用例名

⑤ 如何调用测试用例

💡 unittest.main() 背后的调用链

你写的 unittest.main() 其实做了 3 件事:

TestLoader → 自动发现 TestSuite → 收集用例 TextTestRunner → 执行并输出 测试结果

📋 四种调用方式

① 方式 1:直接运行(最简单)
# 文件末尾写这两行: if __name__ == "__main__": unittest.main(verbosity=2) # 然后命令行: python test_calc.py

✅ 推荐教学用。verbosity=2 显示每个测试用例名,=1 只显示 .F.E

② 方式 2:命令行指定模块
# 跑整个文件 python -m unittest test_calc -v # 跑某个 TestCase 类 python -m unittest test_calc.TestCalculator -v # 跑单个方法 python -m unittest test_calc.TestCalculator.test_add -v

✅ CI/CD 场景常用。-v 详细模式。

③ 方式 3:discover 自动发现
# 扫描当前目录下所有 test_*.py python -m unittest discover -s . -p "test_*.py" -v # -s 起始目录 / -p 文件模式

✅ 项目里有多个测试文件时用。扫描规则:文件名 test_*.py,方法名 test_*。

④ 方式 4:TestLoader + TestSuite + TextTestRunner(高级)
import unittest # 第一步:TestLoader 自动发现并加载 loader = unittest.TestLoader() suite = loader.discover(start_dir="tests", pattern="test_*.py") # 第二步:手动组合 TestSuite(灵活控制顺序/分组) suite = unittest.TestSuite() suite.addTest(test_calc.TestCalculator("test_add")) suite.addTest(test_login.TestLoginPage("test_login_success")) # 第三步:TextTestRunner 执行并输出结果 runner = unittest.TextTestRunner(verbosity=2) result = runner.run(suite) # 第四步:result 里能拿到统计数据 print(f"成功 {result.testsRun - len(result.failures) - len(result.errors)}/{result.testsRun}")

✅ 企业级场景用。可以自定义输出格式、把结果写进 HTML 报告、集成到 CI 系统。unittest.main() 底层其实就是这套。

📌 pytest 对应关系:pytest 直接 pytest tests/ -v 就行——不需要你关心 TestLoader/TestSuite/TestRunner 的分工。它自动发现、收集、执行、生成报告。unittest 是手动模式,pytest 是全自动模式。

⑥ 测试报告示例(终端输出)

$ python test_calc.py test_add (test_calc.TestCalculator.test_add) ... ok test_divide_by_zero (test_calc.TestCalculator.test_divide_by_zero) ... ok test_multiply (test_calc.TestCalculator.test_multiply) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.001s OK $ python test_calc.py # 假设某个断言失败 test_add (test_calc.TestCalculator.test_add) ... FAIL test_divide_by_zero ... ok test_multiply ... ok ---------------------------------------------------------------------- FAIL: test_add (test_calc.TestCalculator.test_add) ---------------------------------------------------------------------- Traceback (most recent call last): File "test_calc.py", line 22, in test_add self.assertEqual(self.calc.add(2, 3), 6) # 故意写错期望值 AssertionError: 5 != 6 ---------------------------------------------------------------------- Ran 3 tests in 0.001s FAILED (failures=1)

⑦ assert* 断言方法速查表

断言方法作用等价于
assertEqual(a, b)a == bassert a == b
assertNotEqual(a, b)a != bassert a != b
assertTrue(x)bool(x) 为 Trueassert x
assertFalse(x)bool(x) 为 Falseassert not x
assertIs(a, b)a 是 b(同一对象)assert a is b
assertIsNone(x)x 是 Noneassert x is None
assertIn(a, b)a 在 b 里面assert a in b
assertIsInstance(a, b)a 是 b 的实例assert isinstance(a, b)
assertAlmostEqual(a, b, places=2)浮点数近似相等round(a-b, 2) == 0
assertRaises(异常类)with 块内抛出异常pytest 的 pytest.raises
assertRegex(text, pattern)正则匹配assert re.search(pattern, text)

⑧ 跳过用例 skip / skipIf / skipUnless

某些环境下特定用例不需要跑(如 Mac 跑不了 Windows 专属测试),用 @unittest.skip* 装饰器标记,报告里显示为 S(SKIP):

import sys import unittest # 无条件跳过 @unittest.skip("此功能暂未实现") def test_not_ready(self): pass # 条件跳过:Windows 上才跑,其他平台跳过 @unittest.skipUnless(sys.platform == "win32", "仅 Windows 支持") def test_windows_feature(self): pass # 反向条件:如果没有 selenium 就跳过 @unittest.skipIf(not HAS_SELENIUM, "selenium 未安装") def test_ui(self): pass # 预期失败:明确知道这条用例会失败,不算红色 @unittest.expectedFailure def test_known_bug(self): self.assertEqual(1, 2) # 预期失败,报告显示 x

⑨ subTest —— 一个方法跑多组数据

不想为每组数据写一个测试方法?用 self.subTest() 循环执行,每组数据独立报告:

class TestCalc(unittest.TestCase): def test_add_all(self): # 一组输入数据:(a, b, expected) cases = [ (2, 3, 5), (-1, 1, 0), (0, 0, 0), (100, 200, 300), ("a", 1, TypeError), # 故意写错,看 subTest 报告 ] for a, b, expected in cases: with self.subTest(a=a, b=b): self.assertEqual(a + b, expected) # 报告中:哪个 (a,b) 组合失败一目了然,不影响其他组结果

⑩ discover 自动发现规则

 # 目录结构示例 project/ ├── tests/ │ ├── __init__.py ← 让 tests 成为 package │ ├── test_calc.py ← 被 discover 扫描(test_ 前缀) │ ├── test_login.py ← 被扫描 │ └── helper.py ← 不扫描(非 test_ 开头) python -m unittest discover -s tests -p "test_*.py" -v ↑ ↑ ↑ │ │ └─ 文件名匹配模式(默认 "test*.py") │ └──────────── 扫描起始目录 └────────────────────── discover 子命令

⑪ unittest vs pytest 对比

对比项unittestpytest
安装✅ 标准库自带pip install pytest
写用例必须继承 TestCase 类普通函数即可
断言self.assertEqual 等 21 种原生 assert + 丰富失败信息
固件setUp/tearDown + setUpClass灵活的 fixture(scope 控制)
参数化subTest 循环(手动)@pytest.mark.parametrize 装饰器
跳过用例@skip / @skipIf / @skipUnless@pytest.mark.skip / skipif
运行命令python -m unittest discoverpytest(自动发现)
插件生态几乎无3000+ 插件(cov / html / xdist / mock 等)
HTML 报告需 xmlrunner 第三方包pytest-html 一键生成
→ 下一节:unittest 是"正确但繁琐"的标准实现,学完它再看 pytest 就能瞬间看懂 pytest "简化了哪些环节、替你做了哪些事"。

pytest 基础入门

安装 pytest

# 安装 pytest pip install pytest # 安装常用插件 pip install pytest-cov pytest-html pytest-xdist pytest-mock

第一个 pytest 测试

# test_basic.py def add(a, b): return a + b def subtract(a, b): return a - b class TestCalculator: def test_add(self): assert add(2, 3) == 5 assert add(-1, 1) == 0 assert add(0, 0) == 0 def test_subtract(self): assert subtract(5, 3) == 2 assert subtract(1, 1) == 0 def test_add_error(self): with pytest.raises(TypeError): add("a", 1)
运行命令:pytest test_basic.py -v 或 pytest -k "test_add" -v

pytest Fixture 机制

Fixture 是 pytest 最强大的功能,用于管理测试前置条件和清理操作。

# conftest.py - Fixture 配置 import pytest import tempfile import os @pytest.fixture def temp_dir(): # 前置:创建临时目录 tmpdir = tempfile.mkdtemp() yield tmpdir # 后置:清理临时目录 import shutil shutil.rmtree(tmpdir) @pytest.fixture(scope="module") def test_user(): return {"name": "admin", "pwd": "123456"}
# test_with_fixture.py - 使用 Fixture def test_user_login(test_user): assert test_user["name"] == "admin" assert len(test_user["pwd"]) >= 6 def test_temp_file(temp_dir): filepath = os.path.join(temp_dir, "test.txt") with open(filepath, "w") as f: f.write("hello") assert os.path.exists(filepath)

scope 参数

控制 Fixture 生命周期:function / class / module / session

yield 关键字

yield 前执行前置,yield 后执行清理(teardown)

自动注入

只需在测试函数参数中声明,pytest 自动匹配 Fixture

Fixture 嵌套

Fixture 可以依赖其他 Fixture,灵活组合

Mock 与 Patch

使用 unittest.mock 模拟外部依赖,使单元测试不依赖真实环境。

from unittest.mock import Mock, patch class TestUserService: @patch('app.db.Database.query') def test_get_user(self, mock_query): # 模拟数据库返回 mock_query.return_value = {"id": 1, "name": "Alice"} result = get_user(1) assert result["name"] == "Alice" mock_query.assert_called_once_with(1) @patch('requests.get') def test_api_call(self, mock_get): mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"status": "ok"} mock_get.return_value = mock_response assert fetch_data() == {"status": "ok"}

pytest 高级用法

参数化测试

import pytest @pytest.mark.parametrize("input_val,expected", [ (2, 4), (3, 9), (4, 16), (0, 0), (-2, 4), ]) def test_square(input_val, expected): assert input_val ** 2 == expected

标记与分组

# pytest.ini 中注册自定义标记 # [tool.pytest.ini_options] # markers = ["slow: 慢速测试", "smoke: 冒烟测试", "regression: 回归测试"] @pytest.mark.slow def test_large_data(): pass @pytest.mark.smoke def test_quick_check(): pass # 运行指定标记:pytest -m "smoke" # 排除标记:pytest -m "not slow"

生成测试报告

# 生成 HTML 报告 pytest --html=report.html --self-contained-html # 生成 JUnit XML 报告(CI 用) pytest --junitxml=result.xml # 覆盖率报告 pytest --cov=src --cov-report=html
注意:单元测试应保持快速(< 100ms)、独立、无副作用。避免在单元测试中做真实的网络请求或数据库操作。

关于我们

Python 自动化测试学习平台是一个面向软件测试工程师、开发人员及在校学生的交互式在线学习平台,专注于 Selenium WebDriver 与 Python unittest 自动化测试技能的教学与实践。

我们以"理论 + 实战 + 可交互演示"为核心特色,将八种常规元素定位和Javascript定位、元素操作、自动化测试工具、单元测试、测试模型等知识体系拆分为模块化课程,每个模块都配有可点击、可输入、可运行的模拟练习页面。

平台致力于降低自动化测试的入门门槛,让学习者从"看懂代码"到"动手实操",逐步掌握企业级自动化测试框架的设计与落地能力。

联系方式

📧邮箱:lq@selenium-auto-test.com

📞电话:0755-12345678

📍地址:深圳

💬微信公众号:Selenium自动化测试

🐙GitHub:github.com/selenium-auto-test

免责声明

1. 本平台所有示例代码、演示页面及教程内容均仅供学习交流使用,不构成任何商业推广或技术服务承诺。

2. 平台中的 Selenium API 描述、Python 语法说明均以官方文档为准,如有版本差异请以对应官方文档为准。

3. 学习者在实际项目中应用平台所学内容时,应结合自身项目环境进行充分测试,本平台不对因直接使用示例代码而产生的任何直接或间接损失承担责任。

4. 本平台所引用的第三方库(Selenium、ChromeDriver、unittest 等)版权归其各自作者及维护者所有。

5. 如涉及版权问题或内容建议,请及时通过联系方式与我们沟通,我们将第一时间处理。