iT邦幫忙

0

Django學習紀錄 19.測試

之前有在用Django寫一些小網站,現在暑假想說再來複習一下之前買的這本書
https://ithelp.ithome.com.tw/upload/images/20190724/20118889bj9fH1vhuR.jpg
於是我就把它寫成一系列的文章,也方便查語法
而且因為這本書大概是2014年出的,如今Django也已經出到2.多版
有些內容也變得不再支援或適用,而且語法或許也改變了
所以我會以最新版的Python和Django來修正這本書的內容跟程式碼

目錄:django系列文章-Django學習紀錄

19.測試

19.1 關於自動化測試

19.2 執行測試

(python) manage.py test

結果

System check identified no issues (0 silenced).

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

因為我們還沒加入測試檔,所以自然就不會有任何測試被執行

19.3 撰寫測試

19.3.1 簡易的單元測試

為了方便示範,新建一個新的應用zoo

(python) manage.py startapp zoo

在settings.py中加入

INSTALLED_APPS = [
    ...
    'zoo',
]

mysite/zoo/models.py

from django.db import models

class Animal(models.Model):
    name = models.CharField(max_length=256)

    def says(self):
        raise NotImplementedError("I don't know what to do")

    class Meta:
        abstract = True

class Dog(Animal):

    def says(self):
        return "woof"

class Cat(Animal):

    def says(self):
        return "meow"

同步資料庫

(python) manage.py makemigrations
(python) manage.py migrate

現在我們可以測試這些動物says()方法的結果是不是如我們所想
把測試寫在zoo應用裡的tests.py檔案(應用建立時預設就會產生tests.py)
mysite/zoo/tests.py

from django.test import TestCase
from zoo import models

class AnimalTestCase(TestCase):
    def test_dog_says(self):
        dog = models.Dog(name="Snoopy")
        self.assertEqual(dog.says(), 'woof')

    def test_cat_says(self):
        cat = models.Cat(name="Garfield")
        self.assertEqual(cat.says(), 'meow')

讓AnimalTestCase這個測試用類別來繼承TestCase這個基礎類別
並在該類別底下定義數個測試用方法(函式)用以測試
要注意的是用來測試的函式都需以test_開頭
這樣Django才能自動找尋並執行這些測試函式
測試類別的assertEqual會做相等性的測試
它會比較第一個參數執行的結果與第二個參數是否相同
如果正確就會繼續執行,如果不正確則會出現AssertionError
我們來實際執行測試

(python) manage.py test

結果

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

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

成功執行這兩個測試,並成功通過測試
現在如果把第二個參數改成錯的
tests.py

from django.test import TestCase
from zoo import models

class AnimalTestCase(TestCase):
    def test_dog_says(self):
        dog = models.Dog(name="Snoopy")
        self.assertEqual(dog.says(), 'woo') <- 改成錯的

    def test_cat_says(self):
        cat = models.Cat(name="Garfield")
        self.assertEqual(cat.says(), 'meow')

再執行一次測試結果

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.F
======================================================================
FAIL: test_dog_says (zoo.tests.AnimalTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\vs code\python\demo\mysite\zoo\tests.py", line 7, in test_dog_says
    self.assertEqual(dog.says(), 'woo')
AssertionError: 'woof' != 'woo'
- woof
?    -
+ woo


----------------------------------------------------------------------
Ran 2 tests in 0.003s

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

這時就會看到有一個Fail出現
失敗的點在zoo.tests.AnimalTestCase這個測試類別中的test_dog_says
失敗的原因是因為'woof' != 'woo'
另外要注意的是,當測試失敗後,導致錯誤的方法(函式)將不會繼續被執行,但是其他以test_開頭的測試方法則會繼續執行喔
以上這種形式的測試叫做單元測試,是擁有最基礎功能的測試方法

(python) manage.py test

這個會執行整個專案中所有的測試檔(以test開頭的檔案)
如果只想執行某個應用的所有測試檔,譬如zoo

(python) manage.py test zoo

只想執行某個應用裡的某個測試檔,譬如zoo/tests.py

(python) manage.py test zoo.tests

只想測試某個測試檔的TestCase,譬如zoo/tests.py裡的AnimalTestCase

(python) manage.py test zoo.tests.AnimalTestCase

甚至是只想執行某個測試函式

(python) manage.py test zoo.tests.AnimalTestCase.test_dog_says

19.3.2 訪問網站的測試

Django提供了一個HTTP Client的物件,可以模擬客戶端發出的請求,並取得回應
要使用這個功能首先要先在settings.py中設定

ALLOWED_HOSTS = ['testserver'] # <-加入'testserver'

打開Django shell

>>> from django.test import client
>>> c = client.Client()
>>> response = c.get('/index/')
>>> response.status_code
200
>>> response.content
b'<!DOCTYPE html>\n<html lang="en">\n    <head>\n        <meta charset="UTF-8">\n
      <title> \xe9\xa6\x96\xe9\xa0\x81 </title>\n    </head>\n    <body>\n        <h2>\n    <p>\xe6\xad\xa1\xe8\xbf\x8e\xe4\xbe\x86\xe5\x88\xb0\xe9\xa4\x90\xe5\xbb\xb3\xe7\x8e\x8b</p>\n    July 27, 2019, 6:04 a.m.\n</h2>\n        \n            <p>\xe6\x82\xa8\xe5\xb0\x9a\xe6\x9c\xaa\xe7\x99\xbb\xe5\x85\xa5\xe5\x96\x94~<a href="/accounts/login/">\xe7\x99\xbb\xe5\x85\xa5</a></p>\n        \n        <p>\xe8\x8b\xa5\xe6\x82\xa8\xe9\x82\x84\xe6\xb2\x92\xe6\x9c\x89\xe5\xb8\xb3\xe8\x99\x9f\xef\xbc\x8c\xe8\xab\x8b<a href="/accounts/register/">\xe8\xa8\xbb\xe5\x86\x8a</a></p>\n    </body>\n</html>'
>>> response.content.decode() # 解碼,把二位元字串轉成普通字串
'<!DOCTYPE html>\n<html lang="en">\n    <head>\n        <meta charset="UTF-8">\n
     <title> 首頁 </title>\n    </head>\n    <body>\n        <h2>\n    <p>歡迎來到
餐廳王</p>\n    July 27, 2019, 6:04 a.m.\n</h2>\n        \n            <p>您尚未登
入喔~<a href="/accounts/login/">登入</a></p>\n        \n        <p>若您還沒有帳號,
請<a href="/accounts/register/">註冊</a></p>\n    </body>\n</html>'

利用client.Client()建立出一個客戶端物件
它的get方法可以指定一個URL路徑,發送請求並取得回應(即response)
response.status_code回傳網頁狀態,response.content回傳網頁的html內容
修改tests.py

from django.test import TestCase
from django.test import client

class IndexWebpageTestCase(TestCase):

    def setUp(self):
        self.c = client.Client()

    def test_index_visiting(self):
        resp = self.c.get('/index/')
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, '<p>歡迎來到餐廳王</p>')
        self.assertTemplateUsed(resp, 'index.html')

就可以執行測試了
其中的setUp函式是每個測試(以test_開頭的函式)執行之前都會預先執行的函式,所以如果有什麼預設的資料或參數就可以在這個函式中做處理與設定,在上面的例子中,我們宣告了客戶端物件self.c
test_index_visiting是主要的測試函式,利用get方法向/index/頁面請求,並得到回應resp,接著利用assertEqual驗證網頁狀態是否是200,用assertContains驗證網頁內容是否包含了'<p>歡迎來到餐廳王</p>',用assertTemplateUsed驗證網頁所使用的模板'index.html'
如果要測試帶有查詢字串的GET方法,要這樣做

>>> from django.test import client
>>> c = client.Client()
>>> c.get('/index/?param=value')

>>> c.get('/index/', {'param': 'value')

以上兩個方法是等效的,第二個方法多了一個字典參數,裡面的鍵值對就是查詢字串中的name和value
如果是模擬POST方法

>>> c.post('/post/url', {'field': 'value')

也可以夾帶檔案

>>> c.post('/post/url', {'attachment': open('file.txt', 'r')})

19.3.3 登入、登出的測試

登入登出的測試要分兩種情況討論:
1.若是使用Django內建的權限系統:那會有內建的登入登出功能可以在測試時使用
2.自行實作或第三方的權限系統:必須使用HTTP協定及狀態碼自行撰寫測試,但若第三方權限系統支援Django的權限系統,則可能可以使用Django內建的登入登出功能
tests.py

from django.test import TestCase
from django.test import client
from django.contrib.auth.models import User

class LoginTestCase(TestCase):

    def setUp(self):
        User.objects.create_user('user', email='user@example.com', password = 'abcde')
        self.c = client.Client()

    def test_login_and_logout(self):
        resp = self.c.get('/restaurants_list/')
        # 未登入的訪問,測試重新導向
        self.assertRedirects(resp, '/accounts/login/?next=/restaurants_list/')
        # Django 內建的登入
        self.c.login(username='user', password='abcde')
        resp = self.c.get('/restaurants_list/') # 登入後的訪問
        self.assertEqual(resp.status_code, 200)
        self.assertTemplateUsed(resp, 'restaurants_list.html')
        self.c.logout()
        resp = self.c.get('/restaurants_list/')
        self.assertRedirects(resp, '/accounts/login/?next=/restaurants_list/')

首先用setUp方法新增一個使用者'user'和生成客戶端物件
接著在未登入的狀況下請求'/restaurants_list/'頁面,此時頁面應該要被重導到'/accounts/login/?next=/restaurants_list/'才對,如果忘記了可以回去看看
Django學習紀錄 14.權限與註冊
這時就可以用assertRedirects方法來檢查頁面是否有被重導至'/accounts/login/?next=/restaurants_list/',第二個參數放重導的URL
接著使用客戶端物件的login方法,幫我們登入,登入完畢後再做一次要求
最後使用客戶端物件的logout方法來登出,然後再一次驗證重導
上面的範例為使用Django內建的權限系統
由Client物件內建的函式便可以輕鬆地完成模擬登入與登出
如果是自行實作的權限系統就得使用HTTP協定來完成這些事情
而使用Django內建的權限系統也是可以透過HTTP協定來完成
以下將示範如何使用HTTP協定來模擬登入與登出的測試
tests.py

from django.test import TestCase
from django.test import client
from django.contrib.auth.models import User

class LoginTestCase(TestCase):

    def setUp(self):
        User.objects.create_user('user', email='user@example.com', password='abcde')
        self.c = client.Client()

    def test_login_and_logout_by_http_protocol(self):
        resp = self.c.get('/restaurants_list/') # 未登入的訪問
        self.assertRedirects(resp, '/accounts/login/?next=/restaurants_list/')
        # 自行post登入資料至登入網址並檢查重導網頁
        # 使用follow=True取得重新導向路徑
        resp = self.c.post('/accounts/login/', {'username': 'user', 'password': 'abcde'}, follow=True)
        self.assertEqual(resp.redirect_chain, [('/index/', 302)])
        resp = self.c.get('/restaurants_list/')
        self.assertEqual(resp.status_code, 200)
        self.assertTemplateUsed(resp, 'restaurants_list.html')
        # 自行訪問登出網頁進行登出
        self.c.get('/accounts/logout/')
        resp = self.c.get('/restaurants_list/')
        self.assertRedirects(resp, '/accounts/login/?next=/restaurants_list/')

登入與登出的方式由loginlogout改為HTTP的getpost方法
首先post了使用者的帳號和密碼去/accounts/login/頁面,follow這個參數表示接受重導並取得重導的chain,最後利用assertEqual來驗證這個redirect_chain的內容,302是HTTP狀態,代表暫時重導,登出因為不須帳密,所以用GET方法即可
而這個登入的POST動作之所以會成功,而不會出現CSRF的錯誤是因為Django在測試時預設會將CSRF檢查關閉
如果想要打開CSRF檢查可以在生成Client實例時這樣做

>>> c = client.Client(enforce_csrf_checks=True)

19.4 其他測試設定

19.4.1 獨立測試設定檔

測試時可以撰寫一個不同於開發或是部署的網站設定,一個不一樣的settings.py
譬如我們可以撰寫一個設定檔settings_test.py

from mysite.settings import *
# 接著在下面覆寫參數
DATABASES = ...

這個設定檔透過from mysite.settings import *來取得settings.py原有的參數設定
接著透過在settings_test.py中賦值來覆寫某些參數的設定
撰寫好後,就可以在測試時使用settings_test.py作為設定檔

(python) manage.py test --settings=mysite.settings_test

19.4.2 測試檔案架構

Django在執行測試時,預設會自動找尋以test開頭並以.py結尾的路徑及檔案,即它會以test*.py去匹配路徑及檔名,如果符合這個規則就會去找尋檔案中的TestCase執行
這個規則是可以自訂的

(python) maange.py test --pattern=mytest*.py

就可以指定要測試的檔案名稱樣式
如果是資料夾的話Django只會找尋套件(資料夾)名稱符合規則的,因此要在目錄底下新增__init__.py,這樣目錄才會被python視為套件,而被Django找尋到

19.4.3 加速測試建置的時間

Django在執行測試時,最主要拖慢時間的因素不是在設定程式碼,不是驗證過程,而是在創建和銷毀測試用的資料庫、一些安全考量的因素以及不經意的設定

使用輕量級的資料庫

相較於MySQL和PostgreSQL,Django預設的SQLite是較輕量的資料庫,所以測試時就可以使用較輕量的資料庫來加快速度,因為一旦在測試時使用的資料庫為SQLite,Django會略過產生真正的資料庫檔案,而是直接將資料庫存在記憶體中,變成連檔案IO都不用做的存在記憶體中的資料庫,大幅度降低了資料庫創建造成的時間障礙

from mysite.settings import *

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}

使用較簡易的加密

例如使用md5對密碼進行加密

PASSWORD_HASHERS = [
    'django.contrib.auth.hashers.MD5PasswordHasher',
]

移除不必要的應用或中間層

如果我這次只修改了APP1這個應用,那在測試時也只需要找尋APP1下的測試,那就可以更改測試用設定:

INSTALLED_APPS = [
    'APP1'
]

這樣執行測試時其它的應用就會略過了
而中間層MIDDLEWARE也可以把不會用到的部分暫時移除

19.4.4 加入預先設定好的資料

在測試時,Django會使用一個全新的測試用的資料庫,並在測試完畢後自動銷毀,所以我們不必擔心測試會影響到我們的真正的資料庫,不過也因為如此,一個空的資料庫難以提供一些初始的素材讓我們測試
所以Django有提供我們fixture功能,可以把要預先載入測試用資料庫的資料(初始的測試素材),寫入fixture檔案裡,fixture檔可以是json、xml或是yaml格式的檔案,假設我們需要一隻狗的資料當作測試素材
dog.json

[
    {
        "model": "zoo.dog", // 注意模型名稱要用小寫
        "pk": 1,
        "fields":{
            "name": "Snoopy"
        }
    }
]

利用json檔列出該筆資料的模型:zoo應用中的Dog模型(注意這裡要用小寫名稱),還有此資料的主鍵("pk": 1)以及它的欄位資訊
把該fixture檔放入fixtures的目錄中(目錄名稱可以取作其他名字)
假設這個目錄放在上層mysite底下
那就設定settings_test.py

FIXTURE_DIRS = [
    os.path.join(BASE_DIR, 'fixtures')
]

接著在會用到該筆資料的測試中加入fixtures這個變數
test_simple_fixture.py

class SimpleTestCase(TestCase):
    fixtures = ['dog.json']

    def test_dog_fixture(self):
        snoopy = Dog.objects.get(id=1)
        self.assertEqual(snoopy.name, 'Snoopy')

fixtures裡面指定了該測試會用到的所有fixture(初始素材)
接著執行測試

(python) manage.py test --settings=settings_test

如果覺得手動撰寫fixture很麻煩,Django也有提供方法讓我們從原本的資料庫中dump出fixture
假如想從原本的資料庫中匯出所有Dog模型的資料到fixture檔

(python) manage.py dumpdata --indent=4 --format=json zoo.dog >>  fixtures/dog.json

Django也提供了將fixture匯入原本資料庫的動作

(python) manage.py loaddata fixtures/dog.json

要注意的是這會直接覆寫相對應主鍵的資料

下一篇將是最重要的部分-網站的部署,我將會整理書上以及網路上的資料,做出一個適用最新版Django的部署教學

上一篇:Django學習紀錄 18.資料庫與模型進階技巧

下一篇:Django學習紀錄 20.部署django至heroku平台


尚未有邦友留言

立即登入留言