Django搭建個人博客:自動化測試

測試是伴隨着開發進行的,開發有多久,測試就要多久。本教程已經進行了30多章了,都是如何測試的?當然是runserver啦!每當開發新功能後,都需要運行服務器,假裝自己就是用戶,測試是否運行正常。

這樣的人工測試優點是非常直觀,你看到的和用戶看到的是完全相同的。但是缺點也很明顯:

  • **效率低。**在開發時可能你需要反覆的修改代碼、測試功能,這樣重複查看幾十次甚至幾百次網頁時會相當的讓人煩躁。
  • **容易遺漏bug。**隨着你的項目越來越複雜,組件之間的交互也更加複雜。修改某一個組件可能會導致另一個組件出現意想不到的bug,但是在人工測試時卻很難檢查出來,總不能每寫幾行代碼就把整個網站統統檢查一遍吧。過了很久之後你終於發現了這個bug,但此時你已經搞不清它來源於什麼地方了。
  • **有的測試不方便進行。**比如說有個功能,限制每個用戶每天發表評論不能超過10條,人工測試就顯得比較麻煩,特別是需要反覆調試的時候。

爲了解決人工測試的種種問題,Django引入了Python標準庫的單元測試模塊,也就是自動化測試了:你可以寫一段代碼,讓代碼幫你測試!(程序員是最會偷懶的職業…)代碼會忠實的完成測試任務,幫助你從繁重的測試工作中解脫出來。除此之外,自動化測試還有以下優點:

  • **預防錯誤。**當應用過於複雜時,代碼的意圖會變得非常不清晰,甚至你都看不懂自己寫的代碼,這是很常見的。而測試就好像是從內部審查代碼一樣,可以幫助你發現微小的錯誤。
  • **有利於團隊協作。**良好的測試保證其他人不會不小心破壞了你的代碼(也保證你不會不小心弄壞別人的…)。現在已經不是單打獨鬥出英雄的年代了,想要成爲優秀的Django程序員,你必須擅長編寫測試!

雖然學習自動化測試不會讓你的博客增加一絲絲的功能,但是可以讓代碼更加強壯,所以我覺得很有必要拿出一章來專門講講。

Django官方文檔的第5部分講測試講得非常的好,並且有中文版本。本章節就大量借鑑了官方文檔,也非常非常推薦讀者去拜讀。

第一個測試

給我bug!

爲了演示測試是如何工作的,讓我們首先在文章模型中寫個有bug的方法:

article/models.py

from django.utils import timezone

class ArticlePost(models.Model):
    ...

    def was_created_recently(self):
        # 若文章是"最近"發表的,則返回 True
        diff = timezone.now() - self.created
        if diff.days <= 0 and diff.seconds < 60:
            return True
        else:
            return False

這個方法用於檢測當前文章是否是最近發表的。

這個方法稍微擴展一下就會變得非常實用。比如可以將博文的發表日期顯示爲“剛剛”、“3分鐘前”、“5小時前”等相對時間,用戶體驗將大有提升。

仔細看看,它是沒辦法正確判斷“未來”的文章的:

>>> import datetime
>>> from django.utils import timezone
>>> from article.models import ArticlePost
>>> from django.contrib.auth.models import User

# 創建一篇"未來"的文章
>>> future_article = ArticlePost(author=User(username='user'), title='test',body='test', created=timezone.now() + datetime.timedelta(days=30))

# 是否是“最近”發表的?
>>> future_article.was_created_recently()
True

未來發生的肯定不是最近發生的,因此代碼是錯誤的。

寫個測試暴露bug

接下來就要寫測試用例,將測試轉爲自動化。

還記得最初生成文章app時候的目錄結構嗎?

article
 │  admin.py
 │  apps.py
 │  models.py
 │  tests.py
 │  views.py
 │  __init__.py
 │
 └─migrations
       └─ __init__.py

這個tests.py就是留給你寫測試用例的地方了:

article/tests.py

from django.test import TestCase

import datetime
from django.utils import timezone
from article.models import ArticlePost
from django.contrib.auth.models import User


class ArticlePostModelTests(TestCase):

    def test_was_created_recently_with_future_article(self):
        # 若文章創建時間爲未來,返回 False
        author = User(username='user', password='test_password')
        author.save()

        future_article = ArticlePost(
            author=author,
            title='test',
            body='test',
            created=timezone.now() + datetime.timedelta(days=30)
            )

        self.assertIs(future_article.was_created_recently(), False)

基本就是把剛纔在Shell中的測試代碼抄了過來。有點不同的是末尾這個assertIs方法,瞭解**“斷言”**的同學會對它很熟悉:它的作用是檢測方法內的兩個參數是否完全一致,如果不是則拋出異常,提醒你這個地方是有問題滴。

接下來運行測試:

(env) > python manage.py test

運行結果如下:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_created_recently_with_future_article (article.tests.ArticlePostModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "E:\django_project\my_blog\article\tests.py", line 19, in test_was_created_recently_with_future_article
    self.assertIs(future_article.was_created_recently(), False)
AssertionError: True is not False

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
Destroying test database for alias 'default'...

這裏面名堂就很多了:

  • 首先測試系統會在所有以tests開頭的文件中尋找測試代碼
  • 所有TestCase的子類都被認爲是測試代碼
  • 系統創建了一個特殊的數據庫供測試使用,即所有測試產生的數據不會對你自己的數據庫造成影響
  • 類中所有以test開頭的方法會被認爲是測試用例
  • 在運行測試用例時,assertIs拋出異常,因爲True is not False
  • 完成測試後,自動銷燬測試數據庫

測試系統明確指明瞭錯誤的數量、位置和種類等信息,請讀者細細品嚐。

修正bug

既然通過測試找到了bug,那接下來就要把代碼進行修正:

article/models.py

from django.utils import timezone

class ArticlePost(models.Model):
    ...

    def was_created_recently(self):
        diff = timezone.now() - self.created
        
        # if diff.days <= 0 and diff.seconds < 60:
        if diff.days == 0 and diff.seconds >= 0 and diff.seconds < 60:
            return True
        else:
            return False

重新運行測試:

(env) > python manage.py test

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

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

這次代碼順利通過了測試。

可以肯定的是,在往後的開發中,這個bug不會再出現了,因爲你只需要運行一遍測試,就會立即得到警告。可以認爲項目的這一小部分代碼永遠是安全的

更全面的測試

既然一個測試用例就可以保證一小段代碼永遠安全,那我寫一堆測試豈不是可以保證整個項目永遠安全嗎?確實如此,這個買賣絕對是不虧的。

因此我們繼續再增加幾個測試,全面強化代碼:

article/tests.py

...

from django.test import TestCase

import datetime
from django.utils import timezone
from article.models import ArticlePost
from django.contrib.auth.models import User


class ArticlePostModelTests(TestCase):

    def test_was_created_recently_with_future_article(self):
        # 若文章創建時間爲未來,返回 False
        ...

    def test_was_created_recently_with_seconds_before_article(self):
        # 若文章創建時間爲 1 分鐘內,返回 True
        author = User(username='user1', password='test_password')
        author.save()
        seconds_before_article = ArticlePost(
            author=author,
            title='test1',
            body='test1',
            created=timezone.now() - datetime.timedelta(seconds=45)
            )
        self.assertIs(seconds_before_article.was_created_recently(), True)

    def test_was_created_recently_with_hours_before_article(self):
        # 若文章創建時間爲幾小時前,返回 False
        author = User(username='user2', password='test_password')
        author.save()
        hours_before_article = ArticlePost(
            author=author,
            title='test2',
            body='test2',
            created=timezone.now() - datetime.timedelta(hours=3)
            )
        self.assertIs(hours_before_article.was_created_recently(), False)

    def test_was_created_recently_with_days_before_article(self):
        # 若文章創建時間爲幾天前,返回 False
        author = User(username='user3', password='test_password')
        author.save()
        months_before_article = ArticlePost(
            author=author,
            title='test3',
            body='test3',
            created=timezone.now() - datetime.timedelta(days=5)
            )
        self.assertIs(months_before_article.was_created_recently(), False)

現在我們擁有了4個測試,來保證was_created_recently()方法對於過去最近未來中的4種情況都返回正確的值。你還可以繼續擴展,直到你覺得完全沒有任何bug藏匿的可能性爲止。

在實際的開發中,有些難纏的bug會把自己僞裝得非常的好,而不是像教程這樣明確的知道它就在那裏。有了自動化測試,無論以後你的項目怎麼變化、app交互多麼的複雜,只要在測試中寫好的邏輯就一定是符合預期的,而你所需要做的只是運行一條測試指令而已。

雖然教程中僅使用了assertIs,但實際上Django中的斷言有大概幾十種之多,比如assertEqualassertContains等,並且還在不斷更新。詳見Python標準斷言Django擴展斷言

測試視圖

上面的測試都是針對模型的。視圖該怎麼測試?如何通過測試系統模擬出用戶的請求呢?

答案是TestCase類提供了一個供測試使用的Client來模擬用戶通過請求和視圖層代碼的交互。

文章詳情視圖瀏覽量統計爲例,比較容易出現的潛在bug有:

  • 增加的瀏覽量未能正常保存進數據庫(即每次請求則瀏覽量+1)
  • 增加瀏覽量的同時,updated字段也錯誤的一併更新

所以有針對的寫2條測試。新寫一個專門測試視圖的類,與前面的測試模型的類區分開:

article/tests.py

...
from time import sleep
from django.urls import reverse


class ArticlePostModelTests(TestCase):
    ...


class ArtitclePostViewTests(TestCase):

    def test_increase_views(self):
        # 請求詳情視圖時,閱讀量 +1
        author = User(username='user4', password='test_password')
        author.save()
        article = ArticlePost(
            author=author,
            title='test4',
            body='test4',
            )
        article.save()
        self.assertIs(article.total_views, 0)

        url = reverse('article:article_detail', args=(article.id,))
        response = self.client.get(url)

        viewed_article = ArticlePost.objects.get(id=article.id)
        self.assertIs(viewed_article.total_views, 1)

    def test_increase_views_but_not_change_updated_field(self):
        # 請求詳情視圖時,不改變 updated 字段
        author = User(username='user5', password='test_password')
        author.save()
        article = ArticlePost(
            author=author,
            title='test5',
            body='test5',
            )
        article.save()

        sleep(0.5)

        url = reverse('article:article_detail', args=(article.id,))
        response = self.client.get(url)

        viewed_article = ArticlePost.objects.get(id=article.id)
        self.assertIs(viewed_article.updated - viewed_article.created < timezone.timedelta(seconds=0.1), True)

注意看代碼是如何與視圖層交互的:response = self.client.get(url)向視圖發起請求並獲得了響應,剩下的就是從數據庫中取出更新後的數據,並用斷言語句來判斷代碼是否符合預期了。

運行測試:

(env) > python manage.py test

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......
----------------------------------------------------------------------
Ran 6 tests in 0.617s

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

6條測試用例全部通過。

越多越好的測試

僅僅是app中的兩個非常小的功能,就已經寫了6條測試用例了,並且還可以繼續擴展。除此之外,其他的每個模型、視圖都可以擴展出幾十甚至上百條測試,這樣下去代碼總量很快就要失去控制了,並且相對於業務代碼來說,測試代碼顯得繁瑣且不夠優雅。

**但是沒關係!**就讓測試代碼繼續肆意增長吧。大部分情況下,你寫完一個測試之後就可以忘掉它了。在你繼續開發的過程中,它會一直默默無聞地爲你做貢獻的。最壞的情況是當你繼續開發的時候,發現之前的一些測試現在看來是多餘的。但是這也不是什麼問題,多做些測試也不錯。

深入代碼測試

在前面的測試中,我們已經從模型層和視圖層的角度檢查了應用的輸入輸出,但是模板呢?雖然可以用assertInHTMLassertJSONEqual等斷言大致檢查模板中的某些內容,但更加近似於瀏覽器的檢查就要使用Selenium等測試工具(畢竟Django的重點是後端而不是前端)。

Selenium不僅可以測試 Django 框架裏的代碼,甚至還可以檢查 JavaScript代碼。它假裝成是一個正在和你站點進行交互的瀏覽器,就好像有個真人在訪問網站一樣。Django 提供了LiveServerTestCase來和Selenium這樣的工具進行交互。

關於測試的話題這裏只是開了個頭,讀者可以繼續閱讀下面的內容進一步瞭解:

總結

有一幫崇尚“測試驅動”的開發者,他們開發時先寫測試代碼,然後才寫業務代碼。而普通開發者通常是先寫業務代碼,再寫測試代碼,這也是沒問題的。但如果你已經寫了很多業務代碼了,再回頭寫測試確實有些無從下手,那麼至少在以後寫新功能時,記得加上測試。測試寫得好不好,甚至比功能本身更能看出編程水平。

測試可以讓代碼更加強壯。項目沒出bug時,皆大歡喜,有沒有測試都一樣;一旦出現難纏的bug,你就會無比想念一套完善的測試代碼了。

博主寫自己的網站時就沒有對測試給與足夠的重視,回想起來走了很多彎路。希望讀者以前車之鑑,培養良好的編程習慣。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章