編寫單元測試主要有2個目的:
- 實現新功能時能夠確保新添加的代碼按預期方式運行;
- 每次修改應用後,運行單元測試能確保現有代碼的功能沒有迴歸,即新改動沒有影響代碼的正常運行;
從一開始我們就爲Flasky應用編寫了單元測試,檢查數據庫模型類有沒有實現功能。由於模型類很容易在運行中的應用上下文之外進行測試,故應用中我們將絕大多數業務邏輯都放在了模型類中實現,視圖函數中的代碼只起到粘合劑的作用。爲模型類編寫單元測試,可以覆蓋絕大多數代碼。本章我們將再介紹2中單元測試的類別:
- 使用Flask客戶端測試視圖函數
- 使用selenium進行端到端的測試
我們使用包來組織測試,tests包中,各模塊都以test_*開頭。unittest支持自動發現測試,執行python -m unittest discover -v運行測試時,unittest默認會從當前目錄開始尋找以test_*.py模式命名的模塊,然後運行其中的測試。
視圖函數只在在請求上下文和運行的應用裏運行。Flask內建了一個測試客戶端用於解決(至少部分解決)這一問題。測試客戶端能復現應用運行在Web服務器中的環境,讓測試充當客戶端來發送請求。
tests/test_client.py:使用Flask測試客戶端編寫的測試框架
import re
import unittest
from app import create_app, db
from app.models import User, Role
class FlaskClientTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
# 應用上下文激活以後,纔可以使用db
db.create_all()
Role.insert_rows()
self.client = self.app.test_client(use_cookies=True)
def tearDown(self):
db.drop_all()
self.app_context.pop()
def test_home_page(self):
response = self.client.get('/')
self.assertTrue(response.status_code, 200)
self.assertIn(b'Stranger', response.data)
在執行測試時是沒有Flask上下文存在的。但有些行爲又依賴於程序上下文或請求上下文才能正確進行。比如,Flask-SQLAlchemy中用來清除數據庫會話的db.session.remove()調用通過teardown_appcontext裝飾器註冊,而這個函數只會在程序上下文銷燬時纔會觸發。
另外,當時用工廠函數創建程序時,我們使用current_app來操作程序實例。事實上,除了我們程序中使用的代碼,擴展的代碼中也會使用current_app。比如,Flask-SQLAlchemy需要從程序實例獲取配置信息。直接創建程序實例,並在實例化SQLAlchemy類時傳入程序實例時,Flask-SQLAlchemy會直接從這個程序實例app對象獲取配置信息。當使用工廠函數創建程序實例並使用init_app()初始化程序後,Flask-SQLAlchemy則會從current_app對象獲取程序配置信息。
使用app_context()和test_request_context()方法可以手動激活程序上下文和請求上下文。
新增的self.client實例變量即Flask測試客戶端對象,在這個對象上調用請求方法嚮應用發起請求。如果創建測試客戶端時啓用了use_cookies選項,這個客戶端就能像瀏覽器一樣接收和發送cookie。
test_home_page()演示了測試客戶端的使用,向根路徑發送GET請求後,我們先檢查響應的狀態碼,然後使用response.data獲取響應體,類型爲字節串,所以使用b'Stranger'。使用response.get_data(as_text=True)可以獲取字符串形式。
測試客戶端還可以使用POST方法發送包含表單數據的POST請求。但需要注意,Flask-WTF生成的表單中包含一個隱藏字段,其內容是CSRF令牌。爲了發送CSRF令牌,測試必須請求表單所在的頁面,然後解析響應返回的HTML代碼,提取CSRF令牌。簡單起見,最好在測試環境中禁用CSRF保護機制。
config.py 在測試配置中禁用CSRF保護機制
class TestingConfig(Config):
TESTING = True
# 未指定環境變量時,測試換將將使用內存數據庫sqlite
SQLALCHEMy_DATABASE_URI = os.environ.get('TEST_DATABASE_URI') or 'sqlite://'
WTF_CSRF_ENABLED = False
tests/test_client.py:使用Flask測試客戶端模擬新用戶註冊的整個流程
class FlaskClientTestCase(unittest.TestCase):
... ...
def test_register_and_login(self):
# register a new account
response = self.client.post('/auth/register', data={
'email': '[email protected]',
'username': 'Bob',
'password': '123',
'password2': '123',
})
self.assertEqual(response.status_code, 302)
# login with the new account
response = self.client.post('/auth/login', data={
'email': '[email protected]',
'password': '123'
}, follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertTrue(re.search(b'Hello,\s+Bob!', response.data))
self.assertTrue(b'You have not confirmed your account yet.' in response.data)
# send a confirmation token
user = User.query.filter_by(email='[email protected]').first()
token = user.generate_confirmation_token()
response = self.client.get('/auth/confirm/{}'.format(token), follow_redirects=True)
self.assertTrue(response.status_code, 200)
self.assertTrue(b'You have confirmed your account. Thanks!' in response.data)
# logout
response = self.client.get('/auth/logout', follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertTrue(b'You have been logged out.' in response.data)
這個測試先向註冊路由提交一個表單。post方法的data參數是一個字典,包含表單中的各字段,字段的名稱和表單字段的name屬性保持一致。由於CSRF保護機制已經在配置中禁用了,因此無需和表單數據一起發送。爲了確認註冊成功,測試檢測響應的狀態碼是否爲302,表示重定向。
接下來使用剛剛註冊的email登錄應用,調用post方法時指定了參數follow_redirects=True,讓客戶端像瀏覽器那樣,自動向重定向的URL發起GET請求。指定這個參數後,返回的不是302狀態碼,而是請求重定向的URL返回的響應。
成功登錄後的響應應該是一個頁面,顯示一個包含用戶名的歡迎消息,並提醒用戶去確認賬戶。值得注意的是在檢查“Hello Bob”時,由於字符串由靜態部分和動態部分組成,Jinjia2模板生成最終的HTML會在中間加上額外的空格。因此我們使用正則進行匹配。
在進行確認賬戶操作時,我們忽略了註冊時生成的令牌,直接調用User模型相關方法產生新令牌,構建URL進行確認。向這個包含令牌的URL發起GET請求,這個請求的響應是重定向到首頁,故這裏再次指定了參數follow_redirects=True。