《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('Test\'s Watchlist', data) self.assertIn('Test Movie Title', data) self.assertEqual(response.status_code, 200)
呼叫這類方法返回包含響應資料的響應物件,對這個響應物件呼叫 get_data()
方法並把 as_text
引數設為 True
可以獲取 Unicode 格式的響應主體。我們通過判斷響應主體中是否包含預期的內容來測試程式是否正常工作,比如 404 頁面響應是否包含 Go Back,主頁響應是否包含標題 Test's 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 NameStmtsMissCover ---------------------------- app.py146597%
從上面的表格可以看出,一共有 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: 999a9fe 。
進階提示
- 訪問 Coverage.py 文件( https://coverage.readthedocs.io)或執行 coverage help 命令來檢視更多用法。
- 使用標準庫中的 unittest 編寫單元測試並不是唯一選擇,你也可以使用第三方測試框架,比如非常流行的pytest。
- 《Flask Web 開發實戰》 第 12 章詳細介紹了測試 Flask 程式的相關知識,包括使用Selenium 編寫使用者介面測試,使用 Flake8 檢查程式碼質量等。
- 本書主頁 & 相關資源索引: http:// helloflask.com/tutorial 。