編寫 Django 應用單元測試

做者:HelloGitHub-追夢人物html

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

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

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

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

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

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

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

咱們首先來進行單元測試,確保各個單元的邏輯都沒問題後,而後進行集成測試,測試整個博客系統的可用性。服務器

Python 通常使用標準庫 unittest 提供單元測試,django 拓展了單元測試,提供了一系列類,用於不一樣的測試場合。其中最經常使用到的就是 django.test.TestCase 類,這個類和 Python 標準庫的 unittest.TestCase 相似,只是拓展瞭如下功能:markdown

  • 提供了一個 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 模板。
  • 包含關鍵的模板變量,例如文章列表,分頁變量等。

咱們首先來測試這幾個視圖。爲了給測試用例生成合適的數據,咱們首先定義一個基類,預先定義好博客的數據內容,其它視圖函數測試用例繼承這個基類,就不須要每次測試時都建立數據了。咱們建立的測試數據以下:

  • 分類1、分類二
  • 標籤1、標籤二
  • 文章一,屬於分類一和標籤一,文章二,屬於分類二,沒有標籤
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 世界裏一切都是開源的,源代碼也沒有什麼神祕的地方,都是人寫的,別人能寫出來,你學習後也同樣能寫出來。單元測試的代碼通常比較冗長重複,但目的也十分明確,並且大都以順序邏輯組織,代碼自成文檔,很是好讀。

單純看文章中的講解你可能仍有迷惑,可是好好讀一遍示例項目中測試部分的源代碼,你必定會對單元測試有一個更加清晰的認識,而後依葫蘆畫瓢,寫出對本身項目代碼的單元測試。


關注公衆號加入交流羣

相關文章
相關標籤/搜索