Python单元测试与pytest框架

一、为什么需要单元测试

单元测试是软件开发中的重要实践,它可以:
- 验证代码的正确性
- 防止回归错误
- 提高代码质量
- 作为代码文档
- 促进更好的设计

二、unittest基础

Python内置的unittest模块提供了基本的测试框架。

2.1 基本测试用例

import unittest

def add(a, b):
return a + b

class TestMath(unittest.TestCase):
def test_add_positive(self):
self.assertEqual(add(2, 3), 5)

def test_add_negative(self):
self.assertEqual(add(-1, -1), -2)

def test_add_zero(self):
self.assertEqual(add(5, 0), 5)

if __name__ == '__main__':
unittest.main()

2.2 常用断言方法

class TestAssertions(unittest.TestCase):
def test_equality(self):
self.assertEqual(1 + 1, 2)
self.assertNotEqual(1, 2)

def test_boolean(self):
self.assertTrue(True)
self.assertFalse(False)

def test_none(self):
self.assertIsNone(None)
self.assertIsNotNone(1)

def test_membership(self):
self.assertIn(1, [1, 2, 3])
self.assertNotIn(4, [1, 2, 3])

def test_exceptions(self):
with self.assertRaises(ValueError):
int('invalid')

def test_almost_equal(self):
self.assertAlmostEqual(0.1 + 0.2, 0.3)

三、测试夹具(Fixtures)

测试夹具用于设置和清理测试环境。

class TestDatabase(unittest.TestCase):
def setUp(self):
"""每个测试方法前执行"""
self.db = Database()
self.db.connect()

def tearDown(self):
"""每个测试方法后执行"""
self.db.disconnect()

def test_insert(self):
self.db.insert('test')
self.assertEqual(self.db.count(), 1)

def test_delete(self):
self.db.insert('test')
self.db.delete('test')
self.assertEqual(self.db.count(), 0)

@classmethod
def setUpClass(cls):
"""所有测试前执行一次"""
print("设置测试类")

@classmethod
def tearDownClass(cls):
"""所有测试后执行一次"""
print("清理测试类")

四、pytest入门

pytest是更强大、更灵活的测试框架。

4.1 基本测试

# test_math.py
def add(a, b):
return a + b

def test_add():
assert add(2, 3) == 5

def test_add_negative():
assert add(-1, -1) == -2

# 运行: pytest test_math.py

4.2 pytest的优势

- 使用简单的assert语句
- 自动发现测试
- 丰富的插件生态
- 更好的错误报告
- 支持参数化测试

五、pytest夹具

5.1 基本夹具

import pytest

@pytest.fixture
def sample_data():
"""提供测试数据"""
return [1, 2, 3, 4, 5]

def test_sum(sample_data):
assert sum(sample_data) == 15

def test_length(sample_data):
assert len(sample_data) == 5

5.2 夹具作用域

@pytest.fixture(scope='function') # 默认,每个测试函数
def func_fixture():
return "function"

@pytest.fixture(scope='class') # 每个测试类
def class_fixture():
return "class"

@pytest.fixture(scope='module') # 每个模块
def module_fixture():
return "module"

@pytest.fixture(scope='session') # 整个测试会话
def session_fixture():
return "session"

5.3 夹具的setup和teardown

@pytest.fixture
def database():
# Setup
db = Database()
db.connect()
print("数据库已连接")

yield db # 提供给测试

# Teardown
db.disconnect()
print("数据库已断开")

def test_query(database):
result = database.query("SELECT * FROM users")
assert len(result) > 0

六、参数化测试

6.1 使用@pytest.mark.parametrize

import pytest

@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(10, -5, 5),
])
def test_add(a, b, expected):
assert add(a, b) == expected

6.2 多个参数组合

@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
assert x * y == y * x

# 会生成6个测试:(1,10), (1,20), (2,10), (2,20), (3,10), (3,20)

七、测试标记

7.1 跳过测试

import pytest

@pytest.mark.skip(reason="暂时跳过")
def test_not_ready():
pass

@pytest.mark.skipif(sys.version_info < (3, 8), reason="需要Python 3.8+")
def test_new_feature():
pass

7.2 预期失败

@pytest.mark.xfail(reason="已知bug")
def test_known_bug():
assert 1 == 2

7.3 自定义标记

# pytest.ini
[pytest]
markers =
slow: 标记慢速测试
integration: 标记集成测试

# 使用标记
@pytest.mark.slow
def test_slow_operation():
time.sleep(5)

@pytest.mark.integration
def test_api_integration():
pass

# 运行特定标记的测试
# pytest -m slow
# pytest -m "not slow"

八、Mock和Patch

8.1 使用unittest.mock

from unittest.mock import Mock, patch

def get_user_data(user_id):
# 假设这会调用外部API
response = requests.get(f'https://api.example.com/users/{user_id}')
return response.json()

def test_get_user_data():
with patch('requests.get') as mock_get:
# 配置mock返回值
mock_get.return_value.json.return_value = {'id': 1, 'name': 'Alice'}

result = get_user_data(1)

assert result['name'] == 'Alice'
mock_get.assert_called_once_with('https://api.example.com/users/1')

8.2 Mock对象

def test_mock_object():
mock = Mock()

# 设置返回值
mock.method.return_value = 42
assert mock.method() == 42

# 设置副作用
mock.method.side_effect = ValueError("错误")
with pytest.raises(ValueError):
mock.method()

# 验证调用
mock.method.assert_called()
mock.method.assert_called_with()

8.3 pytest-mock插件

def test_with_mocker(mocker):
# mocker是pytest-mock提供的夹具
mock_get = mocker.patch('requests.get')
mock_get.return_value.json.return_value = {'data': 'test'}

result = get_user_data(1)
assert result['data'] == 'test'

九、测试覆盖率

9.1 使用pytest-cov

# 安装: pip install pytest-cov

# 运行测试并生成覆盖率报告
# pytest --cov=myproject tests/

# 生成HTML报告
# pytest --cov=myproject --cov-report=html tests/

9.2 配置覆盖率

# .coveragerc
[run]
source = myproject
omit =
*/tests/*
*/venv/*

[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError

十、测试异常

10.1 使用pytest.raises

def divide(a, b):
if b == 0:
raise ValueError("除数不能为0")
return a / b

def test_divide_by_zero():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)

assert "除数不能为0" in str(exc_info.value)

10.2 测试异常消息

def test_exception_message():
with pytest.raises(ValueError, match=r"除数不能为0"):
divide(10, 0)

十一、测试组织

11.1 目录结构

project/
├── myproject/
│ ├── __init__.py
│ ├── module1.py
│ └── module2.py
└── tests/
├── __init__.py
├── test_module1.py
└── test_module2.py

11.2 conftest.py

conftest.py用于共享夹具和配置。

# tests/conftest.py
import pytest

@pytest.fixture
def sample_user():
return {'id': 1, 'name': 'Alice'}

# 所有测试文件都可以使用这个夹具

十二、实战案例:测试Web应用

# app.py
from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/api/users/')
def get_user(user_id):
# 假设从数据库获取
user = {'id': user_id, 'name': 'Alice'}
return jsonify(user)

# test_app.py
import pytest
from app import app

@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client

def test_get_user(client):
response = client.get('/api/users/1')
assert response.status_code == 200
data = response.get_json()
assert data['id'] == 1

十三、实战案例:测试数据库操作

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

@pytest.fixture(scope='function')
def db_session():
# 使用内存数据库
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(engine)

Session = sessionmaker(bind=engine)
session = Session()

yield session

session.close()

def test_create_user(db_session):
user = User(name='Alice', email='alice@example.com')
db_session.add(user)
db_session.commit()

assert user.id is not None
assert db_session.query(User).count() == 1

十四、测试最佳实践

1. 测试应该独立且可重复
2. 一个测试只测试一个功能点
3. 使用描述性的测试名称
4. 遵循AAA模式:Arrange(准备)、Act(执行)、Assert(断言)
5. 不要测试实现细节,测试行为
6. 使用夹具避免重复代码
7. 保持测试简单易懂
8. 定期运行测试
9. 追求高覆盖率,但不要为了覆盖率而测试

十五、pytest配置

# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --strict-markers --tb=short

十六、持续集成

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run tests
run: pytest --cov=myproject tests/

十七、总结

单元测试是保证代码质量的重要手段。pytest提供了强大而灵活的测试框架,通过夹具、参数化、标记等特性,可以编写清晰、可维护的测试代码。结合Mock、覆盖率工具和持续集成,可以构建完整的测试体系。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐