Skip to content

unittest 最初是受到了 JUnit 的启发,Py 原生的自动化测试框架(库)。文档

核心概念

IMPORTANT

test fixture 测试夹具???:测试执行前后需要的准备和清理工作,比如数据库连接、测试数据准备等。

test case 测试用例:最小的测试单元,包含输入、执行和预期输出。

test suite 测试套件:一组相关的测试用例集合,可以一起执行。

test runner 测试运行器:负责执行测试用例和测试套件,并生成测试报告。

不要再叫人家“夹具”了,这是一个略显抽象的感念,在软件测试中的含义是执行一个或多个测试所需的准备工作,以及任何相关的清理操作。

Fixture 最开始来源于电子学,指的是测试电子元器件所需的辅助装置。

注意

测试用例的编写需要继承 unittest.TestCase 类,并且测试方法必须以 test_ 开头。为约定告知程序哪些方法代表测试用例。

快速开始

以一个最小 demo 例子开始:

每个测试类中都可以包含自身的 setUptearDown 方法,这两个方法会在每个测试方法执行前后分别调用,用于准备和清理测试环境。

python
import unittest

class TestStringMethods(unittest.TestCase):
    # 初始化
    def setUp(self)
        self.test_string = 'hello'

    # 测试字符串大写
    def test_upper(self):
        # 断言:用于判定结果是否符合预期
        self.assertEqual(self.test_string.upper(), 'HELLO')

    # 清理
    def tearDown(self) :
        self.test_string = None


if __name__ == '__main__':
    # 运行所有测试
    unittest.main()

setUp 中的参数,在每个测试方法中都可以被访问到,通过实例 self.xxx 访问。并在 tearDown 中进行清理。

脚本执行

bash
# 可以指定具体测试文件
python -m unittest test_demo.py

# uv 执行
uv run python -m unittest test_demo.py
# -v 运行更详细的测试用例。控制台输出更多的信息
uv run python -m unittest -v test_demo.py
# -k 运行指定的测试类或者测试方法
#   -k TestStringMethods 运行指定的测试类
#   -k test_upper 运行指定的测试方法
#   -k TestStringMethods.test_upper 运行指定的测试类中的测试方法
uv run python -m unittest -v -k TestStringMethods.test_upper test_demo.py

test fixture & 测试套件

在实际的场景中,测试用例可能有很多,setUptearDown 的出现就是为了减少重复代码的编写。每次执行用例的时候这两个方法都会被调用。

如果 setUp 调用的过程中出现了错误,会被认为测试已经发生了错误,后续的测试方法将不会被执行。反之一旦执行成功,则 tearDown 方法一定会被执行。

如果该测试类拥有 __init__ 则会被最先执行。这个过程就被成为 test fixture。执行顺序如下

py
# 假定一个类中存在两个测试方法

# 1. __init__ -> setUp -> test_method_1 -> tearDown
# 2. __init__ -> setUp -> test_method_2 -> tearDown

测试套件 的主要目的就是可以允许用户将多个测试用例按照自己想要的方式组织在一起进行批量执行。 通过 unittest.TestSuite 类来实现。

python
import unittest

# 创建一个测试套件
suite = unittest.TestSuite()
# 添加测试用例到测试套件
suite.addTest(TestStringMethods('test_upper'))

# 创建一个测试运行器
runner = unittest.TextTestRunner()
# 运行测试套件
runner.run(suite)

测试套件也存在一些其他的方法或者调用方式

py
# 1. 直接传入 tests 列表
suite = unittest.TestSuite(tests=[
    TestStringMethods('test_upper'),
    TestStringMethods('test_lower')
])

# 2. 调用 addTest 方法
suite.addTest(TestStringMethods('test_upper'))

# 3. 调用 addTests 方法,传入一个可迭代对象
suite.addTests([
    TestStringMethods('test_upper'),
    TestStringMethods('test_lower')
])

运行测试

TextTestRunner 类是用于运行运行测试套件的关键。允许接收一个 verbosity 参数 1 输出简单的测试结果,2 输出复杂的输出结果

py
runner = unittest.TextTestRunner(verbosity=1)
result = runner.run(suite)

# 此外还提供了一些比较有用的的返回值
print(f"\n测试结果: {result.testsRun} 个测试运行 {'全部成功' if result.wasSuccessful() else '存在失败'}")
print(f"失败: {len(result.failures)}")
print(f"错误: {len(result.errors)}")
print(f"跳过: {len(result.skipped)}")

# 测试结果: 4 个测试运行 全部成功
# 失败: 0
# 错误: 0
# 跳过: 1

跳过测试

做个比方可能不太恰当,当被测系统经过迭代后,有些测试用例可能已经不能被使用了。如果此时选择去删除这些用例,万一后续功能重新上线,这些用例又需要重新编写,工作量就会比较大。所以 unittest 提供了一些装饰器来跳过某些测试用例。

python
class UserTestCase(unittest.TestCase):
    # 无条件跳过该用例,需要一个跳过的理由
    @unittest.skip("跳过该测试用例")
    def test_skip(self):
        self.assertEqual(1, 1)
    # 有条件跳过该用例,第一个参数为条件,满足则会被跳过;第二个参数为跳过的理由
    @unittest.skipIf(sys.platform == "win32", "跳过 Windows 平台的测试")
    def test_skip_if(self):
        self.assertEqual(1, 1)
    # 除非条件为真,否则就跳过该用例
    @unittest.skipUnless(sys.platform.startswith("linux"), "仅在 Linux 平台运行")
    def test_skip_unless(self):
        self.assertEqual(1, 1)
    # 将测试标记为预期失败,如果真的失败,则视为成功
    @unittest.expectedFailure
    def test_expected_failure(self):
        self.assertEqual(1, 0)  # 这个测试预期会失败

跳过的测试不会在它们周围运行 setUp()tearDown() 。跳过的类不会运行 setUpClass()tearDownClass() 。跳过的模块不会运行 setUpModule()tearDownModule()

类和模块的 test fixture

在测试用例中不止存在实例的 setUptearDown,还存在类级别和模块级别的 setUptearDown

  • setUpClass(cls)tearDownClass(cls):类级别的初始化和清理方法,在类中的所有测试方法执行前后分别调用一次。需要使用 @classmethod 装饰器。
  • setUpModule()tearDownModule():模块级别的初始化和清理方法,在模块中的所有测试类执行前后分别调用一次。需要使用 def 关键字定义。
python
import unittest

def setUpModule():
    print("模块级别的初始化")

def tearDownModule():
    print("模块级别的清理")

class TestStringMethods(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        print("类级别的初始化")

    def setUp(self):
        print("实例级别的初始化")
    
    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')

    def tearDown(self):
        print("实例级别的清理")

    @classmethod
    def tearDownClass(cls):
        print("类级别的清理")

# 运行顺序为: 类和模块的 test fixture 只会执行一次,实例的 test fixture 会在个用例的运行前后都执行一次
# 
# 1. setUpModule
# 2. setUpClass
# 3. setUp -> test_upper -> tearDown
# 4. tearDownClass
# 5. tearDownModule

个人理解根据官方文档的描述,setUpModule 是不能支持跨文件场景的,如果类被实例化的时候,注定要执行 test fixture 势必会造成重复执行的问题。增加系统复杂度。这么看来系统级别的 test fixture 有点鸡肋。

聪明的你肯定想到了,使用继承去解决这个问题,那我先告诉你,不可行,仍然会再每次实例化类的时候执行。并且基类中定义过的 test fixture 在子类中不能被定义。

py
import unittest

class TestBase(unittest.TestCase):
    def setUp(self):
        self.base_url = 'http://example.com'

    def tearDown(self):
        self.base_url = None

class TestUrl(TestBase):
    # 子类中如果声明了 setUp 或 tearDown 会覆盖父类中的方法,导致 base_url 无法被初始化
    def test_url_equal(self):
        self.assertEqual('http://example.com', self.base_url)

看起来只能将初始化的操作都执行一遍。虽然麻烦了一点,但是可以保证每个测试类的独立性。

加载器

unittest.TestLoader 类的实例。加载器负责从测试模块中加载测试用例,并将它们组织成测试套件。

实际上加载器就两个 TestLoader()defaultTestLoader 后者是前者的实例化对象。在源码中实际上是这样的 defaultTestLoader = TestLoader()

所以无论你用哪个他们的方法都是一致的。提供方法如下

方法作用
loadTestsFromTestCase(testCaseClass)返回一个测试套件,接收一个测试用例类
loadTestsFromModule(module)将模块作为参数传递进去,返回一个测试套件
loadTestsFromName(name, module=None)根据名称查找测试用例可以是类也可以是方法
loadTestsFromNames(names, module=None)FromName 的方法的升级版,接收一个列表
getTestCaseNames(testCaseClass)返回测试用例类中所有的方法名
discover(start_dir, pattern="test*.py", top_level_dir=None)递归的查找指定路径下的 test 开头的文件
py
from unittest import TextTestRunner, TestLoader, TestSuite
from math_demo.test_math import TestMath
from string_demo.test_string import TestString

# 创建加载器和套件
loader = TestLoader()
suite = TestSuite()

# 套件中增加 loader 加载出来的用例 loadTestsFromTestCase
suite.addTests([
  loader.loadTestsFromTestCase(TestMath),
  loader.loadTestsFromTestCase(TestString)
])

# 获取测试用例中的 case 名
print(loader.getTestCaseNames(TestMath))
print(loader.getTestCaseNames(TestString))

# 传递模块
# 注意的是如果使用此方法就不能通过 from 的方式直接导入类了
# import math_demo.test_math
# import string_demo.test_string
suite.addTests([
  loader.loadTestsFromModule(math_demo.test_math)
  loader.loadTestsFromModule(string_demo.test_string)
])

# 默认从执行的当前目录查找,如果执行文件和测试用例不在同一目录需要增加额外的参数
# import tests.math_demo.test_math
# import tests.string_demo.test_string
# suite.addTests([
#     loader.loadTestsFromName('math_demo.test_math.TestMath.test_add', module=tests),
#     loader.loadTestsFromName('string_demo.test_string.TestString', module=tests)
# ])
suite.addTests([
    loader.loadTestsFromName('math_demo.test_math.TestMath.test_add'),
    loader.loadTestsFromName('string_demo.test_string.TestString')
])

# 运行 suite 套件
runner = TextTestRunner()
runner.run(suite)

discover 方法比较常用,相对来说也比较复杂。首先被执行的目录要是一个包(拥有 __init__.py),其实会检查包中是否存在 load_tests 方法,如果有则不会递归查找用例,而是使用 load_tests 的逻辑,如果没有则递归查找用例。 当然你的 __init__.py 可以没有任何内容。

  • start_dir 是一个绝对路径
  • pattern 默认是查找 test*.py 可以指定不同的匹配规则
  • top_level_dir 如果被执行的测试用例不是顶级目录的话,需要指定目录
假设你的项目是这样的
test_project/
├── src/
│   └── tests/
│       ├── run.py
│       ├── math-demo/
│       │   ├── __init__.py
│       │   └── test_math.py
│       └── string-demo/
│           ├── __init__.py
│           └── test_string.py
py
import unittest
import os

suite = unittest.TestSuite()

# 当前目录的绝对路径  /test_project/src/tests
test_dir = os.path.dirname(os.path.abspath(__file__))
# addTests 或 addTest 都可以
suite.addTest(unittest.defaultTestLoader.discover(test_dir))

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

run.py 不是测试用例文件,完全不需要使用 uv run python -m unittest run.py 去执行脚本,它就是一个 .py 文件,你应当这样做 uv run python run.py

测试发现

加载器章节一直都是在代码层面去组织测试用例。实际上 unittest 提供了这种指令去做这件事情。文档 官方称之为 测试发现

bash
python -m unittest discover

# uv
uv run python -m unittest discover

# -v 详细输出
# -s 指定的目录,默认 . 当前目录
# -p 匹配模式,默认 test*.py
# -t top_level_directory

同样以加载器 Demo 为例,你想执行 tests 下的所有用例

bash
# uv
uv run python -m unittest discover -v -s src/tests 

python -m unittest discover -v -s src/tests

断言

方法作用判定逻辑
assertEqual(a, b)是否相等a == b
assertNotEqual(a, b)是否不相等a != b
assertTrue(x)是否为 True,对运算结果转换bool(a) is True
assertFalse(x)是否为 False,对运算结果转换bool(a) is False
assertIs(a, b)比较内存地址(用于对象)a is b
assertIsNot(a, b)比较内存地址(用于对象)a is not b
assertIsNone(x)是否为空a is None
assertIsNotNone(x)是否不为空a is not None
assertIn(a, b)a 是否在 b 里面,列表,对象等a in b
assertNotIn(a, b)a 不在 b 里面,列表,对象等a not in b
assertIsInstance(a, b)a 是否是 b 的实例isInstance(a, b)
assertIsNotInstance(a, b)a 不是 b 的实例not isInstance(a, b)

测试报告生成

生成测试报告需要依赖第三方库 unittestreport 他是一个基于 unittest 的功能拓展库,支持功能如下:文档

bash
pip install unittestreport

uv add unittestreport
  • 生成 HTML 的测试报告
  • 失败用例重试、组织测试用例执行顺序
  • 邮件发送测试报告、推动测试结果到钉钉、企微
  • 数据驱动测试(参数化)
  • 并发执行测试用例

生成测试报告

py
# 使用 report 的 runner 去执行测试用例
from unittestteport import TestRunner

test_dir = '' # 这是一个可以被导入的路径
suite = unittest.defaultTestLoader.discover(test_dir)
runner = TestRunner(suite)
runner.run()

TestRunner 接入具名参数如下

参数作用示例
tester测试执行人TestRunner(tester='小许')
filename指定测试报告的文件名TestRunner(filename='test_report.html')
report_dir测试报告的生成路径TestRunner(report_dir=".")
title测试报告的标题TestRunner(title="xx 迭代")
templates测试报告的风格, 1 | 2 | 3TestRunner(templates=1)

发送邮件

py
import unittest
from unittestreport import TestRunner

suite = unittest.defaultTestLoader.discover(CASE_DIR)
runner = TestRunner(suite)
runner.run()

# 指定邮箱地址和端口等信息
runner.send_email(host="smtp.qq.com",
                  port=465,
                  user="musen_nmb@qq.com",
                  password="alg123412bab",
                  to_addrs="324666668@qq.com")

结果推送钉钉、企微

py
import unittest
from unittestreport import TestRunner

# 收集用例到套件
suite = unittest.defaultTestLoader.discover(CASE_DIR)
runner = TestRunner(suite)
# 执行用例
runner.run()

"""
推动到钉钉
"""
# 准备机器人地址和 token
url = "https://oapi.dingtalk.com/robot/send?access_token=6e2a63c2b9d870ee878335b5ce6d5d10bb1218b8e64a4e2b55f96a6d116aaf50"
# 发送钉钉通知  
runner.dingtalk_notice(url=url, key='钉钉安全设置的关键字',secret='钉钉安全设置签名的秘钥')

"""
推送到企业微信
"""
# 方式一:
runner.weixin_notice(chatid="企业微信群id", access_token="调用企业微信API接口的凭证")
# 方式二:
runner.weixin_notice(chatid="企业微信群id",corpid='企业ID', corpsecret='应用的凭证密钥')

接口测试实践

项目结构
.
├── pyproject.toml   项目依赖
├── src   核心代码
│   ├── core
│   │   └── todo-server.py   flask 最小 todolist 项目
│   └── tests
│       ├── run.py   测试脚本执行
│       └── test_todo_list  测试核心逻辑
│           ├── __init__.py
│           ├── test_create.py
│           ├── test_del.py
│           ├── test_select.py
│           └── test_update.py
└── uv.lock
py
import os
import unittest
from unittestreport import TestRunner

test_dir = os.path.dirname(os.path.abspath(__file__))

suite = unittest.defaultTestLoader.discover(test_dir)
runner = TestRunner(
  suite,
  filename='report.html',
  report_dir=test_dir + '/reports',
  title='Todo List Test Report',
  desc='This is a test report for todo list',
  templates=1
)
runner.run()
py
from unittest import TestCase
from unittestreport import ddt, list_data
import requests

"""
1. @ddt 配置 @list_data 实现参数化测试(数据驱动)
2. 每个方法中的注释在测试报告中会被渲染成用例描述
"""

@ddt
class TestCreateTodo(TestCase):

    def setUp(self):
        self.base_url = 'http://localhost:8080/todos'
        self.payload = {'title': 'Test Todo', 'description': 'Test Description'}

    def test_create(self):
        """创建一个待办事项"""
        response = requests.post(self.base_url, json=self.payload)
        
        self.assertEqual(response.status_code, 201)
        self.assertEqual(response.json()['title'], 'Test Todo')
        self.assertEqual(response.json()['description'], 'Test Description')
        # 将创建的todo的id保存下来
        self.id = response.json()['id']

    @list_data([
        { 
            'title': '批量创建 01', 
            'data': { 'title': 'Test Create 1', 'description': 'Test Description 1' },
            'expected': 201
        },
        { 
            'title': '批量创建 02', 
            'data': { 'title': 'Test Create 2', 'description': 'Test Description 2' },
            'expected': 201
        }
    ])
    def test_batch_create(self, payload):
        response = requests.post(self.base_url, json=payload['data'])
        self.assertEqual(response.status_code, payload['expected'])
py
from unittest import TestCase
import requests

"""
1. 为了保证测试用例的顺序执行,在 test 后跟数字,实际上框架是根据 ASCII 码
2. 使用类的 test fixture 是因为他只会被执行一遍,方便保存临时数据
"""

class TestUpdateTodo(TestCase):

    @classmethod
    def setUpClass(cls):
        cls.todo_id = None
        cls.base_url = 'http://localhost:8080/todos'

    def test_01_update_todo(self):
        """更新一个待办事项"""
        response = requests.post(TestUpdateTodo.base_url, json={'title': 'Test Todo'})
        TestUpdateTodo.todo_id = response.json()['id']

        response = requests.put(TestUpdateTodo.base_url + '/' + TestUpdateTodo.todo_id, json={'title': 'Updated Todo'})

        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()['title'], 'Updated Todo')

    def test_02_complete_todo(self):
        """完成一个待办事项"""
        response = requests.patch(TestUpdateTodo.base_url + '/' + TestUpdateTodo.todo_id + '/complete')
        self.assertEqual(response.status_code, 200)
        self.assertTrue(response.json()['completed'])

    def test_03_incomplete_todo(self):
        """未完成一个待办事项"""
        response = requests.patch(TestUpdateTodo.base_url + '/' + TestUpdateTodo.todo_id + '/incomplete')
        self.assertEqual(response.status_code, 200)
        self.assertFalse(response.json()['completed'])

    @classmethod
    def tearDownClass(cls):
        if cls.todo_id:
            requests.delete(TestUpdateTodo.base_url + '/' + cls.todo_id)
py
from unittest import TestCase
import requests

class TestDeleteTodo(TestCase):
    def setUp(self):
        self.base_url = 'http://localhost:8080/todos'
        
    def test_delete(self):
        """删除一个待办事项"""
        # 创建一个todo
        response = requests.post(self.base_url, json={'title': 'Test Todo'})
        todo_id = response.json()['id']
        
        # 删除这个todo
        response = requests.delete(self.base_url + '/' + todo_id)
        self.assertEqual(response.status_code, 200)

    def test_delete_not_exists(self):
        """删除一个不存在的待办事项"""
        response = requests.delete(self.base_url + '/' + 'not_exists_id')
        self.assertEqual(response.status_code, 404)
        self.assertFalse('error' not in response.json())
py
from unittest import TestCase
import requests

class TestSelectTodo(TestCase):
    
    def setUp(self):
        self.base_url = 'http://localhost:8080/todos'

    def test_empty_list_select(self):
        """查询一个待办事项列表"""
        response = requests.get(self.base_url)
        self.assertEqual(response.status_code, 200)

    def test_select_one(self):
        """查询一个待办事项"""
        # 创建一个待办事项
        response = requests.post(self.base_url, json={'title': 'Test Todo'})
        todo_id = response.json()['id']
        
        response = requests.get(self.base_url + '/' + todo_id)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()['title'], 'Test Todo')
py
from flask import Flask, request, jsonify
from datetime import datetime
import uuid

app = Flask(__name__)

# 内存存储(生产环境建议使用数据库)
todos = []

class Todo:
    def __init__(self, title, description=""):
        self.id = str(uuid.uuid4())
        self.title = title
        self.description = description
        self.completed = False
        self.created_at = datetime.now().isoformat()
        self.updated_at = datetime.now().isoformat()
    
    def to_dict(self):
        return {
            'id': self.id,
            'title': self.title,
            'description': self.description,
            'completed': self.completed,
            'created_at': self.created_at,
            'updated_at': self.updated_at
        }

@app.route('/todos', methods=['GET'])
def get_todos():
    """获取所有待办事项"""
    return jsonify([todo.to_dict() for todo in todos])

@app.route('/todos/<todo_id>', methods=['GET'])
def get_todo(todo_id):
    """获取单个待办事项"""
    todo = next((t for t in todos if t.id == todo_id), None)
    if not todo:
        return jsonify({'error': 'Todo not found'}), 404
    return jsonify(todo.to_dict())

@app.route('/todos', methods=['POST'])
def create_todo():
    """创建新的待办事项"""
    data = request.get_json()
    
    if not data or 'title' not in data:
        return jsonify({'error': 'Title is required'}), 400
    
    title = data['title']
    description = data.get('description', '')
    
    todo = Todo(title, description)
    todos.append(todo)
    
    return jsonify(todo.to_dict()), 201

@app.route('/todos/<todo_id>', methods=['PUT'])
def update_todo(todo_id):
    """更新待办事项"""
    todo = next((t for t in todos if t.id == todo_id), None)
    if not todo:
        return jsonify({'error': 'Todo not found'}), 404
    
    data = request.get_json()
    if not data:
        return jsonify({'error': 'No data provided'}), 400
    
    if 'title' in data:
        todo.title = data['title']
    if 'description' in data:
        todo.description = data['description']
    if 'completed' in data:
        todo.completed = bool(data['completed'])
    
    todo.updated_at = datetime.now().isoformat()
    
    return jsonify(todo.to_dict())

@app.route('/todos/<todo_id>', methods=['DELETE'])
def delete_todo(todo_id):
    """删除待办事项"""
    global todos
    todo = next((t for t in todos if t.id == todo_id), None)
    if not todo:
        return jsonify({'error': 'Todo not found'}), 404
    
    todos = [t for t in todos if t.id != todo_id]
    return jsonify({'message': 'Todo deleted successfully'}), 200

@app.route('/todos/<todo_id>/complete', methods=['PATCH'])
def complete_todo(todo_id):
    """标记待办事项为完成"""
    todo = next((t for t in todos if t.id == todo_id), None)
    if not todo:
        return jsonify({'error': 'Todo not found'}), 404
    
    todo.completed = True
    todo.updated_at = datetime.now().isoformat()
    
    return jsonify(todo.to_dict())

@app.route('/todos/<todo_id>/incomplete', methods=['PATCH'])
def incomplete_todo(todo_id):
    """标记待办事项为未完成"""
    todo = next((t for t in todos if t.id == todo_id), None)
    if not todo:
        return jsonify({'error': 'Todo not found'}), 404
    
    todo.completed = False
    todo.updated_at = datetime.now().isoformat()
    
    return jsonify(todo.to_dict())

@app.errorhandler(404)
def not_found(error):
    return jsonify({'error': 'Not found'}), 404

@app.errorhandler(500)
def internal_error(error):
    return jsonify({'error': 'Internal server error'}), 500

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=8080)

执行 uv run python src/tests/run.py 即可得到对应的测试报告。

alt text