National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 80 24°C PM2.5 21
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 18

專案結構與自動測試

應用程式工廠、藍圖、設定檔與 pytest
Interaction實作 35 minute(s)

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')

只有全大寫的名稱會被載入。下面的練習把這些檔案都放在同一層,操作起來比較方便。

互動練習:一個拆成多個檔案的網站

上方可以切換 app.py、config.py、db.py、notes.py、api.py 與模板。先讀一遍:app.py 只負責組裝,路由都在藍圖裡。

任務三要在 app.py 加上:

app.register_blueprint(api_bp)

任務五要在 notes.py 加一條路由。藍圖已經有 /notes 前綴,所以路由只要寫 /count:

@bp.route('/count')
def count():
    n = get_db().execute('SELECT COUNT(*) FROM notes').fetchone()[0]
    return f'共 {n} 則筆記'

為什麼要寫自動測試

上一課修好的漏洞,半年後改別的功能時可能又被弄壞。手動逐頁點一遍,功能一多就做不完,也一定會漏。

自動測試是一段程式:送出請求、檢查回應。寫好之後,改任何東西都可以幾秒鐘內全部重跑一次。

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 函數。

動手做:寫出抓得到錯誤的測試

補上兩個測試函數。程式會把測試跑兩次:

  1. 對 notes_app(正確的版本):三個測試都要通過
  2. 對 notes_app_buggy(少了擁有者檢查與標題檢查):你寫的兩個測試都要失敗

一個永遠通過的測試沒有用,所以這題兩邊都會比對。client 由 conftest.py 準備好,三個檔案都在輸出區的分頁可以看。

你的輸出
預期輸出

想一想

1.

為什麼要把 app = Flask(__name__) 包進 create_app 函數?

2.

下面的程式會印出什麼?

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)
3.

藍圖 notes_bp = Blueprint('notes', ...) 裡有一個 def detail(note_id)。要產生它的網址,url_for 要怎麼寫?

4.

pytest -q 的第一行是 .F.,代表什麼?

5.

每個測試都用 tmp_path 建立新的資料庫,最主要的好處是?

第 1 次作答

重點整理

  • create_app(test_config) 把建立 app 包成函數,可以建立多個設定不同的 app
  • Blueprint 把路由拆到不同檔案,register_blueprint 之後才生效;端點名稱是「藍圖名稱.函數名稱」
  • view 函數裡用 current_app 取得正在處理請求的 app;設定放 app.config
  • pytest 自動找 test_*.py 與 test_ 函數,用 assert 檢查,輸出 . 代表通過、F 代表失敗
  • fixture 準備 app、client;TESTING 讓例外丟出來;tmp_path 讓每個測試有新的資料庫
  • 正常與錯誤的路徑都要測,尤其是權限;測試要在程式有錯時真的會失敗

程式結構與測試都就緒了,下一課把網站部署到真正的伺服器上。

這一課完成了!
Next lesson:部署上線
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。