編寫 Django 應用單元測試


作者:HelloGitHub-追夢人物

文中所涉及的示例代碼,已同步更新到 HelloGitHub-Team 倉庫

我們博客功能越來越來完善了,但這也帶來了一個問題,我們不敢輕易地修改已有功能的代碼了!

我們怎么知道代碼修改后帶來了預期的效果?萬一改錯了,不僅新功能沒有用,原來已有的功能都可能被破壞。此前我們開發一個新的功能,都是手工運行開發服務器去驗證,不僅費時,而且極有可能驗證不充分。

如何不用每次開發了新的功能或者修改了已有代碼都得去人工驗證呢?解決方案就是編寫自動化測試,將人工驗證的邏輯編寫成腳本,每次新增或修改代碼后運行一遍測試腳本,腳本自動幫我們完成全部測試工作。

接下來我們將進行兩種類型的測試,一種是單元測試,一種是集成測試。

單元測試是一種比較底層的測試,它將一個功能邏輯的代碼塊視為一個單元(例如一個函數、方法、或者一個 if 語句塊等,單元應該盡可能小,這樣測試就會更加充分),程序員編寫測試代碼去測試這個單元,確保這個單元的邏輯代碼按照預期的方式執行了。通常來說我們一般將一個函數或者方法視為一個單元,對其進行測試。

集成測試則是一種更加高層的測試,它站在系統角度,測試由各個已經經過充分的單元測試的模塊組成的系統,其功能是否符合預期。

我們首先來進行單元測試,確保各個單元的邏輯都沒問題后,然后進行集成測試,測試整個博客系統的可用性。

Python 一般使用標准庫 unittest 提供單元測試,django 拓展了單元測試,提供了一系列類,用於不同的測試場合。其中最常用到的就是 django.test.TestCase 類,這個類和 Python 標准庫的 unittest.TestCase 類似,只是拓展了以下功能:

  • 提供了一個 client 屬性,這個 client 是 Client 的實例。可以把 Client 看做一個發起 HTTP 請求的功能庫(類似於 requests),這樣我們可以方便地使用這個類測試視圖函數。
  • 運行測試前自動創建數據庫,測試運行完畢后自動銷毀數據庫。我們肯定不希望自動生成的測試數據影響到真實的數據。

博客應用的單元測試,主要就是和這個類打交道。

django 應用的單元測試包括:

  • 測試 model,model 的方法是否返回了預期的數據,對數據庫的操作是否正確。

  • 測試表單,數據驗證邏輯是否符合預期

  • 測試視圖,針對特定類型的請求,是否返回了預期的響應

  • 其它的一些輔助方法或者類等

接下來我們就逐一地來測試上述內容。

搭建測試環境

測試寫在 tests.py 里(應用創建時就會自動創建這個文件),首先來個冒煙測試,用於驗證測試功能是否正常,在 blog\tests.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 文件升級為一個包,不同的單元測試寫到包下對應的模塊中,這樣便於模塊化地維護和管理。

刪除 blog\tests.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 功能


關注公眾號加入交流群


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM