1. 程式人生 > >編寫 Django 應用單元測試

編寫 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='[email protected]', 
            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='[email protected]',
            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 功能


關注公眾號加入交流