7

统计 Django 项目的测试覆盖率

 4 years ago
source link: http://www.cnblogs.com/xueweihan/p/12419866.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

by2eqm2.jpg!web

作者: 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%
blog\admin.py                                       11      0      0      0   100%
blog\apps.py                                         4      0      0      0   100%
blog\elasticsearch2_ik_backend.py                    8      0      0      0   100%
blog\feeds.py                                       12      0      0      0   100%
blog\migrations\0001_initial.py                      7      0      0      0   100%
blog\migrations\0002_auto_20190711_1802.py           7      0      0      0   100%
blog\migrations\0003_auto_20191011_2326.py           4      0      0      0   100%
blog\migrations\0004_post_views.py                   4      0      0      0   100%
blog\migrations\__init__.py                          0      0      0      0   100%
blog\models.py                                      62      0      0      0   100%
blog\search_indexes.py                               8      0      0      0   100%
blog\templatetags\__init__.py                        0      0      0      0   100%
blog\templatetags\blog_extras.py                    15      0      0      0   100%
blog\tests\__init__.py                               0      0      0      0   100%
blog\tests\test_models.py                           58      0      2      0   100%
blog\tests\test_smoke.py                             4      0      0      0   100%
blog\tests\test_templatetags.py                    115      0      2      0   100%
blog\tests\test_utils.py                            11      0      0      0   100%
blog\tests\test_views.py                           170      0      8      0   100%
blog\urls.py                                         4      0      0      0   100%
blog\utils.py                                       10      0      2      1    92%   14->16
blog\views.py                                       40      7      2      0    79%   64-72
blogproject\__init__.py                              0      0      0      0   100%
blogproject\settings\__init__.py                     0      0      0      0   100%
blogproject\settings\common.py                      22      0      0      0   100%
blogproject\settings\local.py                        5      0      0      0   100%
blogproject\settings\production.py                   5      5      0      0     0%   1-8
blogproject\urls.py                                  4      0      0      0   100%
blogproject\wsgi.py                                  4      4      0      0     0%   10-16
comments\__init__.py                                 0      0      0      0   100%
comments\admin.py                                    6      0      0      0   100%
comments\apps.py                                     4      0      0      0   100%
comments\forms.py                                    6      0      0      0   100%
comments\migrations\0001_initial.py                  7      0      0      0   100%
comments\migrations\0002_auto_20191011_2326.py       4      0      0      0   100%
comments\migrations\__init__.py                      0      0      0      0   100%
comments\models.py                                  15      0      0      0   100%
comments\templatetags\__init__.py                    0      0      0      0   100%
comments\templatetags\comments_extras.py            12      0      2      0   100%
comments\tests\__init__.py                           0      0      0      0   100%
comments\tests\base.py                              10      0      0      0   100%
comments\tests\test_models.py                        8      0      0      0   100%
comments\tests\test_templatetags.py                 57      0      6      0   100%
comments\tests\test_views.py                        34      0      4      0   100%
comments\urls.py                                     4      0      0      0   100%
comments\views.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%
scripts\fake.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/*
   blogproject\wsgi.py

[report]
show_missing = True
skip_covered = True

再次按照上一节所说的方式运行 Coverage,最终报告结果如下:

Name            Stmts   Miss Branch BrPart  Cover   Missing
-----------------------------------------------------------
blog\utils.py      10      0      2      1    92%   14->16
blog\views.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 文件就可以查看报告结果了:

YNR7f2V.png!web

主页和命令行的结果是一样的,不过我们可以点击文件名,进入到对这个文件更加具体的统计报告页面,例如 blog\views.py 结果如下:

BVj6riQ.png!web

绿色部分代表已覆盖的代码,红色部分代表为覆盖的代码。

完善单元测试

查看文件我们发现, blog\views.py 中未被覆盖的代码原来是 Django 博客实现简单的全文搜索 中的代码,现在我们已经将搜索替换为 Django Haystack 全文检索 了,这段代码也就不需要了,可以直接删除。

blog\views.py的报告结果则表明我们在 Django Haystack 全文检索与关键词高亮 中自定义的搜索关键词高亮器有一个 if 分支条件未被测试到:

NvQJNvB.png!web

检查 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 全文检索与关键词高亮

ARJJ733.png!web

关注公众号加入交流群


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK