文章轉自 :https://github.com/WapeYang/The-Flask-Mega-Tutorial/blob/master/testing.rst
感謝原作者的付出
轉載時間爲:2014-05-06
單元測試
回顧
在上一章中我們集中在一步一步爲我們的應用程序的添加功能。到目前爲止,我們有一個數據庫功能的應用程序,它能夠註冊用戶,允許用戶登錄以及登出,查看以及編輯他們的用戶信息。
在本章中,我們不打算添加新的特性。相反,我們將要尋找方式來保證我們編寫的代碼的健壯性,我們也創建了一個測試框架用來幫助我們避免將來的失敗和迴歸測試。
發現 bug
我記得在上一章結尾的時候,我特意提出了應用程序存在 bug。讓我來描述下 bug 是什麼,接着看看當不按預期工作的時候(bug 出現的時候),我們的應用程序會發生什麼。
應用程序中的 bug 就是沒有有效的讓我們用戶的暱稱保持唯一性。應用程序自動選擇用戶的初始暱稱。如果 OpenID 提供商提供一個用戶的暱稱的話我們會使用這個暱稱。如果沒有提供話,應用程序會選擇郵箱的用戶名部分作爲暱稱。如果兩個用戶有着同樣的暱稱的話,第二個用戶是不能夠被註冊的。更加糟糕的是,在編輯用戶信息的時候,我們允許用戶修改暱稱,但是沒有去限制暱稱名稱衝突。
我們將會在分析 bug 發生時候應用程序的的行爲後修正這些問題。
Flask 調試
讓我們來看看當我們觸發一個 bug 的時候會發生些什麼。
先讓我們創建一個新的數據庫。在 Linux:
rm app.db ./db_create.py
或者在 Windows 上:
del app.db flask/Scripts/python db_create.py
爲了重現這個 bug,你需要兩個 OpenID 賬號,理想地是不同的提供商,從而使得它們的 cookies 不會太複雜。遵照這些步驟創建一個重複的暱稱:
- 登錄你的第一個賬號
- 進入到編輯用戶信息頁並且把暱稱改爲'dup'
- 登出
- 登錄第二個賬號
- 進入到編輯用戶信息頁並且把暱稱改爲'dup'
糟糕!我們已經得到了來自 SQLAlchemy 的一個異常。錯誤的信息寫着:
sqlalchemy.exc.IntegrityError IntegrityError: (IntegrityError) column nickname is not unique u'UPDATE user SET nickname=?, about_me=? WHERE user.id = ?' (u'dup', u'', 2)
緊跟着錯誤後面的是錯誤的 堆棧跟蹤,這是一個相當不錯的東西,在這裏你可以去任何一幀並且檢查源代碼或者甚至在瀏覽器正確地上計算表達式。
錯誤是相當地明顯的,我們試着在數據庫中插入重複的暱稱。數據庫模型對 nickname 字段有着 unique 限制,因此這不是一個合法的操作。
除了實際的錯誤,我們面前還有另外一個問題。如果一個用戶不幸在我們的應用程序中遇到一個錯誤(這個或者其它的引起的異常)他或者她將會得到錯誤消息和堆棧跟蹤,然而他們只是用戶不是開發者。儘管這其實是一個很夢幻般的功能當我們開發的時候,但是這也是我們絕對不希望我們的用戶能夠看到的東西。
這段時間內我們的應用程序以調試模式運行着。調試模式是在應用程序運行的時候通過在 run 方法中傳入參數 debug = True。
當我們在開發的應用程序的時候這個功能很方便,但是我們必須在生產環境上確保這個功能被禁用。讓我們創建另外一個調試模式禁用的啓動腳本(文件 runp.py):
#!flask/bin/python from app import app app.run(debug = False)
現在重啓應用程序:
./runp.py
接着重新嘗試修改第二個賬號的暱稱爲 ‘dup’。
這個時候我們不會得到一個前面出現的錯誤。相反,我們會得到一個 HTTP 錯誤 500,這是服務器內部錯誤。儘管還是返回一個錯誤,但至少不暴露我們的應用程序的任何細節給陌生人。當調試關閉,500 錯誤頁是由 Flask 產生的並且發生了未處理的異常。
雖然情況有些好轉,我們現在有兩個新的問題。第一個是外觀上的:默認的 500 錯誤頁很醜陋。第二個小問題相當重要。我們可能不會知道什麼時候用戶會在我們的程序中會遇到一個失敗因爲現在調試被禁用。幸好有兩種簡單的方式解決這兩個問題。
定製 HTTP 錯誤處理器
Flask 爲應用程序提供了一種安裝自己的錯誤頁的機制。作爲例子,讓我們自定義 HTTP 404 以及 500 錯誤頁,這是最常見的兩個。定義其它錯誤的方式是一樣的。
爲了聲明一個定製的錯誤處理器,需要使用裝飾器 errorhandler (文件 app/views.py):
@app.errorhandler(404) def internal_error(error): return render_template('404.html'), 404 @app.errorhandler(500) def internal_error(error): db.session.rollback() return render_template('500.html'), 500
上面的不需要多做解釋,代碼很清楚,唯一值得感興趣就是在錯誤 500 處理器中的 rollback 聲明。這是很有必要的因爲這個函數是被作爲異常的結果被調用。如果異常是被一個數據庫錯誤觸發,數據庫的會話會處於一個不正常的狀態,因此我們必須把會話回滾到正常工作狀態在渲染 500 錯誤頁模板之前。
這是 404 錯誤的模板:
<!-- extend base layout --> {% extends "base.html" %} {% block content %} <h1>File Not Found</h1> <p><a href="{{url_for('index')}}">Back</a></p> {% endblock %}
這是 500 錯誤的一個模板:
<!-- extend base layout --> {% extends "base.html" %} {% block content %} <h1>An unexpected error has occurred</h1> <p>The administrator has been notified. Sorry for the inconvenience!</p> <p><a href="{{url_for('index')}}">Back</a></p> {% endblock %}
注意的是在上面兩個模板中我們繼續使用我們 base.html 佈局,這是爲了讓錯誤頁面和應用程序的外觀是統一的。
通過電子郵件發送錯誤
爲了解決我們第二個問題,我們將會配置兩種應用程序錯誤報告機制。第一個就是當錯誤發生的時候發送電子郵件。
在開始之前我們先在應用程序中配置郵件服務器以及管理員郵箱地址(文件 config.py):
# mail server settings MAIL_SERVER = 'localhost' MAIL_PORT = 25 MAIL_USERNAME = None MAIL_PASSWORD = None # administrator list ADMINS = ['[email protected]']
Flask 使用 Python logging 模塊,因此當發生異常的時候發送郵件是十分簡單(文件 app/__init__.py):
from config import basedir, ADMINS, MAIL_SERVER, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD if not app.debug: import logging from logging.handlers import SMTPHandler credentials = None if MAIL_USERNAME or MAIL_PASSWORD: credentials = (MAIL_USERNAME, MAIL_PASSWORD) mail_handler = SMTPHandler((MAIL_SERVER, MAIL_PORT), 'no-reply@' + MAIL_SERVER, ADMINS, 'microblog failure', credentials) mail_handler.setLevel(logging.ERROR) app.logger.addHandler(mail_handler)
在一個沒有郵件服務器的開發機器上測試上述代碼是相當容易的,多虧了 Python 的 SMTP 調試服務器。僅需要打開一個新的命令行窗口(Windows 用戶打開命令提示符)接着運行如下內容打開一個僞造的郵箱服務器:
python -m smtpd -n -c DebuggingServer localhost:25
當郵箱服務器運行後,應用程序發送的郵件將會被接收到並且顯示在命令行窗口上。
記錄到文件
通過郵件接收錯誤是不錯的,但是有時候這並不夠。有些失敗並不是結束於異常而且也不是主要問題,然而我們可能想要在日誌中追蹤它們以便做一些調試。
出於這個原因,我們還要爲應用程序保持一個日誌文件。
啓用日誌記錄類似於電子郵件發送錯誤(文件 app/__init__.py):
if not app.debug: import logging from logging.handlers import RotatingFileHandler file_handler = RotatingFileHandler('tmp/microblog.log', 'a', 1 * 1024 * 1024, 10) file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]')) app.logger.setLevel(logging.INFO) file_handler.setLevel(logging.INFO) app.logger.addHandler(file_handler) app.logger.info('microblog startup')
日誌文件將會在 tmp 目錄,名稱爲 microblog.log。我們使用了 RotatingFileHandler 以至於生成的日誌的大小是有限制的。在這個例子中,我們的日誌文件的大小限制在 1 兆,我們將保留最後 10 個日誌文件作爲備份。
logging.Formatter 類能夠定製化日誌信息的格式。由於這些信息記錄到一個文件中,我們希望它們提供儘可能多的信息,所以我們寫一個時間戳,日誌記錄級別和消息起源於以及日誌消息和堆棧跟蹤的文件和行號。
爲了使得日誌更有作用,我們降低了應用程序日誌以及文件日誌處理器的級別,這樣給我們機會寫入有用的信息到日誌並不是必須錯誤發生的時候。從這以後,每次你以非調試模式啓動有用程序,日誌將會記錄事件。
雖然我們不會在這個時候有很多記錄器的需求,調試的一個處於聯機狀態並在使用中的網頁服務器是非常困難的。消息記錄到一個文件,是一個非常有用的工具,在診斷和定位問題,所以我們現在都準備好,我們需要使用此功能。
修復 bug
讓我們解決 nickname 重複的問題。
像之前討論的,目前存在兩個地方沒有處理重複。第一個就是在 after_login 函數。當一個用戶成功地登錄進系統這個函數就會被調用,這裏我們需要創建一個新的 User 實例。這裏就是受影響的代碼塊(文件 app/views.py):
if user is None: nickname = resp.nickname if nickname is None or nickname == "": nickname = resp.email.split('@')[0] nickname = User.make_unique_nickname(nickname) user = User(nickname = nickname, email = resp.email, role = ROLE_USER) db.session.add(user) db.session.commit()
解決問題的方式就是讓 User 類爲我們選擇一個唯一的名字。這就是新的 make_unique_nickname 方法所做的(文件app/models.py):
class User(db.Model): # ... @staticmethod def make_unique_nickname(nickname): if User.query.filter_by(nickname = nickname).first() == None: return nickname version = 2 while True: new_nickname = nickname + str(version) if User.query.filter_by(nickname = new_nickname).first() == None: break version += 1 return new_nickname # ...
這種方法簡單地增加一個計數器爲請求的暱稱,直到找到一個唯一的名稱。例如,如果用戶名 “miguel”已經存在,這個方法將會建議使用 “miguel2”,如果這個還是存在,將會建議使用 "miguel3",依次下去直至找到唯一的用戶名。需要注意的是我們把這個方法作爲一個靜態方法,因爲這種操作並不適用於任何特定的類的實例。
第二個存在重複暱稱問題的地方就是編輯用戶信息的視圖函數。這個稍微有些難處理,因爲這是用戶自己選擇的暱稱。正確的做法就是不接受一個重複的暱稱,讓用戶重新輸入一個。我們將通過添加一個暱稱表單字段定製化的驗證來解決這個問題。如果用戶輸入一個不合法的暱稱,字段的驗證將會失敗,用戶將會返回到編輯用戶信息頁。爲了添加驗證,我們只需覆蓋表單的 validate 方法(文件 app/forms.py):
from app.models import User class EditForm(Form): nickname = TextField('nickname', validators = [Required()]) about_me = TextAreaField('about_me', validators = [Length(min = 0, max = 140)]) def __init__(self, original_nickname, *args, **kwargs): Form.__init__(self, *args, **kwargs) self.original_nickname = original_nickname def validate(self): if not Form.validate(self): return False if self.nickname.data == self.original_nickname: return True user = User.query.filter_by(nickname = self.nickname.data).first() if user != None: self.nickname.errors.append('This nickname is already in use. Please choose another one.') return False return True
表單的初始化新增了一個參數 original_nickname。validate 方法使用它來決定暱稱什麼時候更改過。如果沒有發生更改就接受它。如果已經發生更改的話,確保暱稱在數據庫是唯一的。
在視圖函數中傳入這個參數:
@app.route('/edit', methods = ['GET', 'POST']) @login_required def edit(): form = EditForm(g.user.nickname) # ...
爲了完成這個修改,我們必須在表單模板中使得字段錯誤信息會顯示(文件 app/templates/edit.html):
<td>Your nickname:</td> <td> {{form.nickname(size = 24)}} {% for error in form.errors.nickname %} <br><span style="color: red;">[{{error}}]</span> {% endfor %} </td>
現在問題是修復了,重複將會被禁止。。。除非是都沒有。我們仍然存在潛在的問題,當兩個或者更多的線程或者處理同時訪問數據庫的時候,但是這將會是以後的話題。
單元測試框架
在結束本章的話題之前,讓我們來討論一點自動化測試。
隨着應用程序的規模變得越大就越難保證代碼的修改不會影響到現有的功能。
傳統的方式--迴歸測試是一個很好的主意。你編寫測試檢驗應用程序所有不同的功能。每一個測試集中在一個關注點上驗證結果是不是期望的。定期執行測試確保應用程序按預期的工作。當測試覆蓋很大的時候,通過運行測試你就有自信確保修改點和新增點不會影響應用程序。
我們使用 Python 的 unittest 模塊將會構建一個簡單的測試框架(文件 tests.py):
#!flask/bin/python import os import unittest from config import basedir from app import app, db from app.models import User class TestCase(unittest.TestCase): def setUp(self): app.config['TESTING'] = True app.config['CSRF_ENABLED'] = False app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'test.db') self.app = app.test_client() db.create_all() def tearDown(self): db.session.remove() db.drop_all() def test_avatar(self): u = User(nickname = 'john', email = '[email protected]') avatar = u.avatar(128) expected = 'http://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6' assert avatar[0:len(expected)] == expected def test_make_unique_nickname(self): u = User(nickname = 'john', email = '[email protected]') db.session.add(u) db.session.commit() nickname = User.make_unique_nickname('john') assert nickname != 'john' u = User(nickname = nickname, email = '[email protected]') db.session.add(u) db.session.commit() nickname2 = User.make_unique_nickname('john') assert nickname2 != 'john' assert nickname2 != nickname if __name__ == '__main__': unittest.main()
討論 unittest 模塊是在本文的範圍之外的。TestCase 類中含有我們的測試。setUp 和 tearDown 方法是特別的,它們分別在測試之前以及測試之後運行。
在上面代碼中 setUp 和 tearDown 方法十分普通。在 setUp 中做了一些配置,在 tearDown 中重置數據庫內容。
測試實現成了方法。一個測試支持運行應用程序的多個函數,並且有已知的結果以及應該斷言結果是否不同於預期的。
目前爲止在測試框架中有兩個測試。第一個就是驗證 Gravatar 的頭像 URL生成是否正確。注意測試中期待的結果是硬編碼,驗證User 類的返回的頭像 URL。
第二個就是我們前面編寫的 make_unique_nickname 方法,同樣是在 User 類中。
結束語
如果你想要節省時間的話,你可以下載 microblog-0.7.zip。
我希望能在下一章繼續見到各位!