《Flask 入門教程》第 9 章:測試

在此之前,每次為程序添加了新功能,我們都要手動在瀏覽器里訪問程序進行測試。除了測試新添加的功能,你還要確保舊的功能依然正常工作。在功能複雜的大型程序里,如果每次修改代碼或添加新功能後手動測試所有功能,那會產生很大的工作量。另一方面,手動測試並不可靠,重複進行測試操作也很枯燥。

基於這些原因,為程序編寫自動化測試就變得非常重要。

注意 為了便於介紹,本書統一在這裡介紹關於測試的內容。在實際的項目開發中,你應該在開發每一個功能後立刻編寫相應的測試,確保測試通過後再開發下一個功能。

單元測試

單元測試指對程序中的函數等獨立單元編寫的測試,它是自動化測試最主要的形式。這一章我們將會使用 Python 標準庫中的測試框架 unittest 來編寫單元測試,首先通過一個簡單的例子來了解一些基本概念。假設我們編寫了下面這個函數:

def sayhello(to=None):
if to:
return Hello, %s! % to
return Hello!

下面是我們為這個函數編寫的單元測試:

import unittest

from module_foo import sayhello

class SayHelloTestCase(unittest.TestCase): # 測試用例

def setUp(self): # 測試固件
pass

def tearDown(self): # 測試固件
pass

def test_sayhello(self): # 第 1 個測試
rv = sayhello()
self.assertEqual(rv, Hello!)

def test_sayhello_to_somebody(self) # 第 2 個測試
rv = sayhello(to=Grey)
self.assertEqual(rv, Hello, Grey!)

if __name__ == __main__:
unittest.main()

測試用例繼承 unittest.TestCase 類,在這個類中創建的以 test_ 開頭的方法將會被視為測試方法。

內容為空的兩個方法很特殊,它們是測試固件,用來執行一些特殊操作。比如 setUp() 方法會在每個測試方法執行前被調用,而 tearDown() 方法則會在每一個測試方法執行後被調用(注意這兩個方法名稱的大小寫)。

如果把執行測試方法比作戰鬥,那麼準備彈藥、規劃戰術的工作就要在 setUp() 方法里完成,而打掃戰場則要在 tearDown()方法里完成。

每一個測試方法(名稱以 test_ 開頭的方法)對應一個要測試的函數 / 功能 / 使用場景。在上面我們創建了兩個測試方法,test_sayhello() 方法測試 sayhello() 函數,test_sayhello_to_somebody() 方法測試傳入參數時的 sayhello() 函數。

在測試方法里,我們使用斷言方法來判斷程序功能是否正常。以第一個測試方法為例,我們先把 sayhello() 函數調用的返回值保存為 rv 變數(return value),然後使用 self.assertEqual(rv, Hello!) 來判斷返回值內容是否符合預期。如果斷言方法出錯,就表示該測試方法未通過。

下面是一些常用的斷言方法:

  • assertEqual(a, b)
  • assertNotEqual(a, b)
  • assertTrue(x)
  • assertFalse(x)
  • assertIs(a, b)
  • assertIsNot(a, b)
  • assertIsNone(x)
  • assertIsNotNone(x)
  • assertIn(a, b)
  • assertNotIn(a, b)

這些方法的作用從方法名稱上基本可以得知。

假設我們把上面的測試代碼保存到 test_sayhello.py 文件中,通過執行 python test_sayhello.py 命令即可執行所有測試,並輸出測試的結果、通過情況、總耗時等信息。

測試 Flask 程序

回到我們的程序,我們在項目根目錄創建一個 test_watchlist.py 腳本來存儲測試代碼,我們先編寫測試固件和兩個簡單的基礎測試:

test_watchlist.py:測試固件

import unittest

from app import app, db, Movie, User

class WatchlistTestCase(unittest.TestCase):

def setUp(self):
# 更新配置
app.config.update(
TESTING=True,
SQLALCHEMY_DATABASE_URI=sqlite:///:memory:
)
# 創建資料庫和表
db.create_all()
# 創建測試數據,一個用戶,一個電影條目
user = User(name=Test, username=test)
user.set_password(123)
movie = Movie(title=Test Movie Title, year=2019)
# 使用 add_all() 方法一次添加多個模型類實例,傳入列表
db.session.add_all([user, movie])
db.session.commit()

self.client = app.test_client() # 創建測試客戶端
self.runner = app.test_cli_runner() # 創建測試命令運行器

def tearDown(self):
db.session.remove() # 清除資料庫會話
db.drop_all() # 刪除資料庫表

# 測試程序實例是否存在
def test_app_exist(self):
self.assertIsNotNone(app)

# 測試程序是否處於測試模式
def test_app_is_testing(self):
self.assertTrue(app.config[TESTING])

某些配置,在開發和測試時通常需要使用不同的值。在 setUp() 方法中,我們更新了兩個配置變數的值,首先將 TESTING 設為 True 來開啟測試模式,這樣在出錯時不會輸出多餘信息;然後將 SQLALCHEMY_DATABASE_URI 設為 sqlite:///:memory:,這會使用 SQLite 內存型資料庫,不會干擾開發時使用的資料庫文件。你也可以使用不同文件名的 SQLite 資料庫文件,但內存型資料庫速度更快。

接著,我們調用 db.create_all() 創建資料庫和表,然後添加測試數據到資料庫中。在 setUp() 方法最後創建的兩個類屬性分別為測試客戶端和測試命令運行器,前者用來模擬客戶端請求,後者用來觸發自定義命令,下一節會詳細介紹。

tearDown() 方法中,我們調用 db.session.remove() 清除資料庫會話並調用 db.drop_all() 刪除資料庫表。測試時的程序狀態和真實的程序運行狀態不同,所以需要調用 db.session.remove() 來確保資料庫會話被清除。

測試客戶端

app.test_client() 返回一個測試客戶端對象,可以用來模擬客戶端(瀏覽器),我們創建類屬性 self.client 來保存它。對它調用 get() 方法就相當於瀏覽器向伺服器發送 GET 請求,調用 post() 則相當於瀏覽器向伺服器發送 POST 請求,以此類推。下面是兩個發送 GET 請求的測試方法,分別測試 404 頁面和主頁:

test_watchlist.py:測試固件

class WatchlistTestCase(unittest.TestCase):
# ...
# 測試 404 頁面
def test_404_page(self):
response = self.client.get(/nothing) # 傳入目標 URL
data = response.get_data(as_text=True)
self.assertIn(Page Not Found - 404, data)
self.assertIn(Go Back, data)
self.assertEqual(response.status_code, 404) # 判斷響應狀態碼

# 測試主頁
def test_index_page(self):
response = self.client.get(/)
data = response.get_data(as_text=True)
self.assertIn(Tests Watchlist, data)
self.assertIn(Test Movie Title, data)
self.assertEqual(response.status_code, 200)

調用這類方法返回包含響應數據的響應對象,對這個響應對象調用 get_data() 方法並把 as_text 參數設為 True 可以獲取 Unicode 格式的響應主體。我們通過判斷響應主體中是否包含預期的內容來測試程序是否正常工作,比如 404 頁面響應是否包含 Go Back,主頁響應是否包含標題 Tests Watchlist。

接下來,我們要測試資料庫操作相關的功能,比如創建、更新和刪除電影條目。這些操作對應的請求都需要登錄賬戶後才能發送,我們先編寫一個用於登錄賬戶的輔助方法:

test_watchlist.py:測試輔助方法

class WatchlistTestCase(unittest.TestCase):
# ...
# 輔助方法,用於登入用戶
def login(self):
self.client.post(/login, data=dict(
username=test,
password=123
), follow_redirects=True)

login() 方法中,我們使用 post() 方法發送提交登錄表單的 POST 請求。和 get() 方法類似,我們需要先傳入目標 URL,然後使用 data 關鍵字以字典的形式傳入請求數據(字典中的鍵為表單 <input> 元素的 name 屬性值),作為登錄表單的輸入數據;而將 follow_redirects 參數設為 True 可以跟隨重定向,最終返回的會是重定向後的響應。

下面是測試創建、更新和刪除條目的測試方法:

test_watchlist.py:測試創建、更新和刪除條目

class WatchlistTestCase(unittest.TestCase):
# ...
# 測試創建條目
def test_create_item(self):
self.login()

# 測試創建條目操作
response = self.client.post(/, data=dict(
title=New Movie,
year=2019
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertIn(Item created., data)
self.assertIn(New Movie, data)

# 測試創建條目操作,但電影標題為空
response = self.client.post(/, data=dict(
title=,
year=2019
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertNotIn(Item created., data)
self.assertIn(Invalid input., data)

# 測試創建條目操作,但電影年份為空
response = self.client.post(/, data=dict(
title=New Movie,
year=
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertNotIn(Item created., data)
self.assertIn(Invalid input., data)

# 測試更新條目
def test_update_item(self):
self.login()

# 測試更新頁面
response = self.client.get(/movie/edit/1)
data = response.get_data(as_text=True)
self.assertIn(Edit item, data)
self.assertIn(Test Movie Title, data)
self.assertIn(2019, data)

# 測試更新條目操作
response = self.client.post(/movie/edit/1, data=dict(
title=New Movie Edited,
year=2019
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertIn(Item updated., data)
self.assertIn(New Movie Edited, data)

# 測試更新條目操作,但電影標題為空
response = self.client.post(/movie/edit/1, data=dict(
title=,
year=2019
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertNotIn(Item updated., data)
self.assertIn(Invalid input., data)

# 測試更新條目操作,但電影年份為空
response = self.client.post(/movie/edit/1, data=dict(
title=New Movie Edited Again,
year=
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertNotIn(Item updated., data)
self.assertNotIn(New Movie Edited Again, data)
self.assertIn(Invalid input., data)

# 測試刪除條目
def test_delete_item(self):
self.login()

response = self.client.post(/movie/delete/1, follow_redirects=True)
data = response.get_data(as_text=True)
self.assertIn(Item deleted., data)
self.assertNotIn(Test Movie Title, data)

在這幾個測試方法中,大部分的斷言都是在判斷響應主體是否包含正確的提示消息和電影條目信息。

登錄、登出和認證保護等功能的測試如下所示:

test_watchlist.py:測試認證相關功能

class WatchlistTestCase(unittest.TestCase):
# ...
# 測試登錄保護
def test_login_protect(self):
response = self.client.get(/)
data = response.get_data(as_text=True)
self.assertNotIn(Logout, data)
self.assertNotIn(Settings, data)
self.assertNotIn(<form method="post">, data)
self.assertNotIn(Delete, data)
self.assertNotIn(Edit, data)

# 測試登錄
def test_login(self):
response = self.client.post(/login, data=dict(
username=test,
password=123
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertIn(Login success., data)
self.assertIn(Logout, data)
self.assertIn(Settings, data)
self.assertIn(Delete, data)
self.assertIn(Edit, data)
self.assertIn(<form method="post">, data)

# 測試使用錯誤的密碼登錄
response = self.client.post(/login, data=dict(
username=test,
password=456
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertNotIn(Login success., data)
self.assertIn(Invalid username or password., data)

# 測試使用錯誤的用戶名登錄
response = self.client.post(/login, data=dict(
username=wrong,
password=123
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertNotIn(Login success., data)
self.assertIn(Invalid username or password., data)

# 測試使用空用戶名登錄
response = self.client.post(/login, data=dict(
username=,
password=123
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertNotIn(Login success., data)
self.assertIn(Invalid input., data)

# 測試使用空密碼登錄
response = self.client.post(/login, data=dict(
username=test,
password=
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertNotIn(Login success., data)
self.assertIn(Invalid input., data)

# 測試登出
def test_logout(self):
self.login()

response = self.client.get(/logout, follow_redirects=True)
data = response.get_data(as_text=True)
self.assertIn(Goodbye., data)
self.assertNotIn(Logout, data)
self.assertNotIn(Settings, data)
self.assertNotIn(Delete, data)
self.assertNotIn(Edit, data)
self.assertNotIn(<form method="post">, data)

# 測試設置
def test_settings(self):
self.login()

# 測試設置頁面
response = self.client.get(/settings)
data = response.get_data(as_text=True)
self.assertIn(Settings, data)
self.assertIn(Your Name, data)

# 測試更新設置
response = self.client.post(/settings, data=dict(
name=Grey Li,
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertIn(Settings updated., data)
self.assertIn(Grey Li, data)

# 測試更新設置,名稱為空
response = self.client.post(/settings, data=dict(
name=,
), follow_redirects=True)
data = response.get_data(as_text=True)
self.assertNotIn(Settings updated., data)
self.assertIn(Invalid input., data)

測試命令

除了測試程序的各個視圖函數,我們還需要測試自定義命令。app.test_cli_runner() 方法返回一個命令運行器對象,我們創建類屬性 self.runner 來保存它。通過對它調用 invoke() 方法可以執行命令,傳入命令函數對象,或是使用 args 關鍵字直接給出命令參數列表。invoke() 方法返回的命令執行結果對象,它的 output 屬性返回命令的輸出信息。下面是我們為各個自定義命令編寫的測試方法:

test_watchlist.py:測試自定義命令行命令

# 導入命令函數
from app import app, db, Movie, User, forge, initdb

class WatchlistTestCase(unittest.TestCase):
# ...
# 測試虛擬數據
def test_forge_command(self):
result = self.runner.invoke(forge)
self.assertIn(Done., result.output)
self.assertNotEqual(Movie.query.count(), 0)

# 測試初始化資料庫
def test_initdb_command(self):
result = self.runner.invoke(initdb)
self.assertIn(Initialized database., result.output)

# 測試生成管理員賬戶
def test_admin_command(self):
db.drop_all()
db.create_all()
result = self.runner.invoke(args=[admin, --username, grey, --password, 123])
self.assertIn(Creating user..., result.output)
self.assertIn(Done., result.output)
self.assertEqual(User.query.count(), 1)
self.assertEqual(User.query.first().username, grey)
self.assertTrue(User.query.first().validate_password(123))

# 測試更新管理員賬戶
def test_admin_command_update(self):
# 使用 args 參數給出完整的命令參數列表
result = self.runner.invoke(args=[admin, --username, peter, --password, 456])
self.assertIn(Updating user..., result.output)
self.assertIn(Done., result.output)
self.assertEqual(User.query.count(), 1)
self.assertEqual(User.query.first().username, peter)
self.assertTrue(User.query.first().validate_password(456))

在這幾個測試中,大部分的斷言是在檢查執行命令後的資料庫數據是否發生了正確的變化,或是判斷命令行輸出(result.output)是否包含預期的字元。

運行測試

最後,我們在程序結尾添加下面的代碼:

if __name__ == __main__:
unittest.main()

使用下面的命令執行測試:

$ python test_watchlist.py
...............
----------------------------------------------------------------------
Ran 15 tests in 2.942s

OK

如果測試出錯,你會看到詳細的錯誤信息,進而可以有針對性的修復對應的程序代碼,或是調整測試方法。

測試覆蓋率

為了讓讓程序更加強壯,你可以添加更多、更完善的測試。那麼,如何才能知道程序里有哪些代碼還沒有被測試?整體的測試覆蓋率情況如何?我們可以使用 Coverage.py 來檢查測試覆蓋率,首先安裝它(添加 --dev 參數將它作為開發依賴安裝):

$ pipenv install coverage --dev

使用下面的命令執行測試並檢查測試覆蓋率:

$ coverage run --source=app test_watchlist.py

因為我們只需要檢查程序腳本 app.py 的測試覆蓋率,所以使用 --source 選項來指定要檢查的模塊或包。

最後使用下面的命令查看覆蓋率報告:

$ coverage report
Name Stmts Miss Cover
----------------------------
app.py 146 5 97%

從上面的表格可以看出,一共有 146 行代碼,沒測試到的代碼有 5 行,測試覆蓋率為 97%。

你還可以使用 coverage html 命令獲取詳細的 HTML 格式的覆蓋率報告,它會在當前目錄生成一個 htmlcov 文件夾,打開其中的 index.html 即可查看覆蓋率報告。點擊文件名可以看到具體的代碼覆蓋情況,如下圖所示:

同時在 .gitignore 文件後追加下面兩行,忽略掉生成的覆蓋率報告文件:

htmlcov/
.coverage

本章小結

通過測試後,我們就可以準備上線程序了。結束前,讓我們提交代碼:

$ git add .
$ git commit -m "Add unit test with unittest"
$ git push

提示 你可以在 GitHub 上查看本書示常式序的對應 commit:66dc487。

進階提示

  • 訪問 Coverage.py 文檔(https://coverage.readthedocs.io)或執行 coverage help 命令來查看更多用法。
  • 使用標準庫中的 unittest 編寫單元測試並不是唯一選擇,你也可以使用第三方測試框架,比如非常流行的 pytest。
  • 《Flask Web 開發實戰》 第 12 章詳細介紹了測試 Flask 程序的相關知識,包括使用 Selenium 編寫用戶界面測試,使用 Flake8 檢查代碼質量等。
  • 本書主頁 & 相關資源索引:helloflask.com/tutorial

推薦閱讀:

TAG:Flask | Python | Web開發 |