整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

单元测试实战-四种覆盖详解、测试实例

单元测试实战-四种覆盖详解、测试实例

击上方 "程序员小乐"关注公众号, 星标或置顶一起成长



者:HelloGitHub-追梦人物

一个完整的项目,无论是个人的还是公司的,自动化的单元测试是必不可少,否则以后任何的功能改动将成为你的灾难。

假设你正在维护公司的一个项目,这个项目已经开发了几十个 API 接口,但是没有任何的单元测试。现在你的 leader 让你去修改几个接口并实现一些新的功能,你接到需求后高效地完成了开发任务,然后手动测试了一遍改动的接口和新实现的功能,确保没有任何问题后,满心欢喜地提交了代码。

代码上线后出了 BUG,分析原因发现原来是新的改动导致某个旧 API 接口出了问题,因为上线前只对改动的接口做了测试,所以未能发现这个问题。你的 leader 批评了你,你因为事故记了过,年终只能拿个 3.25,非常凄惨。

但是如果我们有全面的单元测试,上述情况就有很大概率避免。只需要在代码发布前运行一遍单元测试,受影响的功能立即就会报错,这样就能在代码部署前发现问题,从而避免线上事故。

当然以上故事纯属虚构,说这么多只是希望大家在开发时养成良好的习惯,一是写优雅的代码,二是一定要测试自己写的代码

单元测试回顾

在上一部教程 Django博客教程(第二版)单元测试:测试 blog 应用单元测试:测试评论应用Coverage.py 统计测试覆盖率 中,我们详细讲解了 django 单元测试框架的使用方式。这里我们再对 djnago 的测试框架做一个回顾整体回顾,至于如何编写和运行测试,后面将会进行详细的讲解,如果想对 django 的单元测试做更基础的了解,推荐回去看看关于测试的 3 篇教程以及 django 的官方文档。

下面是 djnago 单元测试框架的一些要点:

  • django 的单元测试框架基于 Python 的 unittest 测试框架。
  • django 提供了多个 XXTestCase 类,这些类均直接或者间接继承自 unittest.TestCase 类,因为 django 的单元测试框架是基于 unittest 的,所以编写的测试用例类也都需要直接或者间接继承 unittest.TestCase。通常情况我们都是继承 django 提供的 XXTestCase,因为这些类针对 django 定制了更多的功能特性。
  • 默认情况下,测试代码需要放在 django 应用的下的 tests.py 文件或者 tests 包里,django 会自动发现 tests 包中以 test 开头的模块(例如 test_models.py、test_views.py),然后执行测试用例类中命名以 test 开头的方法。
  • python manage.py test 命令可以运行单元测试。

梳理需要测试的接口

接下来我们就为博客的 API 接口来编写单元测试。对 API 接口来说,我们主要关心的就是:对特定的请求返回正确的响应。我们先来梳理一下需要测试的接口和功能点。

博客主要的接口都集中在 PostViewSet 和 CommentViewSet 两个视图集中。

  • CommentViewSet 视图集的接口比较简单,就是创建评论。
  • PostViewSet 视图集的接口则包含了文章列表、文章详情、评论列表、归档日期列表等。对于文章列表接口,还可以通过查询参数对请求的文章列表资源进行过滤,获取全部文章的一个子集。

测试 CommentViewSet

CommentViewSet 只有一个接口,功能比较简单,我们首先以它为例来讲解单元测试的编写方式。

测试接口的一般步骤:

  1. 获得接口的 URL。
  2. 向接口发送请求。
  3. 检查响应的 HTTP 状态码、返回的数据等是否符合预期。

我们以测试创建评论的代码 test_create_valid_comment 为例:

# filename="comments/tests/test_api.py
from django.apps import apps
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.reverse import reverse
from rest_framework.test import APITestCase

from blog.models import Category, Post
from comments.models import Comment


class CommentViewSetTestCase(APITestCase):
    def setUp(self):
        self.url = reverse("v1:comment-list")
        # 断开 haystack 的 signal,测试生成的文章无需生成索引
        apps.get_app_config("haystack").signal_processor.teardown()
        user = User.objects.create_superuser(
            username="admin", email="admin@hellogithub.com", password="admin"
        )
        cate = Category.objects.create(name="测试")
        self.post = Post.objects.create(
            title="测试标题", body="测试内容", category=cate, author=user,
        )

    def test_create_valid_comment(self):
        data = {
            "name": "user",
            "email": "user@example.com",
            "text": "test comment text",
            "post": self.post.pk,
        }
        response = self.client.post(self.url, data)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        comment = Comment.objects.first()
        self.assertEqual(comment.name, data["name"])
        self.assertEqual(comment.email, data["email"])
        self.assertEqual(comment.text, data["text"])
        self.assertEqual(comment.post, self.post)

首先,接口的 URL 地址为:reverse("v1:comment-list")。reverse 函数通过视图函数名来解析对应的 URL,视图函数名的格式为:"<namespace>:<basename>-<action name>"。

其中 namespace 是 include 函数指定的 namespace 参数值,例如:

path("api/v1/", include((router.urls, "api"), namespace="v1"))

basename 是 router 在 register 视图集时指定的参数 basename 的值,例如:

router.register(r"posts", blog.views.PostViewSet, basename="post")

action name 是 action 装饰器指定的 url_name 参数的值,或者默认的 list、retrieve、create、update、delete 标准 action 名,例如:

# filename="blog/views.py
@action(
 methods=["GET"], detail=False, url_path="archive/dates", url_name="archive-date"
)
def list_archive_dates(self, request, *args, **kwargs):
 pass

因此,reverse("v1:comment-list") 将被解析为 /api/v1/comments/。

接着我们向这个 URL 发送 POST 请求:response=self.client.post(self.url, data),因为继承自 django-reset-framework 提供的测试类 APITestCase,因此可以直接通过 self.client 来发送请求,其中 self.client 是 django-rest-framework 提供的 APIClient 的一个实例,专门用来发送 HTTP 测试请求。

最后就是对请求的响应结果 response 做检查。创建评论成功后返回的状态码应该是 201,接口返回的数据在 response.data 属性中,我们对接口返回的状态码和部分数据进行了断言,确保符合预期的结果。

当然以上是评论创建成功的情况,我们测试时不能只测试正常情况,更要关注边界情况和异常情况,我们再来增加一个评论数据格式不正确导致创建失败的测试案例:

# filename="comments/tests/test_api.py
def test_create_invalid_comment(self):
    invalid_data = {
        "name": "user",
        "email": "user@example.com",
        "text": "test comment text",
        "post": 999,
    }
    response = self.client.post(self.url, invalid_data)
    self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
    self.assertEqual(Comment.objects.count(), 0)

套路还是一样的,第一步向接口发请求,然后对预期返回的响应结果进行断言。这里由于评论数据不正确(关联的 id 为 999 的 post 不存在),因此预期返回的状态码是 400,同时数据库中不应该有创建的评论。

测试 PostViewSet

尽管 PostViewSet 包含的接口比较多,但是每个接口测试的套路和上面讲的是一样的,依葫芦画瓢就行了。因为 PostViewSet 测试代码较多,这里仅把各个测试案例对应的方法列出来,具体的测试逻辑省略掉。如需了解详细可查看 GitHub 上项目的源码:

# filename="blog/tests/test_api.py
from datetime import datetime

from django.apps import apps
from django.contrib.auth.models import User
from django.core.cache import cache
from django.urls import reverse
from django.utils.timezone import utc
from rest_framework import status
from rest_framework.test import APITestCase

from blog.models import Category, Post, Tag
from blog.serializers import PostListSerializer, PostRetrieveSerializer
from comments.models import Comment
from comments.serializers import CommentSerializer


class PostViewSetTestCase(APITestCase):
    def setUp(self):
        # 断开 haystack 的 signal,测试生成的文章无需生成索引
        apps.get_app_config("haystack").signal_processor.teardown()
        # 清除缓存,防止限流
        cache.clear()

        # 设置博客数据
        # post3 category2 tag2 2020-08-01 comment1 comment2
        # post2 category1 tag1 2020-07-31
        # post1 category1 tag1 2020-07-10

    def test_list_post(self):
        """
        这个方法测试文章列表接口,预期的响应状态码为 200,数据为文章列表序列化后的结果
        """
        url = reverse("v1:post-list")

    def test_list_post_filter_by_category(self):
        """
        这个方法测试获取某个分类下的文章列表接口,预期的响应状态码为 200,数据为文章列表序列化后的结果
        """
        url = reverse("v1:post-list")
        

    def test_list_post_filter_by_tag(self):
        """
        这个方法测试获取某个标签下的文章列表接口,预期的响应状态码为 200,数据为文章列表序列化后的结果
        """
        url = reverse("v1:post-list")
        

    def test_list_post_filter_by_archive_date(self):
        """
        这个方法测试获取归档日期下的文章列表接口,预期的响应状态码为 200,数据为文章列表序列化后的结果
        """
        url = reverse("v1:post-list")
        

    def test_retrieve_post(self):
        """
        这个方法测试获取单篇文章接口,预期的响应状态码为 200,数据为单篇文章序列化后的结果
        """
        url = reverse("v1:post-detail", kwargs={"pk": self.post1.pk})
        

    def test_retrieve_nonexistent_post(self):
        """
        这个方法测试获取一篇不存在的文章,预期的响应状态码为 404
        """
        url = reverse("v1:post-detail", kwargs={"pk": 9999})
        

    def test_list_archive_dates(self):
        """
        这个方法测试获取文章的归档日期列表接口
        """
        url = reverse("v1:post-archive-date")
        

    def test_list_comments(self):
        """
        这个方法测试获取某篇文章的评论列表接口,预期的响应状态码为 200,数据为评论列表序列化后的结果
        """
        url = reverse("v1:post-comment", kwargs={"pk": self.post3.pk})
        

    def test_list_nonexistent_post_comments(self):
        """
        这个方法测试获取一篇不存在的文章的评论列表,预期的响应状态码为 404
        """
        url = reverse("v1:post-comment", kwargs={"pk": 9999})

我们以 test_list_post_filter_by_archive_date 为例做一个讲解,其它的测试案例代码逻辑大同小异。

# filename="blog/tests/test_api.py
def test_list_post_filter_by_archive_date(self):
    # 解析文章列表接口的 URL
    url = reverse("v1:post-list")
    
    # 发送请求,我们这里给 get 方法的第二个参数传入了一个字典,这个字典代表了 get 请求的查询参数。
    # 例如最终的请求的 URL 会被编码成:/posts/?created_year=2020&created_month=7
    response = self.client.get(url, {"created_year": 2020, "created_month": 7})
    self.assertEqual(response.status_code, status.HTTP_200_OK)
    
    # 如何检查返回的数据是否正确呢?对这个接口的请求,
    # 我们预期返回的结果是 post2 和 post1 这两篇发布于2020年7月的文章序列化后的数据。
    # 因此,我们使用 PostListSerializer 对这两篇文章进行了序列化,
    # 然后和返回的结果 response.data["results"] 进行比较。
    serializer = PostListSerializer(instance=[self.post2, self.post1], many=True)
    self.assertEqual(response.data["results"], serializer.data)

运行测试

接下来运行测试:

"Linux/macOS"
$ pipenv run coverage run manage.py test

"Windows"
...\> pipenv run coverage run manage.py test

大部分测试都通过了,但是也有一个测试失败了,也就是说我们通过测试发现了一个 BUG:

======================================================================FAIL: test_list_archive_dates (blog.tests.test_api.PostViewSetTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\Users\user\SpaceLocal\Workspace\G_Courses\HelloDjango\HelloDjango-rest-framework-tutorial\blog\tests\test_api.py", line 123, in test_list_archive_dates
    self.assertEqual(response.data, ["2020-08", "2020-07"])
AssertionError: Lists differ: ['2020-08-01', '2020-07-01'] != ['2020-08', '2020-07']

失败的是 test_list_archive_dates 这个测试案例,文章归档日期接口返回的数据不符合我们的预期,我们预期得到 yyyy-mm 格式的日期列表,但接口返回的是 yyyy-mm-dd,这是我们之前开发时没有发现的,通过测试将问题暴露了,这也从一定程度上印证了我们之前强调的测试的作用。

既然已经发现了问题,就来修复它。我相信修复这个 bug 对你来说应该已经是轻而易举的事了,因此留作练习吧,这里不再讲解。

重新运行一遍测试,得到 ok 的状态。

Ran 55 tests in 8.997s

OK

说明全部测试通过。

检查测试覆盖率

以上测试充分了吗?单凭肉眼自然很难发现,Coverage.py 统计测试覆盖率 中我们配置了 Coverage.py 并介绍了它的用法,直接运行下面的命令就可以查看代码的测试覆盖程度:

"Linux/macOS"
$ pipenv run coverage report

"Windows"
...\> pipenv run coverage report

覆盖结果如下:

Name                  Stmts   Miss Branch BrPart  Cover   Missing
-----------------------------------------------------------------
blog\serializers.py      46      5      0      0    89%   82-86
blog\utils.py            21      2      4      1    88%   29->30, 30-31
blog\views.py           119      5      4      0    94%   191, 200, 218-225
comments\views.py        25      1      2      0    96%   59
-----------------------------------------------------------------
TOTAL                  1009     13     34      1    98%

可以看到测试覆盖率整体达到了 98%,但是仍有 4 个文件部分代码未被测试,命令行中只给出了未被测试覆盖的代码行号(Missing 列),不是很直观,运行下面的命令可以生成一个 HTML 报告,可视化地查看未被测试覆盖的代码片段:

"Linux/macOS"
$ pipenv run coverage html

"Windows"
...\> pipenv run coverage html

命令执行后会在项目根目录生成一个 htmlcov 文件夹,用浏览器打开里面的 index.html 页面就可以查看测试覆盖情况的详细报告了。

HTML 报告页面示例:



未覆盖的代码通过红色高亮背景标出,非常直观。可以看到 blog/views.py 中 CategoryViewSet 和 TagViewSet 未进行测试,按照上面介绍的测试方法补充测试就可以啦。这两个视图集都非常的简单,测试的任务就留作练习了。

补充测试

blog/serializers.py 中的 HighlightedCharField 未测试,还有 blog/utils.py 中新增的 UpdatedAtKeyBit 未测试,我们编写相应的测试案例。

测试 UpdatedAtKeyBit

UpdatedAtKeyBit 就只有一个 get_data 方法,这个方法预期的逻辑是:从缓存中取得以 self.key 为键的缓存值(缓存被设置时的时间),如果缓存未命中,就取当前时间,并将这个时间写入缓存。

将预期的逻辑写成测试代码如下,需要注意的一点是因为这个辅助类不涉及 django 数据库方面的操作,因此我们直接继承自更为简单的 unittest.TestCase,这可以提升测试速度:

# filename="blog/tests/test_utils.py
import unittest
from datetime import datetime

from django.core.cache import cache

from ..utils import Highlighter, UpdatedAtKeyBit

class UpdatedAtKeyBitTestCase(unittest.TestCase):
    def test_get_data(self):
        # 未缓存的情况
        key_bit = UpdatedAtKeyBit()
        data = key_bit.get_data()
        self.assertEqual(data, str(cache.get(key_bit.key)))

        # 已缓存的情况
        cache.clear()
        now = datetime.utcnow()
        now_str = str(now)
        cache.set(key_bit.key, now)
        self.assertEqual(key_bit.get_data(), now_str)

测试 HighlightedCharField

我们在讲解自定义系列化字段的时候讲过,序列化字段通过调用 to_representation 方法,将传入的值进行序列化。HighlightedCharField 的预期逻辑就是调用 to_representation 方法后将传入的值进行高亮处理。

HighlightedCharField 涉及到一些高级操作,主要是因为 to_representation 方法中涉及到对 HTTP 请求request 的操作。正常的视图函数调用时,视图函数会接收到传入的 request 参数,然后 django-rest-framework 会将 request 传给序列化器(Serializer)的 _context 属性,序列化器中的任何序列化字段均可以通过直接访问 context 属性而间接访问到 _context 属性,从而拿到 request 对象。

但是在单元测试中,可能没有这样的视图函数调用,因此 _context 的设置并不会自动进行,需要我们模拟视图函数调用时的行为,手动进行设置。主要包括 2 点:

  1. 构造 HTTP 请求对象 request。
  2. 设置 _context 属性的值。

具体的代码如下,详细讲解请看相关代码行的注释:

# filename="blog/tests/test_serializer.py
import unittest

from blog.serializers import HighlightedCharField
from django.test import RequestFactory
from rest_framework.request import Request


class HighlightedCharFieldTestCase(unittest.TestCase):
    def test_to_representation(self):
        field = HighlightedCharField()
        # RequestFactory 专门用来构造 request 对象。
        # 这个 RequestFactory 生成的 request 代表了一个对 URL / 访问的 get 请求,
        # 并包含 URL 参数 text=关键词。
        # 请求访问的完整 URL 就是 /?text=关键词
        request = RequestFactory().get("/", {"text": "关键词"})
        
        # django-rest-framework 对 django 内置的 request 进行了包装,
        # 因此这里要手动使用 drf 提供的 Request 类对 django 的 request 进行一层包装。
        drf_request = Request(request=request)
        
        # 设置 HighlightedCharField 实例 _context 属性的值,这样在其内部就可以通过
        # self.context["request"] 拿到请求对象 request
        setattr(field, "_context", {"request": drf_request})
        document = "无关文本关键词无关文本,其他别的关键词别的无关的词。"
        result = field.to_representation(document)
        expected = (
            '无关文本<span class="highlighted">关键词</span>无关文本,'
            '其他别的<span class="highlighted">关键词</span>别的无关的词。'
        )
        self.assertEqual(result, expected)

再次运行一遍测试覆盖率的检查命令,这次得到的测试覆盖率就是 100% 了:

Name    Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------
---------------------------------------------------
TOTAL    1047      0     32      0   100%

当然,需要提醒一点的是,测试覆盖率 100% 并不能说明程序就没有 BUG 了。线上可能出现各种奇奇怪怪的问题,这些问题可能并没有写成测试案例,所以也就没有测试到。但无论如何,目前我们已经进行了较为充分的测试,就可以考虑发布一个版本了。如果以后再线上遇到什么问题,或者想到了新的测试案例,可以随时补充进单元测试,以后程序出 BUG 的几率就会越来越低了。

在我的日常工作中,我是一名专业程序员。我使用c++、c#和Javascript。我是一个开发团队的一员,他们使用单元测试来验证我们的代码是否按照它应该的方式工作。

在本文中,我将通过讨论以下主题来研究如何使用Python创建单元测试。

  • 单元测试基础
  • 可用的Python测试框架
  • 测试设计原则
  • 代码覆盖率

单元测试基础

我使用FizzBuzz编码方式创建了单元测试示例。编码类型是程序员的练习。在这个练习中,程序员试图解决一个特定的问题。但主要目标不是解决问题,而是练习编程。FizzBuz是一个简单的代码类型,非常适合解释和展示Python中的单元测试。

单元测试

单元测试是程序员为测试程序的一小部分而编写的自动化测试。单元测试应该运行得很快。与文件系统、数据库或网络交互的测试不是单元测试。

为了在Python中创建第一个FizzBuzz单元测试,我定义了一个继承自unittest.TestCase的类。这个unittest模块可以在Python的标准安装中获得。

import unittest
class FizzBuzzTest(unittest.TestCase):
    def test_one_should_return_one(self):
        fizzbuzz=FizzBuzz()
        result=fizzbuzz.filter(1)
        self.assertEqual('1', result)


    def test_two_should_return_two(self):
        fizzbuzz=FizzBuzz()
        result=fizzbuzz.filter(2)
        self.assertEqual('2', result)


第一个测试用例验证数字1是否通过了FizzBuzz过滤器,它将返回字符串' 1 '。使用self验证结果。assertEqual方法。方法的第一个参数是预期的结果,第二个参数是实际的结果。

测试用例

我们在测试用例FizzBuzzTest类中调用test_one_should_return_one()方法。测试用例是测试程序特定部分的实际测试代码。

第一个测试用例验证数字1是否通过了FizzBuzz过滤器,它将返回字符串' 1 '。使用self验证结果。assertEqual方法。方法的第一个参数是预期的结果,第二个参数是实际的结果。

如果您查看这两个测试用例,您会看到它们都创建了FizzBuzz类的一个实例。第一个在第6行,另一个在第11行。

我们可以从这两个方法中重构FizzBuzz实例的创建,从而改进代码。

import unittest
class FizzBuzzTest(unittest.TestCase):
    def setUp(self):
        self.fizzbuzz=FizzBuzz()


    def tearDown(self):
        pass


    def test_one_should_return_one(self):
        result=self.fizzbuzz.filter(1)
        self.assertEqual('1', result)


    def test_two_should_return_two(self):
        result=self.fizzbuzz.filter(2)
        self.assertEqual('2', result)

我们使用setUp方法创建FizzBuzz类的实例。TestCase基类的设置在每个测试用例之前执行。

另一个方法tearDown是在每个单元测试执行之后调用的。你可以用它来清理或关闭资源。

测试夹具

方法的设置和拆卸是测试夹具的一部分。测试夹具用于配置和构建被测试单元。每个测试用例都可以使用这些通用条件。在本例中,我使用它创建FizzBuzz类的实例。

要运行单元测试,我们需要一个测试运行器。

测试运行器

测试运行程序是执行所有单元测试并报告结果的程序。Python的标准测试运行器可以使用以下命令在终端上运行。

python -m unittest test_fizzbuzz.py


测试套件

单元测试词汇表的最后一个术语是测试套件。测试套件是测试用例或测试套件的集合。通常一个测试套件包含应该一起运行的测试用例。

单元测试设计

测试用例应该被很好地设计。考试的名称和结构是最重要的。

测试用例名称

测试的名称非常重要。它就像一个总结考试内容的标题。如果测试失败,你首先看到的就是它。因此,名称应该清楚地表明哪些功能不起作用。

测试用例名称的列表应该读起来像摘要或场景列表。这有助于读者理解被测单元的行为。


构造测试用例方法体

一个设计良好的测试用例由三部分组成。第一部分,安排、设置要测试的对象。第二部分,Act,练习被测单元。最后,第三部分,断言,对应该发生的事情提出主张。

有时,我在单元测试中添加这三个部分作为注释,以使其更清楚。

import unittest

class FizzBuzzTest(unittest.TestCase):

    def test_one_should_return_one(self):
        # Arrange
        fizzbuzz=FizzBuzz()
        # Act
        result=fizzbuzz.filter(1)
        # Assert
        self.assertEqual('1', result)


每个测试用例的单个断言

尽管在一个测试用例中可能有很多断言。我总是尝试使用单个断言。

原因是,当断言失败时,测试用例的执行就会停止。因此,您永远不会知道测试用例中的下一个断言是否成功。

使用pytest进行单元测试

在上一节中,我们使用了unittest模块。Python的默认安装安装这个模块。unittest模块于2001年首次引入。基于Kent Beck和Eric Gamma开发的流行的Java单元测试框架JUnit。

另一个模块pytest是目前最流行的Python单元测试框架。与unittest框架相比,它更具有python风格。您可以将测试用例定义为函数,而不是从基类派生。

因为pytest不在默认的Python安装中,所以我们使用Python的包安装程序PIP来安装它。通过在终端中执行以下命令,可以安装pytest。

pip install pytest

下面我将第一个FizzBuzz测试用例转换为pytest。

def test_one_should_return_one():
    fizzbuzz=FizzBuzz()
    result=fizzbuzz.filter(1)
    assert '1'==result

有三个不同点。首先,您不需要导入任何模块。其次,您不需要实现一个类并从基类派生。最后,您可以使用标准的Python assert方法来代替自定义的方法。


测试装置

您还记得,单元测试模块使用setUp和tearDown来配置和构建测试中的单元。相反,pytest使用@pytest.fixture属性。在您的测试用例中,您可以使用用该属性装饰的方法的名称作为参数。

pytest框架在运行时将它们连接起来,并将fizzBuzz实例注入测试用例中。

@pytest.fixture
def fizzBuzz():
    return FizzBuzz()


def test_one_should_return_one(fizzBuzz):
    result=fizzBuzz.filter(1)
    assert result=='1'


def test_two_should_return_two(fizzBuzz):
    result=fizzBuzz.filter(2)
    assert result=='2'

如果您想要模拟单元测试tearDown()方法的行为,可以使用相同的方法来实现。不使用return,而是使用yield关键字。然后,您可以将清理代码放在yield之后。

@pytest.fixture
def fizzBuzz():
    yield FizzBuzz()
    # put your clean up code here


pytest标记

标记是可以在测试各种函数时使用的属性。例如,如果您将跳过标记添加到您的测试用例中,测试运行器将跳过测试。

@pytest.mark.skip(reason="WIP")
def test_three_should_return_fizz(fizzBuzz):
    result=fizzBuzz.filter(3)
    assert result=='Fizz'


pytest插件生态系统

pytest有很多插件可以添加额外的功能。到我写这篇文章的时候,已经有将近900个插件了。例如,pytest-html和pytest-sugar。

pytest-html

pytest- HTML是pytest的插件,它为测试结果生成HTML报告。当您在构建服务器上运行单元测试时,这非常有用。

pytest-sugar

pytest-sugar改变pytest的默认外观和感觉。它会添加一个进度条,并立即显示失败的测试。

创建代码覆盖率报告

有一些工具可以创建代码覆盖率报告。这个代码覆盖率报告显示了您的单元测试执行了哪些代码。

我使用Coverage和pytest-cov来创建代码覆盖率报告。覆盖率是度量代码覆盖率的通用包。模块pytest-cov是pytest的一个插件,用于连接到Coverage。

都可以使用pip安装。

pip install coverage

pip install pytest-cov

在您安装了这两个命令之后,您可以使用这两个命令生成覆盖率报告。在终端或命令中运行它们。

coverage run -m pytest

coverage html

第一个生成覆盖率数据。第二个命令将数据转换为HTML报告。Coverage将报告存储在文件系统的htmlcov文件夹中。

如果你在浏览器中打开index.html,它会显示每个文件覆盖率的概览。

如果您选择一个文件,它将显示下面的屏幕。覆盖率向源代码添加了一个指示,显示单元测试覆盖了哪一行。

下面我们看到我们的单元测试并没有涵盖第12行和第16行。

分支覆盖度量

覆盖率还支持分支覆盖率度量。有了分支覆盖率,如果您的程序中有一行可以跳转到下一行以上,覆盖率跟踪是否访问了这些目的地。

您可以通过执行以下命令来创建带有分支覆盖率的覆盖率报告。

pytest——cov-report html:htmlcov——cov-branch——cov=alarm

我指示pytest生成一个带有分支覆盖的HTML覆盖报告。它应该将结果存储在htmlcov中。而不是为所有文件生成覆盖率报告,我告诉覆盖率只使用alarm.py。