在接触开源社区Github之后,发现特别多的开源项目都会有单元测试TestCase。但是在步入工作后,从业了两个创业公司,发现大多数程序员都没有养成写单元测试的习惯。
在目前的公司面试了一些程序员,他们的工作经验平均都有三年以上,但是都没有编写单元测试的习惯。
问到"为什么不去编写单元测试呢?",无非就是回答"没有时间"、"写的都是接口,直接用客户端工具测试一下就可以了"。
在笔者使用了Django框架自带的TestCase之后,发现用TestCase测试接口不仅比一些客户端工具方便,而且还能降低在对代码进行修改之后出现BUG的几率,
特别是一些对代码有严重的洁癖喜欢优化代码的程序员来说真的非常有用。
而且运用框架的TestCase编写单元测试,还能结合一些CI工具来实现自动化测试,这个我也会专门写一篇文章来介绍我利用Gitlab CI结合Django的TestCase实现自动化测试的一些心得。
为了方便没用用过TestCase的读者,先简单介绍一下TestCase的类结构。
常见的TestCase由setUp函数、tearDown函数和test_func组成。
这里test_func是指你编写了测试逻辑的函数,而setUp函数则是在test_func函数之前执行的函数,tearDown函数则是在test_func执行之后执行的函数。
from django.test import TestCase
class Demo(TestCase):
def setUp(self):
print('setUp')
def tearDown(self):
print('tearDown')
def test_demo(self):
print('test_demo')
def test_demo_2(self):
print('test_demo2')我们可以通过在Django项目的根目录运行以下命令来运行这个单元测试
python manage.py test development_of_test_habits.tests.test_demo.Demo
如果使用Pycharm来运行的话可以直接点击类左侧的运行箭头,更加方便地运行或者Debug这个单元测试。

可以从运行后的结果清晰的看到这个单元测试的执行顺序。
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
setUp
test_demo
tearDown
.setUp
test_demo2
tearDown
.
----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK
Destroying test database for alias 'default'...
此外还可以从运行结果看到,在测试之前单元测试创建了一个测试数据库。
Creating test database for alias ‘default’…
然后在测试结束将数据库摧毁。
Destroying test database for alias ‘default’…
这个也就是在继承了Django框架中的TestCase,它已经帮你实现的一些逻辑方便用于测试,所以我们不需要在setUp和tearDown函数中实现这些逻辑。
接下来讲一下我们如何使用TestCase来测试接口的,首先我们编写一个简单的接口,这里笔者是用Django Rest Framework的APIView来编写的,读者也可以使用自己管用的方法来编写。
from rest_framework.views import APIView
from rest_framework.response import Response
class HelloTestCase(APIView):
def get(self, request, *args, **kwargs):
return Response({
'msg': 'Hello %s I am a test Case' % request.query_params.get('name', ',')
})然后这个接口类加到我们的路由中。
from django.urls import path
from development_of_test_habits import views
urlpatterns = [
path('hello_test_case', views.HelloTestCase.as_view(), name='hello_test_case'),
]接下来我们编写一个HelloTestCase的单元测试类来测试我们的测试用例。
from django.urls import resolve, reverse
from django.test import TestCase
class HelloTestCase(TestCase):
def setUp(self):
self.name = 'Django'
def test_hello_test_case(self):
url = '/test_case/hello_test_case'
# url = reverse('hello_test_case')
# Input: print(resolve(url))
# Output: ResolverMatch(func=development_of_test_habits.views.hello_test_case.HelloTestCase, args=(), kwargs={}, url_name=hello_test_case, app_names=[], namespaces=[])
response = self.client.get(url)
self.assertEqual(response.status_code, 200) # 期望的Http相应码为200
data = response.json()
self.assertEqual(data['msg'], 'Hello , I am a test Case') # 期望的msg返回结果为'Hello , I am a test Case'
response = self.client.get(url, {'name': self.name})
self.assertEqual(response.status_code, 200) # 期望的Http相应码为200
data = response.json()
self.assertEqual(data['msg'], 'Hello Django I am a test Case') # 期望的msg返回结果为'Hello Django I am a test Case'在setUp函数中,我定义了一个name属性并且赋值为Django便于后面使用。
单元测试测试接口主要分为下面几个重要的内容。
在测试接口时无非就是发起请求,检查返回的状态嘛和响应内容是否正确。发请求肯定少不了url地址,这里有两种方式来配置请求地址。
1.直接设置请求地址
url = '/test_case/hello_test_case'
2.透过django.urls.reverse函数和在路由设置的name来得到请求的地址
url = reverse('hello_test_case')
这里在介绍以下我们还可以通过django.urls.resolve和url得到对应的接口类或者接口函数`。
发起请求我们除了需要路由外,我们还需要一个发起请求的客户端。python的requests库就是很好的客户端工具,只不过Django在它的TestCase类
中已经集成了一个客户端工具,我们只需要调用TestCase的client属性就可以得到一个客户端。
client = self.client
发起请求非常简单只需要一行代码,我们就可以通过请求得到它的响应体。
response = self.client.get(url)
如果需要携带参数只需要传入data参数。
response = self.client.get(url, {'name': self.name})
在单元测试中,TestCase的assertEqual有点类似python的assert函数,除了assertEqual外还有assertNotEqual、assertGreater、assertIn等等。
这里笔者主要做了两个检查,一个是检查status_code是否等于200。
self.assertEqual(response.status_code, 200) # 期望的Http相应码为200
另一个是检查响应内容是否正确。
data = response.json()
self.assertEqual(data['msg'], 'Hello , I am a test Case') # 期望的msg返回结果为'Hello , I am a test Case'
这个就是最简单的测试请求的单元测试,但是在实际的接口中,我们是需要数据的,所以我们还需要生成测试数据。
这里介绍一个非常方便的库mixer,可以方便在我们的单元测试中生成测试数据。
首先我们定一个场景,比如说我们记录了学校班级的学生的作业,需要一个接口来返回学生的作业列表,并且这个接口是需要用户登陆后才可以请求的,定义的models和接口类如下。
from django.db import models
class School(models.Model):
name = models.CharField(max_length=32)
class Class(models.Model):
school_id = models.ForeignKey(to=School, on_delete=models.PROTECT)
name = models.CharField(max_length=32)
class Student(models.Model):
class_id = models.ForeignKey(to=Class, on_delete=models.PROTECT)
name = models.CharField(max_length=32)
class HomeWork(models.Model):
student_id = models.ForeignKey(to=Student, on_delete=Student)
name = models.CharField(max_length=32)接口笔者用的是Django rest framework的ReadOnlyModelViewSet视图类实现的,实现的功能就是返回一个json结果集,
并且json中有HomeWork的School Name、Class Name和Student Name,视图类代码和序列化代码如下。
from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework.permissions import IsAuthenticated
from development_of_test_habits.models import HomeWork
from development_of_test_habits.serializers import HomeWorkSerializer
class HomeWorkViewSet(ReadOnlyModelViewSet):
queryset = HomeWork.objects.all()
serializer_class = HomeWorkSerializer
permission_classes = (IsAuthenticated, )from rest_framework import serializers
from development_of_test_habits.models import HomeWork
class HomeWorkSerializer(serializers.ModelSerializer):
class Meta:
model = HomeWork
fields = ('school_name', 'class_name', 'student_name', 'name')
school_name = serializers.CharField(source='student_id.class_id.school_id.name', read_only=True)
class_name = serializers.CharField(source='student_id.class_id.name', read_only=True)
student_name = serializers.CharField(source='student_id.name', read_only=True)最后把我们的接口类添加到路由中。
urlpatterns = [
path('hello_test_case', views.HelloTestCase.as_view(), name='hello_test_case'),
path('api/home_works', views.HomeWorkViewSet.as_view({'get': 'list'}), name='home_works_list')
]完成接口的编写,可以开始写单元测试了,定义HomeWorkAPITestCase测试类并且在setUp中生成测试数据。
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth.models import User
from mixer.backend.django import mixer
from development_of_test_habits import models
class HomeWorkAPITestCase(TestCase):
def setUp(self):
self.user = mixer.blend(User)
self.random_home_works = [
mixer.blend(models.HomeWork)
for _ in range(11)
]这里介绍一下mixer这个模块,这个模块会根据你定义的模型和模型的字段来随机生成测试数据,包括这个数据的外键数据。
这样在我们这种层级非常多的关系型数据就非常的方便,否则需要一层一层的去生成数据。
代码中就利用mixer生成了一个随机的用户和11个随机的HomeWork数据。
接下来编写测试的逻辑代码。
class HomeWorkAPITestCase(TestCase):
def setUp(self):
self.user = mixer.blend(User)
self.random_home_works = [
mixer.blend(models.HomeWork)
for _ in range(11)
]
def test_home_works_list_api(self):
url = reverse('home_works_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
self.client.force_login(self.user)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data), len(self.random_home_works))
data_fields = [key for key in data[0].keys()]
self.assertIn('school_name', data_fields)
self.assertIn('class_name', data_fields)
self.assertIn('student_name', data_fields)
self.assertIn('name', data_fields)首先通过django.urls.reverse函数和接口的路由名称获得url,第一步先测试用户在没有登陆的情况下请求接口,这里期望的请求响应码为403。
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
我们通过client的一个登陆函数force_login来登陆我们随机生成的用户,再次请求接口,这次的期望的请求相应码就为200。
self.client.force_login(self.user)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
最后验证返回的结果数量是和结果中定义的字段是否正确。
data = response.json()
self.assertEqual(len(data), len(self.random_home_works))
data_fields = [key for key in data[0].keys()]
self.assertIn('school_name', data_fields)
self.assertIn('class_name', data_fields)
self.assertIn('student_name', data_fields)
self.assertIn('name', data_fields)
以上就是在项目中测试接口的最常见的流程。
假设我们要在接口中增加请求头,以HelloTestCase接口为例,我们要增加一个TEST_HEADER的请求头,则在接口的逻辑处理中,就需要给这个请求头
加上HTTP_前缀。
class HelloTestCase(APIView):
def get(self, request, *args, **kwargs):
data = {
'msg': 'Hello %s I am a test Case' % request.query_params.get('name', ',')
}
test_header = request.META.get('HTTP_TEST_HEADER')
if test_header:
data['test_header'] = test_header
return Response(data)如果我们用客户端工具类似Post Man、RestFul Client等等,请求时只要在请求头中加上TEST_HEADER即可。
但是在单元测试中,我们也需要把HTTP_这个前缀加上,否则接口逻辑是无法获取的。
def test_hello_test_case(self):
url = '/test_case/hello_test_case'
# url = reverse('hello_test_case')
# Input: print(resolve(url))
# Output: ResolverMatch(func=development_of_test_habits.views.hello_test_case.HelloTestCase, args=(), kwargs={}, url_name=hello_test_case, app_names=[], namespaces=[])
response = self.client.get(url)
self.assertEqual(response.status_code, 200) # 期望的Http相应码为200
data = response.json()
self.assertEqual(data['msg'], 'Hello , I am a test Case') # 期望的msg返回结果为'Hello , I am a test Case'
response = self.client.get(url, {'name': self.name})
self.assertEqual(response.status_code, 200) # 期望的Http相应码为200
data = response.json()
self.assertEqual(data['msg'], 'Hello Django I am a test Case') # 期望的msg返回结果为'Hello Django I am a test Case'
# 假设我们要在接口中增加请求头'TEST_HEADER'
# 则在测试时需要加上前缀'HTTP_'最终的结果为'HTTP_TEST_HEADER'
response = self.client.get(url, **{'HTTP_TEST_HEADER': 'This is a test header.'})
data = response.json()
self.assertEqual(data['test_header'], 'This is a test header.')在用测试用例来测试接口后,笔者已经开始养成写完接口直接用单元测试来测试的习惯,这样不单是在给别人说明自己的接口的功能,还是减少线上环境的BUG都有明显的帮助, 希望读者也能用这种方式不断的养成写单元测试的好习惯。