在后端开发中,自己写测试样例还是非常重要的,不然每次修改程序以后手动测试,工作量又大,还很难测完整。

Django 项目中用到的测试主要是集成测试。

同作为 Web 框架的 Spring Boot 可以单元测试和集成测试,因为 Spring Boot 项目的分层很明显 (Controller, Service, DAO),可以对其中一层进行单元测试。

而 Django 框架本身已经实现了大部分功能 (DAO 由 Django Models 实现、Controller 由 Django Router 实现),只剩下 Service 业务逻辑部分需要做测试,所以直接集成测试就可以了。

Django runserver 时测试 API

DRF 教程提到,在 runserver 时手动测试看效果时可以使用 httpie 或者其他工具。但是 POST 数据似乎有点麻烦。我更常使用 Python 的 requests 库。

1
pip install requests
1
2
3
4
5
from requests import get, post, put, delete
post("http://localhost:8000/login/", {
"username": "lyh543",
"password": "password"
})

语法和 django.test.client.Client 几乎一模一样。

Django test 环境初始化和清理

在编写一个 django.test.TestCase 类的每个函数时,可能涉及到某些重复步骤。如对活动内容进行测试前都需要创建一个活动。文档里提到,可以把相同的准备工作写为这个测试类的 setUp 方法,这个方法在每个测试函数之前都会被调用一次。

而与 setUp 对应的就是 tearDown 方法,它可以完成每个测试函数以后的清理工作(如清空邮箱、删除测试文件)。需要注意的是,一般来说不需要清理数据库,因为 Django 的每个测试函数都是一个事务,测试完成后会回滚。所以如果测试函数只修改了数据库,就不需要单独编写 tearDown 函数了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 重置密码相关测试
class ResetPasswordTest(TestCase):
email = "admin@example.com"
password = "adminadmin"

def setUp(self):
tester_signup(self.email, self.password, 'Admin', '20210101')
self.user = User.objects.first()

def tearDown(self):
mail.outbox.clear()

# Django test 中不会真的发送邮件
# 文档:https://docs.djangoproject.com/zh-hans/3.1/topics/testing/tools/#email-services
def test_forget_password_whole_process(self):
# ...
pass

setUptearDown 在每个函数执行前/后都会执行,而 setUpClasstearDownClass 就是在测试类执行前/后执行。(注意 Django 也编写了 setUpClasstearDownClass,因此重写的时候,不要忘了 super().setUpClass()

Django test 测试邮件服务

Django test 还会替换掉默认的 SMTP 服务器,改为一个虚拟的、不会真正发送邮件的服务器。

文档:https://docs.djangoproject.com/zh-hans/3.1/topics/testing/tools/#email-services

官方也给了一个读取发件箱的方法,这样每次测试的时候就不用人工查询邮件,而是直接在测试代码里读取邮件信息,再配合正则表达式就可以提取出需要的信息了。下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
from django.core import mail

def pop_token_from_virtual_mailbox(test_function):
"""
测试时从虚拟的邮箱中找到验证码,并清空测试发件箱
虚拟邮箱:https://docs.djangoproject.com/zh-hans/3.1/topics/testing/tools/#email-services
调用示例:https://github.com/uestc-msc/uestcmsc_webapp_backend/blob/5ca6316e6de8c42f28e3b7e9f0866b5cba4280c8/users/tests.py#L188
"""
test_function.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0].message().as_string()
mail.outbox = []
token = re.findall('token=.+', message)[0][6:]
return token

上面这个函数自动抓取发送邮件中的 token=XXXXX 字段中的 XXXXX,保存到 token 变量然后返回。

Django test mock 当前时间

我在写签到的 TestCase 的时候,想要修改 now() 时间来进行测试。Google 了一下找到了 mock 的几种写法,这里演示一种(源代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from unittest import mock

class ActivityCheckInTest(TestCase):
@mock.patch('activities.views.now')
def test_check_in_anytime(self, mocked_now): # 需要在 test 函数的参数部分增加一个参数
for day in [1, 2, 3]:
is_today = day == 2
for hour in range(24):
mocked_now.return_value = datetime(2020, 1, day, hour, 15, tzinfo=pytz.timezone('Asia/Shanghai')) # 可任意修改 activities.views.now 的参数
client = Client()
client.force_login(self.user)
response = client.post(activity_check_in_url(self.activity.id), { # 此处测试的 activities.views.now 会返回上面的 return_value
"check_in_code": self.activity.check_in_code
})
self.assertEqual(response.status_code, 200 if is_today else 403, f'date={mocked_now.return_value}')
self.activity.refresh_from_db()
self.assertEqual(self.activity.attender.count(), 1 if is_today else 0)
self.activity.attender.clear()

对于整个类的每个测试函数都需要 mock 的情况,可以参考 Applying the same patch to every test method - unittest.mock

mock 的对象是类和函数,如果想修改变量,直接赋值修改就可以了,不需要 mock。

Django test 和 Integration Error?

我在写登录的 TestCase 时出现了很奇怪的现象:正常运行时 API 貌似没有问题,在一个 Test 函数中调用一次 login 函数也没有问题,但如果调用两次 login 函数,Python
解释器会不报错而停止,错误码为 -1073741819 (0xC0000005)login() 函数如下:

1
2
3
4
5
6
7
8
9
10
def login(request):
try:
username = request.data['username']
password = request.data['password']
with transaction.atomic():
user = authenticate(request, username=username, password=password)
django_login(request, user)
return Response(status=status.HTTP_200_OK)
except IntegrityError or KeyError:
return Response(status=status.HTTP_401_UNAUTHORIZED)

test 函数如下:

1
2
3
4
5
6
7
class LogInTest(TestCase):
def test_log_in_with_less_argument(self):
r = Client().post('/users/login/')
self.assertEqual(r.status_code, 401)

r = Client().post('/users/login/')
self.assertEqual(r.status_code, 401)

我参考了 Django 文档的 事务 部分,按照官方推荐的方法编写这段代码,但是出了问题。

个人猜测可能是 TestCase 中涉及的数据库回滚和 IntegrityError 触发回滚的冲突?

最终我只能按照 if 的方法替代掉 try-catch 的方法。尽量不要触发 IntegrityError 吧。

Django test 时,POST 和 PATCH 记得添加 content_type=’application/json’

笔者已经两次被这个坑了。第一次是在测试 PATCH 时,使用 django.test.client.Client.patch(path, data),返回的 HTTP 状态码为 415 Unsupported media type "application/octet-stream" in request.'

添加参数 Client.patch(path, data, content_type='application/json') 就好了。

Getting 415 code with django.test.Client’s patch method

后来,在 POST 的时候莫名其妙发现我写的下面这段 JSON,手动 POST 时能正常工作,但使用 Client.post(path, data) 时,嵌套的 {"id":1} 部分不能被正确识别到。

1
2
3
4
5
6
{
"title": "test",
"datetime": "2021-01-20T10:29:26+08:00",
"location": "test",
"presenter": [{"id":1}]
}

DEBUG 的时候注意到,response 中包含的 wsgi_request 里面,{"id":1} 就没有被正确提交。猜测可能是 Django Client 没有以 JSON 的形式解析这段代码,于是加上 content_type='application/json',就返回 201 Created 了。