351 lines
8.5 KiB
Markdown
351 lines
8.5 KiB
Markdown
# 测试最佳实践
|
||
|
||
:::tip 说明
|
||
本文在 [测试规范](/zh/volume7/testing.html) 基础上,介绍更高级的测试技巧,包括 Mock、Fixture、API 测试、覆盖率实战等。
|
||
:::
|
||
|
||
## Mock 与 Patch
|
||
|
||
### 什么是 Mock
|
||
|
||
Mock 用于模拟外部依赖,隔离被测代码:
|
||
|
||
```python
|
||
# 不使用 Mock:测试依赖 Redis 连接
|
||
def test_get_captcha():
|
||
# 如果 Redis 未启动,测试会失败
|
||
captcha = generate_captcha() # 依赖 Redis 存储
|
||
|
||
# 使用 Mock:隔离 Redis 依赖
|
||
@patch('utils.redis.get_redis_connection')
|
||
def test_get_captcha(mock_redis):
|
||
mock_redis.return_value = MagicMock()
|
||
captcha = generate_captcha() # 不依赖真实 Redis
|
||
```
|
||
|
||
### unittest.mock.patch
|
||
|
||
```python
|
||
from unittest.mock import patch, MagicMock
|
||
|
||
class UserServiceTest(TestCase):
|
||
"""用户服务测试"""
|
||
|
||
@patch('application.user.services.get_redis_connection')
|
||
def test_login_with_mock_redis(self, mock_redis):
|
||
"""测试登录(Mock Redis)"""
|
||
# 配置 Mock
|
||
mock_redis.return_value.get.return_value = None
|
||
|
||
# 执行测试
|
||
result = login_service(request)
|
||
|
||
# 验证结果
|
||
self.assertEqual(result['code'], 0)
|
||
|
||
@patch('utils.common.get_client_ip')
|
||
def test_record_log_with_mock_ip(self, mock_ip):
|
||
"""测试记录日志(Mock IP 获取)"""
|
||
mock_ip.return_value = '127.0.0.1'
|
||
|
||
record_login_log(request, 'admin', 1, 0)
|
||
# 验证日志记录
|
||
```
|
||
|
||
### Mock 返回值配置
|
||
|
||
```python
|
||
# Mock 返回固定值
|
||
mock_func.return_value = 'mocked_value'
|
||
|
||
# Mock 返回不同值(按调用顺序)
|
||
mock_func.side_effect = ['first', 'second', 'third']
|
||
|
||
# Mock 抛出异常
|
||
mock_func.side_effect = Exception('connection failed')
|
||
|
||
# Mock 根据参数返回不同值
|
||
def side_effect_func(arg):
|
||
if arg == 'valid':
|
||
return True
|
||
return False
|
||
mock_func.side_effect = side_effect_func
|
||
```
|
||
|
||
## Fixture 管理
|
||
|
||
### Django TestCase setUp
|
||
|
||
```python
|
||
class ExampleServiceTest(TestCase):
|
||
"""案例服务测试"""
|
||
|
||
def setUp(self):
|
||
"""测试前准备:创建测试数据"""
|
||
self.user = User.objects.create(
|
||
username='testuser',
|
||
realname='测试用户',
|
||
password=make_password('123456'),
|
||
status=1
|
||
)
|
||
|
||
self.example = Example.objects.create(
|
||
name='测试案例',
|
||
type=1,
|
||
status=1,
|
||
sort=1,
|
||
create_user='testuser'
|
||
)
|
||
|
||
def tearDown(self):
|
||
"""测试后清理(可选,Django TestCase 自动回滚)"""
|
||
pass
|
||
```
|
||
|
||
### 批量创建测试数据
|
||
|
||
```python
|
||
def create_test_examples(count=10):
|
||
"""批量创建测试数据"""
|
||
examples = []
|
||
for i in range(count):
|
||
examples.append(Example(
|
||
name=f'案例{i}',
|
||
type=i % 3 + 1,
|
||
status=1,
|
||
sort=i,
|
||
create_user='admin'
|
||
))
|
||
Example.objects.bulk_create(examples)
|
||
return examples
|
||
```
|
||
|
||
### Factory Boy(推荐大型项目)
|
||
|
||
```python
|
||
# pip install factory-boy
|
||
|
||
import factory
|
||
from application.example.models import Example
|
||
|
||
class ExampleFactory(factory.django.DjangoModelFactory):
|
||
"""案例工厂"""
|
||
class Meta:
|
||
model = Example
|
||
|
||
name = factory.Sequence(lambda n: f'案例{n}')
|
||
type = 1
|
||
status = 1
|
||
sort = factory.Sequence(lambda n: n)
|
||
create_user = 'admin'
|
||
|
||
# 使用
|
||
example = ExampleFactory() # 创建单个
|
||
examples = ExampleFactory.create_batch(10) # 批量创建
|
||
example = ExampleFactory(name='自定义名称') # 自定义字段
|
||
```
|
||
|
||
## API 测试
|
||
|
||
### Django Client
|
||
|
||
```python
|
||
from django.test import TestCase, Client
|
||
|
||
class ExampleViewTest(TestCase):
|
||
"""案例视图测试"""
|
||
|
||
def setUp(self):
|
||
self.client = Client()
|
||
# 创建测试数据
|
||
Example.objects.create(name='案例1', type=1, status=1, sort=1)
|
||
|
||
def test_page_without_login(self):
|
||
"""测试未登录访问"""
|
||
response = self.client.get('/example/page')
|
||
self.assertIn(response.status_code, [401, 302])
|
||
|
||
def test_page_with_login(self):
|
||
"""测试登录后访问"""
|
||
# 模拟登录
|
||
self.client.force_login(User.objects.first())
|
||
|
||
response = self.client.get('/example/page')
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
data = response.json()
|
||
self.assertEqual(data['code'], 0)
|
||
self.assertIn('records', data['data'])
|
||
```
|
||
|
||
### RequestFactory
|
||
|
||
```python
|
||
from django.test import TestCase, RequestFactory
|
||
|
||
class ExampleServiceTest(TestCase):
|
||
"""案例服务测试"""
|
||
|
||
def setUp(self):
|
||
self.factory = RequestFactory()
|
||
|
||
def test_get_page(self):
|
||
"""测试分页查询"""
|
||
request = self.factory.get('/example/page', {'pageNo': 1, 'pageSize': 10})
|
||
result = get_example_page(request)
|
||
self.assertEqual(result.status_code, 200)
|
||
|
||
def test_add(self):
|
||
"""测试添加"""
|
||
request = self.factory.post(
|
||
'/example/add',
|
||
data=json.dumps({'name': '新案例', 'type': 1}),
|
||
content_type='application/json'
|
||
)
|
||
result = add_example(request)
|
||
self.assertEqual(result.status_code, 200)
|
||
```
|
||
|
||
## 常见测试场景
|
||
|
||
### 权限测试
|
||
|
||
```python
|
||
class PermissionTest(TestCase):
|
||
"""权限测试"""
|
||
|
||
def test_admin_bypass_permission(self):
|
||
"""测试管理员跳过权限检查"""
|
||
admin = User.objects.get(id=1)
|
||
self.client.force_login(admin)
|
||
|
||
response = self.client.get('/example/page')
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_user_permission_check(self):
|
||
"""测试普通用户权限检查"""
|
||
user = User.objects.create(username='normal_user')
|
||
self.client.force_login(user)
|
||
|
||
response = self.client.get('/example/page')
|
||
# 无权限时应返回 401
|
||
self.assertIn(response.status_code, [401, 403])
|
||
```
|
||
|
||
### 文件上传测试
|
||
|
||
```python
|
||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||
|
||
class UploadTest(TestCase):
|
||
"""文件上传测试"""
|
||
|
||
def test_upload_image(self):
|
||
"""测试上传图片"""
|
||
image = SimpleUploadedFile(
|
||
name='test.png',
|
||
content=b'\x89PNG\r\n...',
|
||
content_type='image/png'
|
||
)
|
||
|
||
response = self.client.post('/upload/uploadFile', {'file': image})
|
||
self.assertEqual(response.status_code, 200)
|
||
```
|
||
|
||
### 软删除测试
|
||
|
||
```python
|
||
class SoftDeleteTest(TestCase):
|
||
"""软删除测试"""
|
||
|
||
def test_soft_delete(self):
|
||
"""测试软删除"""
|
||
example = Example.objects.create(name='待删除', type=1, status=1)
|
||
|
||
# 软删除
|
||
example.is_delete = True
|
||
example.save()
|
||
|
||
# 验证查询不到
|
||
self.assertFalse(
|
||
Example.objects.filter(is_delete=False, id=example.id).exists()
|
||
)
|
||
|
||
# 验证包含已删除可以查到
|
||
self.assertTrue(
|
||
Example.objects.filter(id=example.id).exists()
|
||
)
|
||
```
|
||
|
||
## 测试覆盖率
|
||
|
||
### 配置 coverage
|
||
|
||
```bash
|
||
# 安装
|
||
pip install coverage
|
||
|
||
# 运行测试并生成覆盖率
|
||
coverage run --source='application' manage.py test
|
||
|
||
# 查看报告
|
||
coverage report
|
||
|
||
# 生成 HTML 报告
|
||
coverage html
|
||
|
||
# 查看未覆盖的行
|
||
coverage report --show-missing
|
||
```
|
||
|
||
### 覆盖率目标
|
||
|
||
| 层级 | 目标 | 说明 |
|
||
|------|------|------|
|
||
| Model 层 | 90%+ | 模型方法、字段约束 |
|
||
| Service 层 | 80%+ | 业务逻辑、异常处理 |
|
||
| View 层 | 70%+ | 请求处理、权限校验 |
|
||
| 工具函数 | 90%+ | 通用工具函数 |
|
||
|
||
### CI 集成
|
||
|
||
```yaml
|
||
# .github/workflows/test.yml
|
||
- name: Run tests
|
||
run: |
|
||
coverage run --source='application' manage.py test
|
||
coverage report --fail-under=80
|
||
```
|
||
|
||
## 测试组织
|
||
|
||
### 按模块组织
|
||
|
||
```
|
||
application/
|
||
├── user/
|
||
│ ├── models.py
|
||
│ ├── services.py
|
||
│ ├── views.py
|
||
│ └── tests.py # 该模块的测试
|
||
├── role/
|
||
│ └── tests.py
|
||
└── example/
|
||
└── tests.py
|
||
```
|
||
|
||
### 按类型组织
|
||
|
||
```
|
||
tests/
|
||
├── test_models.py # 模型测试
|
||
├── test_services.py # Service 层测试
|
||
├── test_views.py # View 层测试
|
||
├── test_forms.py # Form 层测试
|
||
└── test_utils.py # 工具函数测试
|
||
```
|
||
|
||
## 总结
|
||
|
||
测试最佳实践涵盖 Mock/patch(隔离外部依赖)、Fixture(测试数据管理)、API 测试(Client/RequestFactory)、常见场景(权限/上传/软删除)、覆盖率(coverage + CI 集成)等方面。核心原则:隔离依赖、覆盖关键路径、自动化运行。
|