一个完整的项目,无论是个人的还是公司的,自动化的单元测试是必不可少,否则以后任何的功能改动将成为你的灾难。
假设你正在维护公司的一个项目,这个项目已经开发了几十个 API 接口,但是没有任何的单元测试。现在你的 leader 让你去修改几个接口并实现一些新的功能,你接到需求后高效地完成了开发任务,然后手动测试了一遍改动的接口和新实现的功能,确保没有任何问题后,满心欢喜地提交了代码。
代码上线后出了 BUG,分析原因发现原来是新的改动导致某个旧 API 接口出了问题,因为上线前只对改动的接口做了测试,所以未能发现这个问题。你的 leader 批评了你,你因为事故记了过,年终只能拿个 3.25,非常凄惨。
但是如果我们有全面的单元测试,上述情况就有很大概率避免。只需要在代码发布前运行一遍单元测试,受影响的功能立即就会报错,这样就能在代码部署前发现问题,从而避免线上事故。
当然以上故事纯属虚构,说这么多只是希望大家在开发时养成良好的习惯,一是写优雅的代码,二是一定要测试自己写的代码。
单元测试回顾
在上一部教程 Django博客教程(第二版) 的 单元测试:测试 blog 应用、单元测试:测试评论应用、Coverage.py 统计测试覆盖率 中,我们详细讲解了 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 只有一个接口,功能比较简单,我们首先以它为例来讲解单元测试的编写方式。
测试接口的一般步骤:
1、获得接口的 URL。
2、向接口发送请求。
3、检查响应的 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 统计测试覆盖率 中我们配置了 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 点:
1、构造 HTTP 请求对象 request。
2、设置 _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 的几率就会越来越低了。
作者:追梦人物
文章链接:https://www.cnblogs.com/xueweihan/p/13499563.html