作者:HelloGitHub-追梦人物
一个完整的项目,无论是个人的还是公司的,自动化的单元测试是必不可少,否则以后任何的功能改动将成为你的灾难。
假设你正在维护公司的一个项目,这个项目已经开发了几十个 API 接口,但是没有任何的单元测试。现在你的 leader 让你去修改几个接口并实现一些新的功能,你接到需求后高效地完成了开发任务,然后手动测试了一遍改动的接口和新实现的功能,确保没有任何问题后,满心欢喜地提交了代码。
代码上线后出了 BUG,分析原因发现原来是新的改动导致某个旧 API 接口出了问题,因为上线前只对改动的接口做了测试,所以未能发现这个问题。你的 leader 批评了你,你因为事故记了过,年终只能拿个 3.25,非常凄惨。
但是如果我们有全面的单元测试,上述情况就有很大概率避免。只需要在代码发布前运行一遍单元测试,受影响的功能立即就会报错,这样就能在代码部署前发现问题,从而避免线上事故。
当然以上故事纯属虚构,说这么多只是希望大家在开发时养成良好的习惯,一是写优雅的代码,二是一定要测试自己写的代码。
单元测试回顾
在上一部教程 Django博客教程(第二版)[1] 的 单元测试:测试 blog 应用[2]、单元测试:测试评论应用[3]、Coverage.py 统计测试覆盖率[4] 中,我们详细讲解了 django 单元测试框架的使用方式。这里我们再对 djnago 的测试框架做一个回顾整体回顾,至于如何编写和运行测试,后面将会进行详细的讲解,如果想对 django 的单元测试做更基础的了解,推荐回去看看关于测试的 3 篇教程以及 django 的官方文档。
下面是 djnago 单元测试框架的一些要点:
django 的单元测试框架基于 Python 的 unittest 测试框架。
django 提供了多个 XXTestCase 类,这些类均直接或者间接继承自
unittest.TestCase
类,因为 django 的单元测试框架是基于 unittest 的,所以编写的测试用例类也都需要直接或者间接继承unittest.TestCase
。通常情况我们都是继承 django 提供的 XXTestCase,因为这些类针对 django 定制了更多的功能特性。默认情况下,测试代码需要放在 django 应用的下的 tests.py 文件或者 tests 包里,django 会自动发现 tests 包中以 test 开头的模块(例如 test_models.py、test_views.py),然后执行测试用例类中命名以 test 开头的方法。
python manage.py test 命令可以运行单元测试。
梳理需要测试的接口
接下来我们就为博客的 API 接口来编写单元测试。对 API 接口来说,我们主要关心的就是:对特定的请求返回正确的响应。我们先来梳理一下需要测试的接口和功能点。
博客主要的接口都集中在 PostViewSet
和 CommentViewSet
两个视图集中。
CommentViewSet
视图集的接口比较简单,就是创建评论。PostViewSet
视图集的接口则包含了文章列表、文章详情、评论列表、归档日期列表等。对于文章列表接口,还可以通过查询参数对请求的文章列表资源进行过滤,获取全部文章的一个子集。
测试 CommentViewSet
CommentViewSet
只有一个接口,功能比较简单,我们首先以它为例来讲解单元测试的编写方式。
测试接口的一般步骤:
获得接口的 URL。
向接口发送请求。
检查响应的 HTTP 状态码、返回的数据等是否符合预期。
我们以测试创建评论的代码 test_create_valid_comment
为例:
-
# filename=
"comments/tests/test_api.py
-
from django.apps import apps
-
from django.contrib.auth.models import User
-
from rest_framework import status
-
from rest_framework.reverse import reverse
-
from rest_framework.test import APITestCase
-
-
from blog.models import Category, Post
-
from comments.models import Comment
-
-
-
class CommentViewSetTestCase(APITestCase):
-
def setUp(self):
-
self.url = reverse("v1:comment-list
")
-
# 断开 haystack 的 signal,测试生成的文章无需生成索引
-
apps.get_app_config("haystack
").signal_processor.teardown()
-
user = User.objects.create_superuser(
-
username="admin
", email="admin@hellogithub.com
", password="admin
"
-
)
-
cate = Category.objects.create(name="测试
")
-
self.post = Post.objects.create(
-
title="测试标题
", body="测试内容
", category=cate, author=user,
-
)
-
-
def test_create_valid_comment(self):
-
data = {
-
"name
": "user
",
-
"email
": "user@example.com
",
-
"text
": "test comment text
",
-
"post
": self.post.pk,
-
}
-
response = self.client.post(self.url, data)
-
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-
comment = Comment.objects.first()
-
self.assertEqual(comment.name, data["name
"])
-
self.assertEqual(comment.email, data["email
"])
-
self.assertEqual(comment.text, data["text
"])
-
self.assertEqual(comment.post, self.post)
-
首先,接口的 URL 地址为:reverse("v1:comment-list")
。reverse
函数通过视图函数名来解析对应的 URL,视图函数名的格式为:"<namespace>:<basename>-<action name>"
。
其中 namespace 是 include
函数指定的 namespace
参数值,例如:
path("api/v1/", include((router.urls, "api"), namespace="v1"))
basename 是 router 在 register
视图集时指定的参数 basename 的值,例如:
router.register(r"posts", blog.views.PostViewSet, basename="post")
action name 是 action 装饰器指定的 url_name
参数的值,或者默认的 list、retrieve、create、update、delete 标准 action 名,例如:
-
# filename=
"blog/views.py
-
@action(
-
methods=["GET
"], detail=False, url_path="archive/dates
", url_name="archive-date
"
-
)
-
def list_archive_dates(self, request, *args, **kwargs):
-
pass
-
因此,reverse("v1:comment-list")
将被解析为 /api/v1/comments/。
接着我们向这个 URL 发送 POST 请求:response = self.client.post(self.url, data)
,因为继承自 django-reset-framework 提供的测试类 APITestCase
,因此可以直接通过 self.client
来发送请求,其中 self.client
是 django-rest-framework 提供的 APIClient
的一个实例,专门用来发送 HTTP 测试请求。
最后就是对请求的响应结果 response
做检查。创建评论成功后返回的状态码应该是 201,接口返回的数据在 response.data
属性中,我们对接口返回的状态码和部分数据进行了断言,确保符合预期的结果。
当然以上是评论创建成功的情况,我们测试时不能只测试正常情况,更要关注边界情况和异常情况,我们再来增加一个评论数据格式不正确导致创建失败的测试案例:
-
# filename=
"comments/tests/test_api.py
-
def test_create_invalid_comment(self):
-
invalid_data = {
-
"name
": "user
",
-
"email
": "user@example.com
",
-
"text
": "test comment text
",
-
"post
": 999,
-
}
-
response = self.client.post(self.url, invalid_data)
-
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
self.assertEqual(Comment.objects.count(), 0)
-
套路还是一样的,第一步向接口发请求,然后对预期返回的响应结果进行断言。这里由于评论数据不正确(关联的 id 为 999 的 post 不存在),因此预期返回的状态码是 400,同时数据库中不应该有创建的评论。
测试 PostViewSet
尽管 PostViewSet
包含的接口比较多,但是每个接口测试的套路和上面讲的是一样的,依葫芦画瓢就行了。因为 PostViewSet
测试代码较多,这里仅把各个测试案例对应的方法列出来,具体的测试逻辑省略掉。如需了解详细可查看 GitHub 上项目的源码:
-
# filename=
"blog/tests/test_api.py
-
from datetime import datetime
-
-
from django.apps import apps
-
from django.contrib.auth.models import User
-
from django.core.cache import cache
-
from django.urls import reverse
-
from django.utils.timezone import utc
-
from rest_framework import status
-
from rest_framework.test import APITestCase
-
-
from blog.models import Category, Post, Tag
-
from blog.serializers import PostListSerializer, PostRetrieveSerializer
-
from comments.models import Comment
-
from comments.serializers import CommentSerializer
-
-
-
class PostViewSetTestCase(APITestCase):
-
def setUp(self):
-
# 断开 haystack 的 signal,测试生成的文章无需生成索引
-
apps.get_app_config("haystack
").signal_processor.teardown()
-
# 清除缓存,防止限流
-
cache.clear()
-
-
# 设置博客数据
-
# post3 category2 tag2 2020-08-01 comment1 comment2
-
# post2 category1 tag1 2020-07-31
-
# post1 category1 tag1 2020-07-10
-
-
def test_list_post(self):
-
"
""
-
这个方法测试文章列表接口,预期的响应状态码为
200,数据为文章列表序列化后的结果
-
""
"
-
url = reverse("v1:post-list
")
-
-
def test_list_post_filter_by_category(self):
-
"
""
-
这个方法测试获取某个分类下的文章列表接口,预期的响应状态码为
200,数据为文章列表序列化后的结果
-
""
"
-
url = reverse("v1:post-list
")
-
-
-
def test_list_post_filter_by_tag(self):
-
"
""
-
这个方法测试获取某个标签下的文章列表接口,预期的响应状态码为
200,数据为文章列表序列化后的结果
-
""
"
-
url = reverse("v1:post-list
")
-
-
-
def test_list_post_filter_by_archive_date(self):
-
"
""
-
这个方法测试获取归档日期下的文章列表接口,预期的响应状态码为
200,数据为文章列表序列化后的结果
-
""
"
-
url = reverse("v1:post-list
")
-
-
-
def test_retrieve_post(self):
-
"
""
-
这个方法测试获取单篇文章接口,预期的响应状态码为
200,数据为单篇文章序列化后的结果
-
""
"
-
url = reverse("v1:post-detail
", kwargs={"pk
": self.post1.pk})
-
-
-
def test_retrieve_nonexistent_post(self):
-
"
""
-
这个方法测试获取一篇不存在的文章,预期的响应状态码为
404
-
""
"
-
url = reverse("v1:post-detail
", kwargs={"pk
": 9999})
-
-
-
def test_list_archive_dates(self):
-
"
""
-
这个方法测试获取文章的归档日期列表接口
-
""
"
-
url = reverse("v1:post-archive-date
")
-
-
-
def test_list_comments(self):
-
"
""
-
这个方法测试获取某篇文章的评论列表接口,预期的响应状态码为
200,数据为评论列表序列化后的结果
-
""
"
-
url = reverse("v1:post-comment
", kwargs={"pk
": self.post3.pk})
-
-
-
def test_list_nonexistent_post_comments(self):
-
"
""
-
这个方法测试获取一篇不存在的文章的评论列表,预期的响应状态码为
404
-
""
"
-
url = reverse("v1:post-comment
", kwargs={"pk
": 9999})
-
我们以 test_list_post_filter_by_archive_date
为例做一个讲解,其它的测试案例代码逻辑大同小异。
-
# filename=
"blog/tests/test_api.py
-
def test_list_post_filter_by_archive_date(self):
-
# 解析文章列表接口的 URL
-
url = reverse("v1:post-list
")
-
-
# 发送请求,我们这里给 get 方法的第二个参数传入了一个字典,这个字典代表了 get 请求的查询参数。
-
# 例如最终的请求的 URL 会被编码成:/posts/?created_year=2020&created_month=7
-
response = self.client.get(url, {"created_year
": 2020, "created_month
": 7})
-
self.assertEqual(response.status_code, status.HTTP_200_OK)
-
-
# 如何检查返回的数据是否正确呢?对这个接口的请求,
-
# 我们预期返回的结果是 post2 和 post1 这两篇发布于2020年7月的文章序列化后的数据。
-
# 因此,我们使用 PostListSerializer 对这两篇文章进行了序列化,
-
# 然后和返回的结果 response.data["results
"] 进行比较。
-
serializer = PostListSerializer(instance=[self.post2, self.post1], many=True)
-
self.assertEqual(response.data["results
"], serializer.data)
-
运行测试
接下来运行测试:
-
"Linux/macOS"
-
$ pipenv run coverage run manage.py test
-
-
"Windows"
-
...\> pipenv run coverage run manage.py test
大部分测试都通过了,但是也有一个测试失败了,也就是说我们通过测试发现了一个 BUG:
-
======================================================================
-
FAIL: test_list_archive_dates (blog.tests.test_api.PostViewSetTestCase)
-
----------------------------------------------------------------------
-
Traceback (most recent call last):
-
File
"C:\Users\user\SpaceLocal\Workspace\G_Courses\HelloDjango\HelloDjango-rest-framework-tutorial\blog\tests\test_api.py", line
123, in test_list_archive_dates
-
self.assertEqual(response.data, [
"2020-08",
"2020-07"])
-
AssertionError: Lists differ: [
'2020-08-01',
'2020-07-01'] != [
'2020-08',
'2020-07']
失败的是 test_list_archive_dates
这个测试案例,文章归档日期接口返回的数据不符合我们的预期,我们预期得到 yyyy-mm 格式的日期列表,但接口返回的是 yyyy-mm-dd,这是我们之前开发时没有发现的,通过测试将问题暴露了,这也从一定程度上印证了我们之前强调的测试的作用。
既然已经发现了问题,就来修复它。我相信修复这个 bug 对你来说应该已经是轻而易举的事了,因此留作练习吧,这里不再讲解。
重新运行一遍测试,得到 ok 的状态。
Ran 55 tests in 8.997s
OK
说明全部测试通过。
检查测试覆盖率
以上测试充分了吗?单凭肉眼自然很难发现,Coverage.py 统计测试覆盖率[4] 中我们配置了 Coverage.py 并介绍了它的用法,直接运行下面的命令就可以查看代码的测试覆盖程度:
-
"Linux/macOS"
-
$ pipenv run coverage report
-
-
"Windows"
-
...\> pipenv run coverage report
覆盖结果如下:
-
Name Stmts Miss Branch BrPart Cover Missing
-
-----------------------------------------------------------------
-
blog\serializers.py
46
5
0
0
89%
82
-86
-
blog\utils.py
21
2
4
1
88%
29->
30,
30
-31
-
blog\views.py
119
5
4
0
94%
191,
200,
218
-225
-
comments\views.py
25
1
2
0
96%
59
-
-----------------------------------------------------------------
-
TOTAL
1009
13
34
1
98%
可以看到测试覆盖率整体达到了 98%,但是仍有 4 个文件部分代码未被测试,命令行中只给出了未被测试覆盖的代码行号(Missing 列),不是很直观,运行下面的命令可以生成一个 HTML 报告,可视化地查看未被测试覆盖的代码片段:
-
"Linux/macOS"
-
$ pipenv run coverage html
-
-
"Windows"
-
...\> pipenv run coverage html
命令执行后会在项目根目录生成一个 htmlcov 文件夹,用浏览器打开里面的 index.html 页面就可以查看测试覆盖情况的详细报告了。
HTML 报告页面示例:
未覆盖的代码通过红色高亮背景标出,非常直观。可以看到 blog/views.py 中 CategoryViewSet
和 TagViewSet
未进行测试,按照上面介绍的测试方法补充测试就可以啦。这两个视图集都非常的简单,测试的任务就留作练习了。
补充测试
blog/serializers.py 中的 HighlightedCharField
未测试,还有 blog/utils.py 中新增的 UpdatedAtKeyBit
未测试,我们编写相应的测试案例。
测试 UpdatedAtKeyBit
UpdatedAtKeyBit 就只有一个 get_data
方法,这个方法预期的逻辑是:从缓存中取得以 self.key
为键的缓存值(缓存被设置时的时间),如果缓存未命中,就取当前时间,并将这个时间写入缓存。
将预期的逻辑写成测试代码如下,需要注意的一点是因为这个辅助类不涉及 django 数据库方面的操作,因此我们直接继承自更为简单的 unittest.TestCase
,这可以提升测试速度:
-
# filename=
"blog/tests/test_utils.py
-
import unittest
-
from datetime import datetime
-
-
from django.core.cache import cache
-
-
from ..utils import Highlighter, UpdatedAtKeyBit
-
-
class UpdatedAtKeyBitTestCase(unittest.TestCase):
-
def test_get_data(self):
-
# 未缓存的情况
-
key_bit = UpdatedAtKeyBit()
-
data = key_bit.get_data()
-
self.assertEqual(data, str(cache.get(key_bit.key)))
-
-
# 已缓存的情况
-
cache.clear()
-
now = datetime.utcnow()
-
now_str = str(now)
-
cache.set(key_bit.key, now)
-
self.assertEqual(key_bit.get_data(), now_str)
-
测试 HighlightedCharField
我们在讲解自定义系列化字段的时候讲过,序列化字段通过调用 to_representation
方法,将传入的值进行序列化。HighlightedCharField
的预期逻辑就是调用 to_representation
方法后将传入的值进行高亮处理。
HighlightedCharField
涉及到一些高级操作,主要是因为 to_representation
方法中涉及到对 HTTP 请求request 的操作。正常的视图函数调用时,视图函数会接收到传入的 request 参数,然后 django-rest-framework 会将 request 传给序列化器(Serializer)的 _context
属性,序列化器中的任何序列化字段均可以通过直接访问 context
属性而间接访问到 _context
属性,从而拿到 request 对象。
但是在单元测试中,可能没有这样的视图函数调用,因此 _context
的设置并不会自动进行,需要我们模拟视图函数调用时的行为,手动进行设置。主要包括 2 点:
构造 HTTP 请求对象 request。
设置 _context 属性的值。
具体的代码如下,详细讲解请看相关代码行的注释:
-
# filename=
"blog/tests/test_serializer.py
-
import unittest
-
-
from blog.serializers import HighlightedCharField
-
from django.test import RequestFactory
-
from rest_framework.request import Request
-
-
-
class HighlightedCharFieldTestCase(unittest.TestCase):
-
def test_to_representation(self):
-
field = HighlightedCharField()
-
# RequestFactory 专门用来构造 request 对象。
-
# 这个 RequestFactory 生成的 request 代表了一个对 URL / 访问的 get 请求,
-
# 并包含 URL 参数 text=关键词。
-
# 请求访问的完整 URL 就是 /?text=关键词
-
request = RequestFactory().get("/
", {"text
": "关键词
"})
-
-
# django-rest-framework 对 django 内置的 request 进行了包装,
-
# 因此这里要手动使用 drf 提供的 Request 类对 django 的 request 进行一层包装。
-
drf_request = Request(request=request)
-
-
# 设置 HighlightedCharField 实例 _context 属性的值,这样在其内部就可以通过
-
# self.context["request
"] 拿到请求对象 request
-
setattr(field, "_context
", {"request
": drf_request})
-
document = "无关文本关键词无关文本,其他别的关键词别的无关的词。
"
-
result = field.to_representation(document)
-
expected = (
-
'无关文本<span class="highlighted
">关键词</span>无关文本,'
-
'其他别的<span class="highlighted
">关键词</span>别的无关的词。'
-
)
-
self.assertEqual(result, expected)
-
再次运行一遍测试覆盖率的检查命令,这次得到的测试覆盖率就是 100% 了:
-
Name Stmts Miss Branch BrPart Cover Missing
-
---------------------------------------------------
-
---------------------------------------------------
-
TOTAL
1047
0
32
0
100%
当然,需要提醒一点的是,测试覆盖率 100% 并不能说明程序就没有 BUG 了。线上可能出现各种奇奇怪怪的问题,这些问题可能并没有写成测试案例,所以也就没有测试到。但无论如何,目前我们已经进行了较为充分的测试,就可以考虑发布一个版本了。如果以后再线上遇到什么问题,或者想到了新的测试案例,可以随时补充进单元测试,以后程序出 BUG 的几率就会越来越低了。
参考资料
[1]
Django博客教程(第二版): https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/
[2]单元测试:测试 blog 应用: https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/materials/87/
[3]单元测试:测试评论应用: https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/materials/88/
[4]Coverage.py 统计测试覆盖率: https://www.zmrenwu.com/courses/hellodjango-blog-tutorial/materials/89/
关注公众号加入交流群
『讲解开源项目系列』——让对开源项目感兴趣的人不再畏惧、让开源项目的发起者不再孤单。跟着我们的文章,你会发现编程的乐趣、使用和发现参与开源项目如此简单。欢迎联系我(微信:xueweihan,备注:讲解)加入我们,让更多人爱上开源、贡献开源~
点击本文最上面的“来自专辑”,可阅读本教程往期文章。
点击“阅读原文”给个 star 吧、“分享”让本文被更多人看到、“在看”支持我们。
转载:https://blog.csdn.net/a419240016/article/details/108016457