IT教程 ·

编写 Django 应用单元测试

高等数学——讲透微分中值定理

编写 Django 应用单元测试 IT教程 第1张

作者:HelloGitHub-追梦人物

文中所触及的示例代码,已同步更新到 HelloGitHub-Team 堆栈

我们博客功用越来越来完美了,但这也带来了一个问题,我们不敢轻易地修正已有功用的代码了!

我们怎样晓得代码修正后带来了预期的效果?万一改错了,不仅新功用没有用,本来已有的功用都大概被损坏。此前我们开发一个新的功用,都是手工运转开发服务器去考证,不仅费时,而且极有大概考证不充足。

怎样不必每次开发了新的功用或许修正了已有代码都得去人工考证呢?解决方案就是编写自动化测试,将人工考证的逻辑编写成剧本,每次新增或修正代码后运转一遍测试剧本,剧本自动帮我们完成悉数测试事变。

接下来我们将举行两种范例的测试,一种是单位测试,一种是集成测试。

单位测试是一种比较底层的测试,它将一个功用逻辑的代码块视为一个单位(比方一个函数、要领、或许一个 if 语句块等,单位应当尽量小,如许测试就会越发充足),程序员编写测试代码去测试这个单位,确保这个单位的逻辑代码根据预期的体式格局实行了。平常来讲我们平常将一个函数或许要领视为一个单位,对其举行测试。

集成测试则是一种越发高层的测试,它站在体系角度,测试由各个已经由充足的单位测试的模块构成的体系,其功用是不是符合预期。

我们起首来举行单位测试,确保各个单位的逻辑都没问题后,然后举行集成测试,测试全部博客体系的可用性。

Python 平常运用规范库 unittest 供应单位测试,django 拓展了单位测试,供应了一系列类,用于差别的测试场所。个中最常用到的就是 django.test.TestCase 类,这个类和 Python 规范库的 unittest.TestCase 相似,只是拓展了以下功用:

  • 供应了一个 client 属性,这个 client 是 Client 的实例。能够把 Client 看作一个提议 HTTP 要求的功用库(相似于 requests),如许我们能够方便地运用这个类测试视图函数。
  • 运转测试前自动建立数据库,测试运转终了后自动烧毁数据库。我们肯定不愿望自动生成的测试数据影响到实在的数据。

博客运用的单位测试,重要就是和这个类打交道。

django 运用的单位测试包括:

  • 测试 model,model 的要领是不是返回了预期的数据,对数据库的操纵是不是准确。
  • 测试表单,数据考证逻辑是不是符合预期
  • 测试视图,针对特定范例的要求,是不是返回了预期的响应
  • 别的的一些辅佐要领或许类等

接下来我们就逐一地来测试上述内容。

搭建测试环境

测试写在 tests.py 里(运用建立时就会自动建立这个文件),起首来个冒烟测试,用于考证测试功用是不是一般,在 blogtests.py 文件写入以下代码:

from django.test import TestCase


class SmokeTestCase(TestCase):
    def test_smoke(self):
        self.assertEqual(1 + 1, 2)

运用 manage.py 的 test 敕令将自动发明 django 运用下的 tests 文件或许模块,而且自动实行以 test_ 开头的要领。运转:pipenv run python manage.py test

Creating test database for alias 'default'...
System check identified no issues (0 silenced).

.

-------------------------------------------------------

Ran 1 test in 0.002s

OK
Destroying test database for alias 'default'...

OK 表明我们的测试运转胜利。

不过,假如须要测试的代码比较多,把悉数测试逻辑一股脑塞入 tests.py,这个模块就会变得异常痴肥,不利于保护,所以我们把 tests.py 文件升级为一个包,差别的单位测试写到包下对应的模块中,如许便于模块化地保护和治理。

删除 blogtests.py 文件,然后在 blog 运用下建立一个 tests 包,再建立各个单位测试模块:

blog
    tests
        __init__.py
        test_smoke.py
        test_models.py
        test_views.py
        test_templatetags.py
        test_utils.py
  • test_models.py 寄存和模子有关的单位测试
  • test_views.py 测试视图函数
  • test_templatetags.py 测试自定义的模板标签
  • test_utils.py 测试一些辅佐要领和类等

注重

tests 包中的各个模块必需以 test_ 开头,不然 django 没法发明这些测试文件的存在,从而不会运转内里的测试用例。

测试模子

模子须要测试的不多,因为基本上都是运用了 django 基类 models.Model 的特征,本身的逻辑很少。拿最为庞杂的 Post 模子举例,它包括的逻辑功用重要有:

  • __str__ 要领返回 title 用于模子实例的字符示意
  • save 要领中设置文章建立时候(created_time)和择要(exerpt)
  • get_absolute_url 返回文章概况视图对应的 url 途径
  • increase_views 将 views 字段的值 +1

单位测试就是要测试这些要领实行后确实返回了上面预期的效果,我们在 test_models.py 中新增一个类,叫做 PostModelTestCase,在这个类中编写上述单位测试的用例。

from django.apps import apps

class PostModelTestCase(TestCase):
    def setUp(self):
        # 断开 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_str_representation(self):
        self.assertEqual(self.post.__str__(), self.post.title)

    def test_auto_populate_modified_time(self):
        self.assertIsNotNone(self.post.modified_time)

        old_post_modified_time = self.post.modified_time
        self.post.body = '新的测试内容'
        self.post.save()
        self.post.refresh_from_db()
        self.assertTrue(self.post.modified_time > old_post_modified_time)

    def test_auto_populate_excerpt(self):
        self.assertIsNotNone(self.post.excerpt)
        self.assertTrue(0 < len(self.post.excerpt) <= 54)

    def test_get_absolute_url(self):
        expected_url = reverse('blog:detail', kwargs={'pk': self.post.pk})
        self.assertEqual(self.post.get_absolute_url(), expected_url)

    def test_increase_views(self):
        self.post.increase_views()
        self.post.refresh_from_db()
        self.assertEqual(self.post.views, 1)

        self.post.increase_views()
        self.post.refresh_from_db()
        self.assertEqual(self.post.views, 2)

这里代码虽然比较多,但做的事变很明白。setUp 要领会在每一个测试案例运转前实行,这里做的事变是在数据库中建立一篇文章,用于测试。

接下来的各个 test_* 要领就是关于各个功用单位的测试,以 test_auto_populate_modified_time 为例,这里我们要测试文章保留到数据库后,modifited_time 被准确设置了值(期待的值应当是文章保留时的时候)。

self.assertIsNotNone(self.post.modified_time) 断言文章的 modified_time 不为空,申明白实设置了值。TestCase 类供应了系列 assert* 要领用于断言测试单位的逻辑效果是不是和预期符合,平常从要领的定名中就能够读出其功用,比方这里 assertIsNotNone 就是断言被测试的变量值不为 None。

接着我们尝试经由历程

self.post.body = '新的测试内容'
self.post.save()

修正文章内容,并从新保留数据库。预期的效果应当是,文章保留后,modifited_time 的值也被更新为修正文章时的时候,接下来的代码就是对这个预期效果的断言:

self.post.refresh_from_db()
self.assertTrue(self.post.modified_time > old_post_modified_time)

这个 refresh_from_db 要领将革新对象 self.post 的值为数据库中的最新值,然后我们断言数据库中 modified_time 纪录的最新时候比本来的时候晚,假如断言经由历程,申明我们更新文章后,modified_time 的值也举行了响应更新来纪录修正时候,效果符合预期,测试经由历程。

别的的测试要领都是做着相似的事变,这里不再逐一解说,请自行看代码剖析。

测试视图

视图函数测试的基本思路是,向某个视图对应的 URL 提议要求,视图函数被挪用并返回预期的响应,包括准确的 HTTP 响应码和 HTML 内容。

我们的博客运用包括以下范例的视图须要举行测试:

  • 首页视图 IndexView,接见它将返回悉数文章列表。
  • 标签视图,接见它将返回某个标签下的文章列表。假如接见的标签不存在,返回 404 响应。
  • 分类视图,接见它将返回某个分类下的文章列表。假如接见的分类不存在,返回 404 响应。
  • 归档视图,接见它将返回某个月份下的悉数文章列表。
  • 概况视图,接见它将返回某篇文章的概况,假如接见的文章不存在,返回 404。
  • 自定义的 admin,增加文章后自动添补 author 字段的值。
  • RSS,返回悉数文章的 RSS 内容。

首页视图、标签视图、分类视图、归档视图都是统一范例的视图,他们预期的行动应当是:

  • 返回准确的响应码,胜利返回200,不存在则返回404。
  • 没有文章时准确地提醒暂无文章。
  • 衬着了准确的 html 模板。
  • 包括症结的模板变量,比方文章列表,分页变量等。

我们起首来测试这几个视图。为了给测试用例生成适宜的数据,我们起首定义一个基类,预先定义好博客的数据内容,别的视图函数测试用例继续这个基类,就不须要每次测试时都建立数据了。我们建立的测试数据以下:

  • 分类一、分类二
  • 标签一、标签二
  • 文章一,属于分类一和标签一,文章二,属于分类二,没有标签
class BlogDataTestCase(TestCase):
    def setUp(self):
        apps.get_app_config('haystack').signal_processor.teardown()

        # User
        self.user = User.objects.create_superuser(
            username='admin',
            email='admin@hellogithub.com',
            password='admin'
        )

        # 分类
        self.cate1 = Category.objects.create(name='测试分类一')
        self.cate2 = Category.objects.create(name='测试分类二')

        # 标签
        self.tag1 = Tag.objects.create(name='测试标签一')
        self.tag2 = Tag.objects.create(name='测试标签二')

        # 文章
        self.post1 = Post.objects.create(
            title='测试标题一',
            body='测试内容一',
            category=self.cate1,
            author=self.user,
        )
        self.post1.tags.add(self.tag1)
        self.post1.save()

        self.post2 = Post.objects.create(
            title='测试标题二',
            body='测试内容二',
            category=self.cate2,
            author=self.user,
            created_time=timezone.now() - timedelta(days=100)
        )

CategoryViewTestCase 为例:

class CategoryViewTestCase(BlogDataTestCase):
    def setUp(self):
        super().setUp()
        self.url = reverse('blog:category', kwargs={'pk': self.cate1.pk})
        self.url2 = reverse('blog:category', kwargs={'pk': self.cate2.pk})

    def test_visit_a_nonexistent_category(self):
        url = reverse('blog:category', kwargs={'pk': 100})
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_without_any_post(self):
        Post.objects.all().delete()
        response = self.client.get(self.url2)
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed('blog/index.html')
        self.assertContains(response, '临时还没有宣布的文章!')

    def test_with_posts(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed('blog/index.html')
        self.assertContains(response, self.post1.title)
        self.assertIn('post_list', response.context)
        self.assertIn('is_paginated', response.context)
        self.assertIn('page_obj', response.context)
        self.assertEqual(response.context['post_list'].count(), 1)
        expected_qs = self.cate1.post_set.all().order_by('-created_time')
        self.assertQuerysetEqual(response.context['post_list'], [repr(p) for p in expected_qs])

这个类起首继续自 BlogDataTestCasesetUp 要领别忘了挪用父类的 stepUp 要领,以便在每一个测试案例运转时,设置好博客测试数据。

然后就是举行了3个案例测试:

  • 接见一个不存在的分类,预期返回 404 响应码。
  • 没有文章的分类,返回200,但提醒临时还没有宣布的文章!衬着的模板为 index.html
  • 接见的分类有文章,则响应中应当包括系列症结的模板变量,post_listis_paginatedpage_objpost_list 文章数目为1,因为我们的测试数据中这个分类下只要一篇文章,post_list 是一个 queryset,预期是该分类下的悉数文章,时候倒序排序。

别的的 TagViewTestCase 等测试相似,请自行参照代码剖析。

博客文章概况视图的逻辑越发庞杂一点,所以测试用例也更多,重要须要测试的点有:

  • 接见不存在文章,返回404。
  • 文章每被接见一次,接见量 views 加一。
  • 文章内容被 markdown 衬着,并生成了目次。

测试代码以下:

class PostDetailViewTestCase(BlogDataTestCase):
    def setUp(self):
        super().setUp()
        self.md_post = Post.objects.create(
            title='Markdown 测试标题',
            body='# 标题',
            category=self.cate1,
            author=self.user,
        )
        self.url = reverse('blog:detail', kwargs={'pk': self.md_post.pk})

    def test_good_view(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed('blog/detail.html')
        self.assertContains(response, self.md_post.title)
        self.assertIn('post', response.context)

    def test_visit_a_nonexistent_post(self):
        url = reverse('blog:detail', kwargs={'pk': 100})
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_increase_views(self):
        self.client.get(self.url)
        self.md_post.refresh_from_db()
        self.assertEqual(self.md_post.views, 1)

        self.client.get(self.url)
        self.md_post.refresh_from_db()
        self.assertEqual(self.md_post.views, 2)

    def test_markdownify_post_body_and_set_toc(self):
        response = self.client.get(self.url)
        self.assertContains(response, '文章目次')
        self.assertContains(response, self.md_post.title)

        post_template_var = response.context['post']
        self.assertHTMLEqual(post_template_var.body_html, "<h1 id='标题'>标题</h1>")
        self.assertHTMLEqual(post_template_var.toc, '<li><a href="#标题">标题</li>')

接下来是测试 admin 增加文章和 rss 定阅内容,这一块比较简朴,因为大部份都是 django 的逻辑,django 已为我们举行了测试,我们须要测试的只是自定义的部份,确保自定义的逻辑根据预期的定义运转,而且得到了预期的效果。

关于 admin,预期的效果就是宣布文章后,确实自动添补了 author:

class AdminTestCase(BlogDataTestCase):
    def setUp(self):
        super().setUp()
        self.url = reverse('admin:blog_post_add')

    def test_set_author_after_publishing_the_post(self):
        data = {
            'title': '测试标题',
            'body': '测试内容',
            'category': self.cate1.pk,
        }
        self.client.login(username=self.user.username, password='admin')
        response = self.client.post(self.url, data=data)
        self.assertEqual(response.status_code, 302)

        post = Post.objects.all().latest('created_time')
        self.assertEqual(post.author, self.user)
        self.assertEqual(post.title, data.get('title'))
        self.assertEqual(post.category, self.cate1)
  • reverse('admin:blog_post_add') 猎取 admin 治理增加博客文章的 URL,django admin 增加文章的视图函数名为 admin:blog_post_add,平常 admin 背景操纵模子的视图函数定名规则是 <app_label>_<model_name>_<action>
  • self.client.login(username=self.user.username, password='admin') 登录用户,相当于背景登录治理员账户。
  • self.client.post(self.url, data=data) ,向增加文章的 url 提议 post 要求,post 的数据为须要宣布的文章内容,只指定了 title,body和分类。

接着我们举行一系列断言,确认是不是准确建立了文章。

RSS 测试也相似,我们期待的是,它返回的内容中确实包括了悉数文章的内容:

class RSSTestCase(BlogDataTestCase):

    def setUp(self):
        super().setUp()
        self.url = reverse('rss')

    def test_rss_subscription_content(self):
        response = self.client.get(self.url)
        self.assertContains(response, AllPostsRssFeed.title)
        self.assertContains(response, AllPostsRssFeed.description)
        self.assertContains(response, self.post1.title)
        self.assertContains(response, self.post2.title)
        self.assertContains(response, '[%s] %s' % (self.post1.category, self.post1.title))
        self.assertContains(response, '[%s] %s' % (self.post2.category, self.post2.title))
        self.assertContains(response, self.post1.body)
        self.assertContains(response, self.post2.body)

测试模板标签

这里测试的核心内容是,模板中 {% templatetag %} 被衬着成了准确的 HTML 内容。你能够看到测试代码中对应的代码:

context = Context(show_recent_posts(self.ctx))
template = Template(
    '{% load blog_extras %}'
    '{% show_recent_posts %}'
)
expected_html = template.render(context)

注重模板标签本质上是一个 Python 函数,第一句代码中我们直接挪用了这个函数,因为它须要吸收一个 Context 范例的标量,因而我们构造了一个空的 context 给它,挪用它将返回须要的上下文变量,然后我们构造了一个须要的上下文变量。

接着我们构造了一个模板对象。

末了我们运用构造的上下文去衬着了这个模板。

我们挪用了模板引擎的底层 API 来衬着模板,视图函数会衬着模板,返回响应,然则我们没有看到这个历程,是因为 django 帮我们在背地的挪用了这个历程。

悉数模板引擎的测试套路都是一样,构造须要的上下文,构造模板,运用上下文衬着模板,断言衬着的模板内容符合预期。认为例:

def test_show_recent_posts_with_posts(self):
    post = Post.objects.create(
        title='测试标题',
        body='测试内容',
        category=self.cate,
        author=self.user,
    )
    context = Context(show_recent_posts(self.ctx))
    template = Template(
        '{% load blog_extras %}'
        '{% show_recent_posts %}'
    )
    expected_html = template.render(context)
    self.assertInHTML('<h3 class="widget-title">最新文章</h3>', expected_html)
    self.assertInHTML('<a href="{}">{}</a>'.format(post.get_absolute_url(), post.title), expected_html)

这个模板标签对应侧边栏的最新文章版块。我们举行了2处症结性的内容断言。一个是包括最新文章版块标题,一个是内容中含有文章标题的超链接。

测试辅佐要领和类

我们的博客中只自定义了症结词高亮的一个逻辑。

class HighlighterTestCase(TestCase):
    def test_highlight(self):
        document = "这是一个比较长的标题,用于测试症结词高亮但不被截断。"
        highlighter = Highlighter("标题")
        expected = '这是一个比较长的<span class="highlighted">标题</span>,用于测试症结词高亮但不被截断。'
        self.assertEqual(highlighter.highlight(document), expected)

        highlighter = Highlighter("症结词高亮")
        expected = '这是一个比较长的标题,用于测试<span class="highlighted">症结词高亮</span>但不被截断。'
        self.assertEqual(highlighter.highlight(document), expected)

这里 Highlighter 实例化时吸收搜刮症结词作为参数,然后 highlight 将搜刮效果中症结词包裹上 span 标签。

Highlighter 事实上 haystack 为我们供应的类,我们只是定义了 highlight 要领的逻辑。我们又是怎样晓得 highlight 要领的逻辑呢?怎样举行测试呢?

我是看源码,大抵了解了 Highlighter 类的完成逻辑,然后我从 haystack 的测试用例中找到了 highlight 的测试要领。

所以,有时候不要恐惧去看源代码,Python 天下里一切都是开源的,源代码也没有什么神奇的处所,都是人写的,他人能写出来,你进修后也一样能写出来。单位测试的代码平常比较冗杂反复,但目标也异常明白,而且大都以次序逻辑构造,代码自成文档,异常好读。

纯真看文章中的解说你大概仍有疑惑,然则好好读一遍示例项目中测试部份的源代码,你肯定会对单位测试有一个越发清楚的熟悉,然后依葫芦画瓢,写出对本身项目代码的单位测试。

HelloDjango 往期回忆:

第 28 篇:Django Haystack 全文检索与症结词高亮

第 27 篇:开启 Django 博客完成简朴的全文搜刮

第 26 篇:开启 Django 博客的 RSS 功用

 

一篇文章带你「重新认识」线程上下文切换怎么玩儿

参与评论