Learning Objectives
- 用應用程式工廠 create_app 建立設定不同的 app
- 用 Blueprint 把路由拆到不同檔案,並說明 current_app 的用途
- 看懂 pytest 的輸出,寫出使用 fixture 的測試
- 用暫存資料庫讓每個測試互不影響,並同時測正常與錯誤的路徑
單一檔案放不下之後
到目前為止的網站都寫在一個 app.py,頂層一行 app = Flask(__name__)。功能變多之後會碰到三個問題:
- 檔案太長:登入、筆記、API 都在一起,要改一個地方得找很久。
- 循環匯入:路由拆到其他檔案時要
from app import app,而app.py又要匯入那些檔案。 - 很難測試:測試想用另一個資料庫,但
app在匯入的當下就建好了,設定已經固定。
Flask 的解法是兩個機制:應用程式工廠(application factory)把建立 app 的動作包成函數;Blueprint(藍圖)把路由先記下來,等 app 建好再註冊上去。
逐步看:工廠建立兩個不同的 app
同一個 create_app 被呼叫兩次,建立兩個設定不同的 app。注意 notes_bp 上的路由在 app 存在之前就寫好了。
資料夾怎麼放
Flask 官方教學的結構是把程式包成一個套件。小型專案可以這樣放:
noteapp/
├── noteapp/
│ ├── __init__.py # create_app 放這裡
│ ├── db.py # get_db、close_db
│ ├── notes.py # notes 藍圖
│ ├── api.py # api 藍圖
│ ├── schema.sql
│ ├── templates/
│ └── static/
├── tests/
│ ├── conftest.py # 共用的 fixture
│ └── test_notes.py
├── config.py
└── .venv/
執行時不用改指令:flask --app noteapp run --debug。Flask 會自動尋找 create_app 或 make_app。
設定集中放在 app.config(行為就像字典)。常見做法是一個類別放預設值,再用 from_object 載入:
class DevConfig:
SITE_NAME = '筆記小站'
DATABASE = 'site.db'
app.config.from_object('config.DevConfig')
只有全大寫的名稱會被載入。下面的練習把這些檔案都放在同一層,操作起來比較方便。
為什麼要寫自動測試
上一課修好的漏洞,半年後改別的功能時可能又被弄壞。手動逐頁點一遍,功能一多就做不完,也一定會漏。
自動測試是一段程式:送出請求、檢查回應。寫好之後,改任何東西都可以幾秒鐘內全部重跑一次。
pytest 是 Python 常用的測試工具(pip install pytest)。規則很簡單:
- 檔名以
test_開頭,函數也以test_開頭,pytest 會自己找到 - 用 Python 內建的
assert檢查;條件不成立,這個測試就算失敗 - 在專案根目錄執行
pytest
瀏覽器裡沒有終端機,所以下面的範例用
pytest.main([...])啟動,效果等於在終端機打pytest。-p no:cacheprovider與--no-header是這裡為了讓輸出簡潔才加的,平常不需要。這裡顯示的耗時固定是 0.00s;真實環境會顯示實際秒數。
看懂 pytest 的輸出
測試檔 test_app.py 在輸出區的分頁,有三個測試,最後一個故意寫錯。可以直接按到最後一步看輸出:
- 第一行
..F:一個字元代表一個測試,.通過,F失敗 E開頭的行:實際得到'3',測試期待'4'- 最後一行:幾個失敗、幾個通過
逐步播放時,下方的呼叫堆疊會標示目前在 test_app.py 的哪個函數;程式碼區只顯示主程式,所以這時候要看呼叫堆疊。@py_assert 開頭的變數是 pytest 為了顯示失敗原因自己加的,可以不用管。
fixture:測試需要的東西由 pytest 準備
剛才的 test_add(client) 參數名稱叫 client,pytest 就去找同名的 fixture,執行它,再把結果傳進來。Flask 文件建議的基本組合:
@pytest.fixture
def app(tmp_path):
return create_app({'TESTING': True, 'DATABASE': str(tmp_path / 'test.db')})
@pytest.fixture
def client(app): # fixture 也可以用其他 fixture
return app.test_client()
TESTING:例外直接丟出來,而不是變成 500 頁面,出錯時才看得到原因。tmp_path:pytest 內建,每個測試各給一個暫存資料夾。所以每個測試都有全新的資料庫,順序不影響結果。這也是工廠帶來的好處:沒有工廠,就沒有地方換資料庫。conftest.py:共用的 fixture 放這裡,同資料夾的測試檔不用匯入就能使用。
正常與錯誤的路徑都要測。 錯誤路徑通常更重要,因為它們平常不會有人走,壞了也不會被發現:
| 功能 | 正常路徑 | 錯誤路徑 |
|---|---|---|
| 新增筆記 | 登入後新增,回 201 | 沒登入回 401;標題空白回 400 |
| 讀取筆記 | 讀自己的,回 200 | 讀別人的回 404;不存在回 404 |
測你自己寫的程式,不必測 Flask 本身。修 bug 時,先寫一個會失敗的測試再修,那個 bug 就不會回來。
一個使用暫存資料庫的測試檔
notes_app.py(被測的程式)與 test_notes.py(測試)都在輸出區的分頁。這次用 -v,每個測試印一行。
注意 test_database_starts_empty:前一個測試明明新增了編號 1 的筆記,這裡卻查不到,因為每個測試的資料庫都是新的。逐步播放時,看下方的呼叫堆疊怎麼從測試函數進到 add_note 這些 view 函數。
動手做:寫出抓得到錯誤的測試
補上兩個測試函數。程式會把測試跑兩次:
- 對
notes_app(正確的版本):三個測試都要通過 - 對
notes_app_buggy(少了擁有者檢查與標題檢查):你寫的兩個測試都要失敗
一個永遠通過的測試沒有用,所以這題兩邊都會比對。client 由 conftest.py 準備好,三個檔案都在輸出區的分頁可以看。
想一想
為什麼要把 app = Flask(__name__) 包進 create_app 函數?
下面的程式會印出什麼?
from flask import Flask, Blueprint
bp = Blueprint('shop', __name__, url_prefix='/shop')
@bp.route('/items')
def items():
return 'ok'
app1 = Flask(__name__)
app2 = Flask(__name__)
app2.register_blueprint(bp)
print(app1.test_client().get('/shop/items').status_code, app2.test_client().get('/shop/items').status_code)
藍圖 notes_bp = Blueprint('notes', ...) 裡有一個 def detail(note_id)。要產生它的網址,url_for 要怎麼寫?
pytest -q 的第一行是 .F.,代表什麼?
每個測試都用 tmp_path 建立新的資料庫,最主要的好處是?
重點整理
create_app(test_config)把建立 app 包成函數,可以建立多個設定不同的 appBlueprint把路由拆到不同檔案,register_blueprint之後才生效;端點名稱是「藍圖名稱.函數名稱」- view 函數裡用
current_app取得正在處理請求的 app;設定放app.config - pytest 自動找
test_*.py與test_函數,用assert檢查,輸出.代表通過、F代表失敗 - fixture 準備
app、client;TESTING讓例外丟出來;tmp_path讓每個測試有新的資料庫 - 正常與錯誤的路徑都要測,尤其是權限;測試要在程式有錯時真的會失敗
程式結構與測試都就緒了,下一課把網站部署到真正的伺服器上。