統計 Django 項目的測試覆蓋率

作者:HelloGitHub-追夢人物

文中所涉及的示例程式碼,已同步更新到 HelloGitHub-Team 倉庫

我們完成了對 blog 應用和 comment 應用這兩個核心 app 的測試。現在我們想知道的是究竟測試效果怎麼樣呢?測試充分嗎?測試全面嗎?還有沒有沒有測到的地方呢?

單憑肉眼觀察難以回答上面的問題,接下來我們就藉助 Coverage.py,從程式碼覆蓋率的角度來檢測一下我們的測試效果究竟如何。

Coverage.py (以下簡稱 Coverage)是 Python 測試界最為流行的一個庫之一,用來統計測試覆蓋率。測試覆蓋率可以從一個角度衡量程式碼的品質,覆蓋率越高,說明測試越充分,程式碼出現 bug 的幾率也就越小。當然需要注意的是,測試覆蓋率僅僅只是衡量程式碼品質的一個角度,即使是 100% 的覆蓋率也不能說程式碼就是完美的,沒有 bug 的。

安裝 Coverage

要使用 Coverage,首先當然是安裝它:

$ pipenv install coverage --dev

因為只在開發時才用得到,所以使用 Pipenv 安裝時加 –dev 選項將其標記為開發時的依賴庫。

簡單配置 Coverage

Coverage 支援很多配置選項,為了方便,通常將這些配置寫在名為 .coveragerc 的文件中,Coverage 運行時會從項目根目錄讀取這個配置文件。因此先在項目根目錄創建這個文件並寫入最基本的配置:

[run]  branch = True  source = .    [report]  show_missing = True

Coverage 的配置遵循 ini 文件語法。簡單來說就是,[section] 代表一個配置塊,用於組織相關的一組配置。例如這裡 [run] 是一個配置塊,[report] 是另一個配置塊,兩個塊下都有相關的一些配置項。

配置項的格式為 key = value

這幾個簡單配置項的含義為:

  • branch = True。是否統計條件語句的分支覆蓋情況。if 條件語句中的判斷通常有 True 和 False 兩種情況,設置 branch = True 後,Coverage 會測量這兩種情況是否都被測試到。
  • source = .。指定需統計的源程式碼目錄,這裡設置為當前目錄(即項目根目錄)。
  • show_missing = True。在生成的統計報告中顯示未被測試覆蓋到的程式碼行號。

運行 Coverage

簡單配置後,我們就可以來運行 Coverage 了。

打開命令行,進入項目根目錄,依次運行下面的命令(注意如果沒有激活虛擬需使用 pipenv run 讓命令在虛擬環境中執行)。

首先運行 erase 命令清除上一次的統計資訊

$ pipenv run coverage erase

manage.py test 運行 django 單元測試,這是這一次用 coverage run 來運行

$ pipenv run coverage run manage.py test

生成覆蓋率統計報告

$ pipenv run coverage report

覆蓋率統計報告輸出如下:

Name                                             Stmts   Miss Branch BrPart  Cover   Missing  --------------------------------------------------------------------------------------------  _credentials.py                                      2      2      0      0     0%   1-2  blog__init__.py                                     0      0      0      0   100%  blogadmin.py                                       11      0      0      0   100%  blogapps.py                                         4      0      0      0   100%  blogelasticsearch2_ik_backend.py                    8      0      0      0   100%  blogfeeds.py                                       12      0      0      0   100%  blogmigrations001_initial.py                      7      0      0      0   100%  blogmigrations002_auto_20190711_1802.py           7      0      0      0   100%  blogmigrations003_auto_20191011_2326.py           4      0      0      0   100%  blogmigrations004_post_views.py                   4      0      0      0   100%  blogmigrations__init__.py                          0      0      0      0   100%  blogmodels.py                                      62      0      0      0   100%  blogsearch_indexes.py                               8      0      0      0   100%  blogtemplatetags__init__.py                        0      0      0      0   100%  blogtemplatetagsblog_extras.py                    15      0      0      0   100%  blogtests__init__.py                               0      0      0      0   100%  blogteststest_models.py                           58      0      2      0   100%  blogteststest_smoke.py                             4      0      0      0   100%  blogteststest_templatetags.py                    115      0      2      0   100%  blogteststest_utils.py                            11      0      0      0   100%  blogteststest_views.py                           170      0      8      0   100%  blogurls.py                                         4      0      0      0   100%  blogutils.py                                       10      0      2      1    92%   14->16  blogviews.py                                       40      7      2      0    79%   64-72  blogproject__init__.py                              0      0      0      0   100%  blogprojectsettings__init__.py                     0      0      0      0   100%  blogprojectsettingscommon.py                      22      0      0      0   100%  blogprojectsettingslocal.py                        5      0      0      0   100%  blogprojectsettingsproduction.py                   5      5      0      0     0%   1-8  blogprojecturls.py                                  4      0      0      0   100%  blogprojectwsgi.py                                  4      4      0      0     0%   10-16  comments__init__.py                                 0      0      0      0   100%  commentsadmin.py                                    6      0      0      0   100%  commentsapps.py                                     4      0      0      0   100%  commentsforms.py                                    6      0      0      0   100%  commentsmigrations001_initial.py                  7      0      0      0   100%  commentsmigrations002_auto_20191011_2326.py       4      0      0      0   100%  commentsmigrations__init__.py                      0      0      0      0   100%  commentsmodels.py                                  15      0      0      0   100%  commentstemplatetags__init__.py                    0      0      0      0   100%  commentstemplatetagscomments_extras.py            12      0      2      0   100%  commentstests__init__.py                           0      0      0      0   100%  commentstestsbase.py                              10      0      0      0   100%  commentsteststest_models.py                        8      0      0      0   100%  commentsteststest_templatetags.py                 57      0      6      0   100%  commentsteststest_views.py                        34      0      4      0   100%  commentsurls.py                                     4      0      0      0   100%  commentsviews.py                                   17      0      2      0   100%  fabfile.py                                          21     21      0      0     0%   1-43  manage.py                                           12      2      2      1    79%   11-12, 20->exit  scripts__init__.py                                  0      0      0      0   100%  scriptsfake.py                                     63     63     14      0     0%   1-106  --------------------------------------------------------------------------------------------  TOTAL                                              876    104     46      2    87%

倒數第二列是被統計文件的測試覆蓋率,第一列是未被覆蓋的程式碼行號。

大部分文件測試覆蓋率為 100%,說明我們的測試還是比較充分的。但從報告結果中我們發現這樣幾個問題:

  1. 有一些文件其實並不需要測試,或者並非項目的核心文件(例如部署腳本 fabfile.py,django 的 migrations 文件等),這些文件應該從統計中排除。
  2. Coverage 默認顯示全部文件的覆蓋率統計結果,如果文件比較多的話就不好查找非 100% 覆蓋率的文件。畢竟我們的目標是提高程式碼覆蓋率,因此已達 100% 覆蓋的程式碼文件我們不再關心。我們要做的是找到非 100% 覆蓋率的文件,為其添加缺失的測試。

完善 Coverage 配置

可以通過添加 Coverage 配置項輕鬆解決上面 2 個問題。

[run] 配置塊中增加 omit 配置項可以指定排除統計的文件。

[report] 配置塊中增加 skip_covered 配置項可以指定統計報告中不顯示 100% 覆蓋的文件。

這是 .coveragerc 最終配置結果,注意我們在 omit 配置項中指定忽略了一些非核心的項目文件:

[run]  branch = True  source = .  omit =     _credentials.py     manage.py     blogproject/settings/*     fabfile.py     scripts/fake.py     */migrations/*     blogprojectwsgi.py    [report]  show_missing = True  skip_covered = True

再次按照上一節所說的方式運行 Coverage,最終報告結果如下:

Name            Stmts   Miss Branch BrPart  Cover   Missing  -----------------------------------------------------------  blogutils.py      10      0      2      1    92%   14->16  blogviews.py      40      7      2      0    79%   64-72  -----------------------------------------------------------  TOTAL             709      7     30      1    99%    33 files skipped due to complete coverage.

這個報告指出我們仍有 2 個文件沒有達到 100% 的覆蓋率,我們要做的就是為這兩個文件中未測試的程式碼增加單元測試,讓其達到 100% 測試覆蓋率。

不過在動手寫測試之前,我們要搞清楚哪些程式碼沒被測到。命令行報告的最後一列指出了未被測試程式碼的行號,但是這樣看著不是很直觀。一種體驗更好的方式是生成 HTML 報告,這樣我們可以直接在 HTML 報告中查看到未被測試到的具體程式碼。

生成 HTML 報告

coverage report 命令在命令行生成統計報告,而 coverage html 則可以生成 HTML 報告。

在上一節的基礎上,運行如下命令:

$ pipenv run coverage html

運行完成後項目根目錄會多出一個 htmlcov 的文件夾,裡面就是測試覆蓋率的 HTML 報告文件。用瀏覽器打開裡面的 index.html 文件就可以查看報告結果了:

主頁和命令行的結果是一樣的,不過我們可以點擊文件名,進入到對這個文件更加具體的統計報告頁面,例如 blogviews.py 結果如下:

綠色部分代表已覆蓋的程式碼,紅色部分代表為覆蓋的程式碼。

完善單元測試

查看文件我們發現,blogviews.py 中未被覆蓋的程式碼原來是 Django 部落格實現簡單的全文搜索 中的程式碼,現在我們已經將搜索替換為 Django Haystack 全文檢索 了,這段程式碼也就不需要了,可以直接刪除。

blogviews.py 的報告結果則表明我們在 Django Haystack 全文檢索與關鍵詞高亮 中自定義的搜索關鍵詞高亮器有一個 if 分支條件未被測試到:

檢查 blog/tests/test_utils.py 中的測試用例,我們發現只測試了比較短的標題不被截斷,也就是

if len(text_block) < self.max_length:

判斷條件為 True,缺失對判斷條件為 False 的測試。所以我們來構造一個新的測試用例測試標題長度超過 max_length (默認值為 200)的情況時會被截斷:

class HighlighterTestCase(TestCase):      def test_highlight(self):          # 省略已有程式碼 ...            highlighter = Highlighter("標題")          document = "這是一個長度超過 200 的標題,應該被截斷。" + "HelloDjangoTutorial" * 200          self.assertTrue(              highlighter.highlight(document).startswith(                  '...<span class="highlighted">標題</span>,應該被截斷。'              )          )

再次運行 Coverage 生成報告,測試覆蓋率全都 100% 了!

$ pipenv run coverage erase  $ pipenv run coverage run manage.py test  $ pipenv run coverage report  # 輸出  Name    Stmts   Miss Branch BrPart  Cover   Missing  ---------------------------------------------------  ---------------------------------------------------  TOTAL     704      0     28      0   100%

最後提醒一點,Coverage 運行後可能會在項目目錄下生成一些文件,這些文件並不需要納入版本管理,所以將其加入 .gitignore 文件中,防止被提交到程式碼庫:

htmlcov/  .coverage  .coverage.*  coverage.xml  *.cover

HelloDjango 往期回顧:

第 30 篇:Django 部落格單元測試:測試評論應用

第 29 篇:編寫 Django 應用單元測試

第 28 篇:Django Haystack 全文檢索與關鍵詞高亮


關注公眾號加入交流群