1. Python单元测试基础认知
单元测试是软件开发中不可或缺的一环,它专注于验证代码中最小的可测试单元(通常是函数或方法)的正确性。Python内置的unittest框架为我们提供了完整的测试解决方案,它借鉴了JUnit的设计理念,采用面向对象的方式组织测试用例。
重要提示:良好的单元测试覆盖率可以降低约40%的生产环境缺陷率,这是Martin Fowler在《重构》中引用的行业研究数据
unittest框架包含四个核心概念:
- 测试夹具(Test Fixture):通过setUp()和tearDown()方法管理测试环境
- 测试用例(Test Case):继承unittest.TestCase的最小测试单元
- 测试套件(Test Suite):测试用例的集合
- 测试运行器(Test Runner):执行并输出测试结果的组件
2. unittest框架实战演练
2.1 基础测试用例编写
我们先创建一个计算器类作为被测对象(calculator.py):
class Calculator: """简易计算器实现""" def add(self, a, b): """加法运算""" if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): raise TypeError("参数必须是数值类型") 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("除数不能为零") return a / b对应的测试用例(test_calculator.py):
import unittest from calculator import Calculator class TestCalculator(unittest.TestCase): """Calculator类测试用例""" def setUp(self): """每个测试方法执行前运行""" self.calc = Calculator() def test_add_integers(self): """整数加法测试""" result = self.calc.add(2, 3) self.assertEqual(result, 5) def test_add_floats(self): """浮点数加法测试""" result = self.calc.add(2.5, 3.7) self.assertAlmostEqual(result, 6.2, places=1) def test_add_type_error(self): """类型错误测试""" with self.assertRaises(TypeError): self.calc.add("two", 3) def test_divide_by_zero(self): """除零异常测试""" with self.assertRaises(ValueError): self.calc.divide(10, 0) def test_multiply_negative(self): """负数乘法测试""" result = self.calc.multiply(-2, 3) self.assertEqual(result, -6) if __name__ == '__main__': unittest.main()2.2 高级断言方法
unittest提供了丰富的断言方法:
| 断言方法 | 检查条件 | 适用场景 |
|---|---|---|
| assertEqual(a, b) | a == b | 常规相等性检查 |
| assertNotEqual(a, b) | a != b | 不等性检查 |
| assertTrue(x) | bool(x) is True | 布尔真值检查 |
| assertFalse(x) | bool(x) is False | 布尔假值检查 |
| assertIs(a, b) | a is b | 同一性检查 |
| assertIsNot(a, b) | a is not b | 非同一性检查 |
| assertIsNone(x) | x is None | None值检查 |
| assertIsNotNone(x) | x is not None | 非None检查 |
| assertIn(a, b) | a in b | 包含关系检查 |
| assertNotIn(a, b) | a not in b | 不包含检查 |
| assertIsInstance(a, b) | isinstance(a, b) | 类型检查 |
| assertNotIsInstance(a, b) | not isinstance(a, b) | 非类型检查 |
| assertAlmostEqual(a, b) | round(a-b, 7) == 0 | 浮点数近似相等 |
| assertNotAlmostEqual(a, b) | round(a-b, 7) != 0 | 浮点数不近似相等 |
| assertRaises(exc, fun, *args, **kwds) | fun(*args, **kwds) raises exc | 异常检查 |
2.3 测试发现与组织
unittest支持自动发现测试:
# 发现并运行当前目录下所有test_*.py文件 python -m unittest discover # 指定测试目录 python -m unittest discover -s tests # 运行单个测试模块 python -m unittest test_module # 运行单个测试类 python -m unittest test_module.TestClass # 运行单个测试方法 python -m unittest test_module.TestClass.test_method3. 测试覆盖率与质量保障
3.1 安装覆盖率工具
pip install coverage3.2 生成覆盖率报告
# 运行测试并收集覆盖率数据 coverage run -m unittest discover # 生成控制台报告 coverage report -m # 生成HTML报告 coverage html理想的覆盖率目标:
- 核心业务逻辑:100%
- 工具类/辅助函数:90%+
- 整体项目:80%+
实践建议:不要盲目追求100%覆盖率,应该优先保证核心业务逻辑的完整覆盖
4. 高级测试技巧
4.1 参数化测试
使用subTest实现参数化测试:
class TestParameterized(unittest.TestCase): def test_multiple_cases(self): """使用subTest进行参数化测试""" test_cases = [ (1, 1, 2), (2, 3, 5), (-1, -1, -2), (0, 0, 0) ] for a, b, expected in test_cases: with self.subTest(f"{a}+{b}={expected}"): result = a + b self.assertEqual(result, expected)4.2 跳过测试与条件跳过
class TestSkip(unittest.TestCase): @unittest.skip("演示跳过测试") def test_skip(self): self.fail("不应该执行") @unittest.skipIf(1 > 0, "条件为真时跳过") def test_skip_if(self): self.fail("不应该执行") @unittest.skipUnless(sys.platform.startswith("win"), "需要Windows平台") def test_windows_only(self): # Windows特定测试 pass4.3 模拟对象(Mock)
from unittest.mock import Mock, patch class TestMock(unittest.TestCase): def test_mock_method(self): """模拟方法调用""" mock = Mock() mock.method.return_value = "mocked" self.assertEqual(mock.method(), "mocked") mock.method.assert_called_once() @patch('os.getcwd') def test_patch_decorator(self, mock_getcwd): """使用patch装饰器模拟""" mock_getcwd.return_value = "/fake/path" self.assertEqual(os.getcwd(), "/fake/path")5. Django项目中的单元测试
5.1 模型测试
from django.test import TestCase from myapp.models import Product class ProductModelTest(TestCase): def setUp(self): Product.objects.create( name="测试产品", price=99.99, stock=100 ) def test_product_creation(self): """测试产品创建""" product = Product.objects.get(name="测试产品") self.assertEqual(product.price, 99.99) self.assertEqual(product.stock, 100) def test_price_validation(self): """测试价格验证""" from django.core.exceptions import ValidationError product = Product(name="无效价格", price=-10) with self.assertRaises(ValidationError): product.full_clean()5.2 视图测试
from django.urls import reverse from django.test import TestCase class ProductViewTest(TestCase): def test_product_list_view(self): """测试产品列表视图""" response = self.client.get(reverse('product-list')) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'products/list.html') self.assertContains(response, "产品列表") def test_product_create_view(self): """测试产品创建视图""" data = { 'name': '新测试产品', 'price': 199.99, 'stock': 50 } response = self.client.post(reverse('product-create'), data) self.assertEqual(response.status_code, 302) # 重定向 self.assertTrue(Product.objects.filter(name='新测试产品').exists())5.3 API测试
from rest_framework.test import APITestCase from rest_framework import status class ProductAPITest(APITestCase): def setUp(self): self.product = Product.objects.create( name="API测试产品", price=299.99, stock=200 ) def test_product_list_api(self): """测试产品列表API""" response = self.client.get('/api/products/') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) def test_product_detail_api(self): """测试产品详情API""" url = f'/api/products/{self.product.id}/' response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['name'], 'API测试产品')6. 测试优化策略
6.1 测试数据工厂
使用factory_boy创建测试数据:
pip install factory_boy创建工厂类:
import factory from myapp.models import Product class ProductFactory(factory.django.DjangoModelFactory): class Meta: model = Product name = factory.Faker('word') price = factory.Faker('pydecimal', left_digits=3, right_digits=2, positive=True) stock = factory.Faker('random_int', min=0, max=1000)在测试中使用:
def test_with_factory(): product = ProductFactory() assert product.price > 06.2 测试性能优化
- 使用setUpTestData替代setUp:
class TestPerformance(TestCase): @classmethod def setUpTestData(cls): """整个测试类只执行一次""" cls.product = ProductFactory.create_batch(100) def test_performance(self): """性能测试""" # 使用预先创建的数据 pass- 使用事务加速测试:
from django.test import TransactionTestCase class FastTest(TransactionTestCase): """对于需要测试事务行为的用例使用"""6.3 持续集成配置
示例GitLab CI配置(.gitlab-ci.yml):
stages: - test unit_test: stage: test image: python:3.9 before_script: - pip install -r requirements.txt script: - python manage.py test --noinput - coverage run -m pytest - coverage xml artifacts: reports: cobertura: coverage.xml only: - merge_requests - master7. 常见问题与解决方案
7.1 测试数据库问题
问题:测试数据库未正确重置解决:确保使用TransactionTestCase或添加--keepdb参数
python manage.py test --keepdb7.2 测试依赖问题
问题:测试执行顺序影响结果解决:确保每个测试都是独立的,使用setUp创建干净环境
7.3 慢速测试
优化方案:
- 使用Mock替代外部API调用
- 减少数据库操作
- 并行运行测试:
pip install pytest-xdist pytest -n auto7.4 测试失败诊断
当测试失败时,检查:
- 测试数据是否正确
- 环境变量是否设置
- 模拟对象行为是否符合预期
- 时间相关测试是否考虑时区
使用--pdb调试失败测试:
python -m pytest --pdb8. 测试最佳实践
命名规范:
- 测试模块:test_*.py
- 测试类:Test* 或 *TestCase
- 测试方法:test_*
测试结构:
def test_method_should_do_something_when_condition(self): # 准备 (Arrange) obj = ClassUnderTest() # 执行 (Act) result = obj.method() # 断言 (Assert) self.assertEqual(expected, result)测试原则:
- 每个测试只验证一件事
- 避免测试实现细节
- 测试应该稳定可靠
- 测试应该快速执行
测试金字塔:
- 单元测试:70%
- 集成测试:20%
- E2E测试:10%
测试文档:
- 为复杂测试添加docstring
- 使用有意义的断言消息
- 记录测试的设计决策
在实际项目中,我通常会建立一个tests目录结构如下:
tests/ ├── unit/ │ ├── models/ │ ├── services/ │ └── utils/ ├── integration/ │ ├── api/ │ └── workflows/ └── e2e/ ├── ui/ └── api/这种结构可以清晰地组织不同层次的测试,便于团队协作和维护。对于大型项目,建议将测试与业务代码分离但保持相同的包结构,这样既保持了内聚性又避免了测试代码污染生产代码。