編寫測試單元的目的主要有兩個,實現新功能時,單元測試能夠確保新添加的代碼按預期方式運行,這個過程也可手動完成,不過自動化測試顯然能有效節省時間和精力
另一重要目的是每次修改程序後,運行單元測試能保證現有代碼的功能沒有退化, 也就是說改動沒有影響原有代碼的正常運行
在最開始,單元測試就是Flasky開發的一部分,我們爲數據庫模型類中實現的程序功能編寫了測試,模型類很容易在運行中的程序上下文之外進行測試,因此不用花費太多精力,爲數據庫模型中是瞎玩呢的全部功能編寫測試,這至少能有效保證程序這部分在不斷完善的過程中仍能按預期運行
獲取代碼覆蓋報告
編寫測試組件很重要,但知道測試的好壞同樣重要,代碼覆蓋工具用來統計單元測試檢查了多少程序功能,並提供一個詳細的報告,說明程序的哪些代碼沒有測試到,這個信息非常重要,因爲它能指引你爲最需要測試的部分編寫出新測試
Python提供了一個優秀的代碼覆蓋工具coverage,可以使用pip安裝
這個工具本身是一個命令行腳本,可以在任何一個Python程序中檢查代碼覆蓋,除此之外它還提供了更方便的腳本訪問功能,使用編程方式啓動覆蓋檢查引擎,爲了能更好地把覆蓋監測集成到啓動腳本manage.py
中,我們可以增強之前我們自定義的test命令,添加可選選項--coverage
,這個選項的實現方式如下:
import os
COV = None
if os.environ.get('FLASK_COVERAGE'):
import coverage
COV = coverage.coverage(branch=True, include='app/*')
COV.start()
#...
@manager.command
def test(coveage=False):
'''Run the unit tests.'''
if coverage and not os.environ.get('FLASK_COVERAGE'):
import sys
os.environ['FLASK_COVERAGE'] = '1'
os.execvp(sys.executable, [sys.executable] + sys.argv)
import unittest
tests = unittest.TestLoader().discover('tests')
unittest.TextTestRunner(verbosity=2).run(tests)
if COV:
COV.stop()
COV.save()
print('Coverage Summary:')
COV.report()
basedir = os.path.abspath(os.path.dirname(__file__))
covdir = os.path.join(basedir, 'tmp/coverage')
COV.html_report(directory=covdir)
print('HTML version: file://%s/index.html' % covdir)
#...
在Flask-Script中,自定義命令很簡單,若想爲test
命令添加一個布爾值選項,只需在test()
函數中添加一個布爾值參數即可,Flask-Script根據參數名確定選項名,並據此向函數中傳入True
或False
不過,把代碼覆蓋集成到manage.py
腳本中有個小問題,test()
函數收到 --coverage
選項的值後再啓動覆蓋測試已經晚了,那時全局作用域中的所有代碼都已經執行了,爲了檢測的準確性,設定完環境變量FLASK_COVERAGE
後,腳本會重啓,再次運行時,腳本頂端的代碼發現已經設定了環境變量,於是立即啓動覆蓋檢測
函數coverage.coverage()
用於啓動覆蓋測試引擎,branch=True
選項開啓分支覆蓋分析,除了跟蹤哪行代碼已經執行外,還要檢查每個條件語句的True
分支和False
分支是否都執行了,include
選項用來限制程序包中文件的分析範圍,只對這些文件中的代碼進行覆蓋檢測,如果不指定include選項,虛擬環境中安裝的全部擴展和測試代碼會包含進覆蓋報告中,給報告添加很多雜項
執行完所有測試後,test()
函數會在終端輸出報告,同時還會生成一個使用HTML編寫的精美報告並寫入硬盤,HTML格式的報告非常適合直觀形象地展示覆蓋信息,因爲它按照源碼的使用情況給代碼行加上了不同的顏色
# (env) PS C:\Users\Bangys\AppData\Local\GitHub\flasky> python manage.py test
test_app_exists (test_basics.BasicsTestCase) ... ok
test_app_is_testing (test_basics.BasicsTestCase) ... ok
test_home_page (test_client.FlaskClientTestCase) ... ok
test_register_and_login (test_client.FlaskClientTestCase) ... ok
test_anonymous_user (test_user_model.UserModelTestCase) ... ok
test_duplicate_email_change_token (test_user_model.UserModelTestCase) ... ok
test_expired_confirmation_token (test_user_model.UserModelTestCase) ... ok
test_follows (test_user_model.UserModelTestCase) ... ok
test_gravatar (test_user_model.UserModelTestCase) ... ok
test_invalid_confirmation_token (test_user_model.UserModelTestCase) ... ok
test_invalid_email_change_token (test_user_model.UserModelTestCase) ... ok
test_invalid_reset_token (test_user_model.UserModelTestCase) ... ok
test_no_password_getter (test_user_model.UserModelTestCase) ... ok
test_password_salts_are_random (test_user_model.UserModelTestCase) ... ok
test_password_setter (test_user_model.UserModelTestCase) ... ok
test_password_verification (test_user_model.UserModelTestCase) ... ok
test_ping (test_user_model.UserModelTestCase) ... ok
test_roles_and_permissions (test_user_model.UserModelTestCase) ... ok
test_timestamps (test_user_model.UserModelTestCase) ... ok
test_to_json (test_user_model.UserModelTestCase) ... ok
test_valid_confirmation_token (test_user_model.UserModelTestCase) ... ok
test_valid_email_change_token (test_user_model.UserModelTestCase) ... ok
test_valid_reset_token (test_user_model.UserModelTestCase) ... ok
----------------------------------------------------------------------
Ran 23 tests in 10.205s
OK
Coverage Summary:
Name Stmts Miss Branch BrPart Cover
-------------------------------------------------------------------------
app\__init__.py 33 0 0 0 100%
app\api_1_0\__init__.py 3 0 0 0 100%
app\api_1_0\authentication.py 30 19 10 0 28%
app\api_1_0\comments.py 40 30 8 0 21%
app\api_1_0\decorators.py 11 3 2 0 62%
app\api_1_0\errors.py 17 10 0 0 41%
app\api_1_0\posts.py 35 23 6 0 29%
app\api_1_0\users.py 30 24 8 0 16%
app\auth\__init__.py 3 0 0 0 100%
app\auth\forms.py 45 6 8 2 77%
app\auth\views.py 109 56 40 6 42%
app\decorators.py 14 3 2 0 69%
app\email.py 15 0 0 0 100%
app\exceptions.py 2 0 0 0 100%
app\main\__init__.py 6 0 0 0 100%
app\main\errors.py 20 15 6 0 19%
app\main\forms.py 39 7 4 0 74%
app\main\views.py 169 120 30 2 27%
app\models.py 243 59 40 5 73%
-------------------------------------------------------------------------
TOTAL 864 375 164 15 51%
HTML version: file://C:\Users\Bangys\AppData\Local\GitHub\flasky\tmp/coverage/index.html
上述報告顯示,整體覆蓋率爲51%,情況並不糟,但也不太好,現階段,模型類是單元測試的關注焦點,它包含243個語句,測試覆蓋了其中72%的語句,很明顯,main和auth藍本中的views.py
文件以及api_1_0
藍本中的路由的覆蓋率都很低,所以我們沒有爲這些代碼編寫單元測試
有了這個報告,我們就能很容易確定向測試組件中添加哪些測試以提高覆蓋率,但遺憾的是,並非程序的所有組成部分都能像數據庫模型那樣易於測試,所以我們要學習如何去測試視圖函數,表單和模板
注意,由於排版,實例報告中省略了“Missing”列的內容,這一列顯示測試沒有覆蓋的源碼行,是一個由行號範圍組成的長列表
Flask測試客戶端
程序的某些代碼嚴重依賴運行中的程序所創建的環境,例如不能直接調用視圖函數中的代碼進行測試,因爲這個函數可能需要訪問Flask上下文全局變量,如request
或session
,視圖函數還可能等待接受POST請求中的表單數據,而且某些視圖函數要求用戶先登錄,簡言之,視圖函數只能在請求上下文和運行的程序中運行
Flask內建了一個測試客戶端用於解決(部分解決)這一問題,測試客戶端能復現程序運行在Web服務器中的環境,讓測試扮演成客戶端從而發送請求
在測試客戶端中運行的視圖函數和正常情況下的沒有太大區別,服務器收到請求,將其分配給適當的視圖函數,視圖函數生成響應,將其返回給測試客戶端,執行視圖函數後,生成的響應會傳入測試,檢查是否正確
測試Web程序
下例是一個使用測試客戶端編寫的單元測試框架
#tests/test_client.py
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.create_all()
Role.insert_roles()
self.client = self.app.test_client(use_cookies=True)
def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()
def test_home_page(self):
response = self.client.get(url_for('main.index'))
self.assertTrue('Stranger' in response.get_data(as_text=True))
測試用例中的實例變量self.clinet
是Flask測試客戶端對象,在這個對象上可調用方法向程序發起請求,如果創建測試客戶端時啓用了use_cookies
選項,這個測試客戶端就能像瀏覽器一樣接受和發送cookie,因此能使用依賴cookie的功能記住請求之間的上下文,值得一提的是,這個選項可用來啓用用戶會話,讓用戶登錄和退出
test_home_page()
測試作爲一個簡單的例子演示了測試客戶端的作用,在這個例子中,客戶端向首頁發起了一個請求,在測試客戶端上調用get()
方法得到的結果是一個Response
對象,內容是用視圖函數得到的響應,爲了檢查測試是否成功,要在響應主體中搜索是否包含‘Stranger’這個詞,響應主體可使用response.get_data()
獲取,而‘Stranger’這個詞包含在向向名用戶顯示的歡迎消息是“Hello,Stranger”中,注意的是,默認情況下get_data()
得到的響應主體是一個字節數組,傳入參數as_text=True
後得到的是一個更易於處理的Unicode字符串
測試客戶端還能使用post()方法發送包含表單數據的POST請求,不過提交表單時會有一個小麻煩,Flask-WTF生成的表單中包含一個隱藏字段,其內容是CSRF令牌,需要和表單中的數據一起提交,爲了復現這個功能,測試必須請求包含表單的頁面,然後解析響應返回的HTML代碼並提取令牌,這樣才能把令牌和表單中的數據一起發送,爲了避免在測試中處理CSRF令牌這一繁瑣操作,最好在測試配置中禁用CSRF保護功能,實現方法如下:
# config.py
class TestingConfig(Config):
#...
WTF_CSRF_ENABLED = False
下面是一個更高級的單元測試,模擬了新用戶註冊賬戶、登錄、使用令牌確認賬戶以及退出的過程
# test/text_client.py
class FlaskClientTestCase(unittest.TestCase):
def text_register_and_login(self):
# new account
response = self.clinet.post(url_for('auth.register'), data={
'email':'[email protected]',
'username':'john',
'password':'cat',
'passwprd2':'cat'
})
self.assertTrue(response.status_code == 302)
# use new account login
response = self.clinet.post(url_for('auth.login'), data={
'email':'[email protected]',
'password':'cat'
}m follow_redirects=True)
data = response.get_data(as_text=True)
self.assertTrue(re.search('Hello, \s+john', data))
self.assertTrue('You have not confirmed your account yet' in data)
# send confirm token
user = User.query.filter_by(email = '[email protected]').first()
token = user.generate_confirmation_token()
response = self.client.get(url_for('auth.comfirm', token=token),
follow_redirects=True)
data = response.get_data(as_text=True)
self.assertTrue('You have comfirmed your account' in data)
#quit
response = self.client.get(url_for('auth.logout'),
follow_redirects=True)
data = response.get_data(as_text=True)
self.assertTrue('You have been logged out' in data)
這個測試先向註冊路由提交一個表單,post()
方法的data
參數是個字典,包含表單中的各個字段,各字段的名字必須嚴格匹配定義表單時使用的名字,由於CSRF保護已經在測試配置中禁用了,因此無需和表單數據一起發送
/auth/register
路由有兩種響應方式,如果註冊數據可用,會返回一個重定向,把用戶轉到登錄頁面,註冊不可用的情況下,返回的響應會再次渲染註冊表單,而且還包含適當的錯誤信息,爲了確認註冊成功,測試會檢查響應的狀態碼是否爲302,這個代碼表示重定向
這個測試的第二部分使用剛纔註冊時使用的電子郵件和密碼登錄程序,這一工作通過向/auth/login
路由發起POST請求完成,這一次,調用post()
方法時指定了參數follow_redirects=True
,讓測試客戶端和瀏覽器一樣,自動向重定向的URL發起GET請求,指定這個參數後,返回的不是302狀態碼,而是請求重定向的URL返回的響應
成功登錄後的響應應該是一個頁面,顯示一個包含用戶名的歡迎消息,並提醒用戶需要進行賬戶確認才能獲得權限,爲此,兩個斷言語句被用於檢查響應是否爲這個頁面,值得注意的是,直接搜索字符串“Hello,john!”並沒有用,因爲這個字符串由動態部分和靜態部分組成,而且兩部分之間有額外的空白,爲了避免測試時空白引起的問題,我們使用更爲靈活的正則表達式
下一步我們要確認賬戶,這裏也有一個小障礙,在註冊過程中,通過電子郵件將確認URL發給用戶,而在測試中處理電子郵件不是一件簡單的事情,上面這個測試使用的解決方法是忽略了註冊時生成的令牌,直接在User
實例上調用方法重新生成一個新令牌,在測試環境中,Flask-Mail會保存郵件正文,所以還有一種可行的解決方法,即通過解析郵件正文來提取令牌
得到令牌後,測試的第三部分模擬用戶點擊確認令牌URL,這一過程通過向確認URL發起GET請求並附上確認令牌來完成,這個請求的響應是重定向,轉到首頁,但這裏再次指定了參數follow_redirects=True
,所以測試客戶端會自動向重定向的頁面發起請求,此外,還要檢查響應中是否包含歡迎消息和一個向用戶說明確認成功的Flash消息
這個測試的最後一步是向退出路由發送GET請求,爲了證實確認退出,這段測試在響應中搜索一個Flash消息
測試Web服務
Flask客戶端還可用來測試REST Web服務,下例包含了兩個測試:
def get_api_headers(self, username, password):
return {
'Authorization':
'Basic ' + b64encode(
(username + ':' + password).encode('utf-8')).decode('utf-8'),
'Accept':'application/json',
'Content-Type':'application/json'
}
def test_no_auth(self):
response = self.client.get(url_for('api.get_posts'),
content_Type='application/json')
self.assertTrue(response.status_code == 401)
def test_posts(self):
# add a user
r = Role.query.filter_by(name="User").first()
self.assertIsNotNone(r)
u = User(email='[email protected]', password='cat', confirmed=True, role=r)
db.session.add(u)
db.session.commit()
#write a post
response = self.clinet.post(
url_for('api.new_post'),
header=self.get_auth_header('[email protected]', 'cat'),
data=json.dumps({'body': 'body of the *blog* post'}))
self.assertTrue(response.status_code == 201)
url = response.headers.get('Location')
self.assertIsNotNone(url)
#receive post
response = self.client.get(
url,
headers=self.get_auth_header('[email protected]', 'cat'))
self.assertTrue(response_status_code == 200)
json_response = json.loads(response.data.decode('utf-8'))
self.assertTrue(json_response['url'] == url)
self.assertTrue(json_response['body'] == 'body of the *blog* post')
self.assertTrue(json_response['body_html'] ==
'<p>body of the <em>blog</em> post</p>')
測試API時使用的setUp()
和tearDown()
方法和測試普通程序所用的一樣,不過API不使用cookie,所以無需配置相應支持,get_api_headers
是一個輔助方法,返回所有請求都要發送的通用首部,其中包含認證密令和MIME類型相關的首部,大多數測試都要發送這些首部
test_no_auth()
是一個簡單的測試,確保Web服務會拒絕沒有提供認證密令的請求,返回401錯誤碼,test_posts()
測試把一個用戶插入數據庫,然後使用基於REST的API創建一篇博客文章,然後再讀取這篇文章,所有請求主體中發送的數據都要使用json.dumps()
方法進行編碼,因爲Flask測試客戶端不會自動編碼JSON格式數據,類似的,返回的響應主體也是JSON格式,處理之前必須使用json.loads()
進行編碼