2.Python测试

自动化测试一直是软件开发中的一个热门话题,但是在持续集成和微服务的时代,它被谈论得更多。有许多工具可以帮助我们在Python项目中编写、运行和评估测试。我们来看看其中的几个。

1 pytest

虽然Python标准库附带了一个名为unittest的单元测试框架,但pytest[1]是用于测试Python代码的首选测试框架。

pytest让它变得简单(而且有趣!)编写、组织和运行测试。与unittest相比,Python标准库中的pytest

  1. 需要更少的样板代码,这样我们的测试代码将更具可读性。
  2. 支持普通的assert语句,与unittest中的assertSomething方法(如assertEqualsassertTrueassertContains)相比,它可读性更强,更容易记住。
  3. 更新的频率更高,因为它不是Python标准库的一部分。
  4. 通过fixture系统可轻易设置或去掉测试。
  5. 函数型编程

另外,使用pytest,我们可以在所有Python项目中拥有一致的样式风格。比方说,我们的堆栈中有两个web应用程序:一个是用Django构建的,另一个是用Flask构建的。如果没有pytest,我们很可能会使用Django的测试框架和使用Flask扩展如Flask-Testing。这会导致我们的测试会有两种不同的风格。另一方面,使用pytest,两个测试套件将具有一致的代码样式,使得从一个测试套件跳到另一个测试套件更容易。

pytest还有一个大型的、由社区维护的插件生态系统。如:

  • pytest-django:提供一组专门用于测试django应用程序的工具
  • pytest-xdist:用于并行运行测试
  • pytest-cov:添加代码覆盖率支持
  • pytest-instafail:立即显示失败和错误,而不是等到运行结束

2 Mocking

自动化测试应该是快速的、独立的、确定的、可重复的。因此,如果我们需要测试向第三方API发出外部HTTP请求的代码,我们应该真正地模拟该请求。为什么?如果你没有这样的模拟,那么具体的测试将有可能是这样的:

  1. 速度慢,因为它通过网络发出HTTP请求
  2. 依赖于第三方服务和网络本身的速度
  3. 具有不确定性,因为根据API的响应,测试可能会得出不同的结果

Mocking是一种在运行时用模拟对象替换真实对象的做法,模拟对象的行为。因此,我们不会在网络上发送真实的HTTP请求,而只是在调用模拟方法时返回预期的响应。

举个栗子:

import requests

def get_my_ip():
    response = requests.get(
        'http://ipinfo.io/json'
    )
    return response.json()['ip']

def test_get_my_ip(monkeypatch):
    my_ip = '123.123.123.123'

    class MockResponse:
        def __init__(self, json_body):
            self.json_body = json_body

        def json(self):
            return self.json_body

    monkeypatch.setattr(
        requests,
        'get',
        lambda *args, **kwargs: MockResponse({'ip': my_ip})
    )

    assert get_my_ip() == my_ip

我们使用pytestmonkeypatch fixture,用lambda回调替换来自requests模块的get方法的所有调用,lambda回调总是返回MockedResponse的实例。

我们可以使用unittest.mock模块中的create_autospec方法简化测试。此方法创建一个模拟对象,该对象具有与作为参数传递的对象相同的属性和方法:

from unittest import mock
import requests
from requests import Response

def get_my_ip():
    response = requests.get(
        'http://ipinfo.io/json'
    )
    return response.json()['ip']

def test_get_my_ip(monkeypatch):
    my_ip = '123.123.123.123'
    response = mock.create_autospec(Response)
    response.json.return_value = {'ip': my_ip}

    monkeypatch.setattr(
        requests,
        'get',
        lambda *args, **kwargs: response
    )

    assert get_my_ip() == my_ip

尽管pytest建议使用Monkeypatch方法进行模拟,但是标准库中的pytest-mock扩展和unittest.mock库也是很好的方法。

3 代码覆盖率

测试的另一个重要方面是Code Coverage(代码覆盖率)。它是一个度量标准,用于告诉我们测试运行期间执行的行数与代码库中所有行的总数之间的比率。我们可以为此使用pytest-cov插件。

安装后,要使用覆盖率报告运行测试,请添加--cov选项,如下所示:

$ python -m pytest --cov=.

这将会打印以下结果:

================================== test session starts ==================================
platform linux -- Python 3.7.9, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: /home/johndoe/sample-project
plugins: cov-2.10.1
collected 6 items

tests/test_sample_project.py ....                                             [ 66%]
tests/test_sample_project_mock.py .                                           [ 83%]
tests/test_sample_project_mock_1.py .                                         [100%]

----------- coverage: platform linux, python 3.7.9-final-0 -----------
Name                                  Stmts   Miss  Cover
---------------------------------------------------------
sample_project/__init__.py                1      1     0%
tests/__init__.py                         0      0   100%
tests/test_sample_project.py              5      0   100%
tests/test_sample_project_mock.py        13      0   100%
tests/test_sample_project_mock_1.py      12      0   100%
---------------------------------------------------------
TOTAL                                    31      1    97%


==================================  6 passed in 0.13s ==================================

对于项目的每个文件都会有:

  • Stmts:代码行数
  • Miss:测试没有覆盖的代码行数
  • Cover:代码覆盖率

请记住,尽管鼓励我们尽量达到较高的覆盖率,但这并不意味着我们的测试就是良好的测试,而是测试代码的每个happy path和exception path、例如,使用assert sum(3,2)== 5之类的断言进行的测试可以达到很高的覆盖率,但是由于未涵盖exception path,因此我们的代码实际上仍未经测试。

3 Hypothesis

Hypothesis[2]是在Python中执行基于属性测试的库。基于属性的测试不必为每个要测试的参数编写不同的测试用例,而是生成大量依赖于以前测试运行的随机测试数据。这有助于提高测试套件的健壮性,同时减少测试冗余。简而言之,我们的测试代码将更整洁、更干爽,并且总体上更高效,同时覆盖范围更广。

举个栗子,为以下函数编写测试:

def increment(num: int) -> int:
    return num + 1

测试也许会写成这样:

import pytest

@pytest.mark.parametrize(
    'number, result',
    [
        (-2-1),
        (01),
        (34),
        (101234101235),
    ]
)
def test_increment(number, result):
    assert increment(number) == result

这种方法没有错。代码能通过测试,代码覆盖率也很高(准确地说是100%)。那么,基于可能输入的范围,我们的代码测试得如何呢?有相当多的整数可以测试,但只有四个正在测试中使用。在某些情况下,这就足够了。在其他情况下,四种情况是不够的,例如,我们的函数接受一个整数列表而不是一个整数,如果这个列表是空的或者它包含一个元素、几百个元素或者几千个元素呢?在某些情况下,我们根本无法提供(更不用说考虑)所有可能的情况。这就是基于属性的测试发挥作用的地方。

Hypothesis这样的框架提供了生成随机测试数据的方法(称为策略)。Hypothesis还存储以前测试运行的结果,并使用它们创建新的案例。

策略是根据输入数据的形状生成伪随机数据的算法。这是伪随机的,因为生成的数据是基于以前测试的数据。

使用Hypothesis进行基于属性的测试会是如此:

from hypothesis import given
import hypothesis.strategies as st

@given(st.integers())
def test_add_one(num):
    assert increment(num) == num - 1

st.integers()是一种Hypothesis策略,生成用于测试的随机整数,而@given装饰器用于参数化测试函数。因此,当调用测试函数时,从策略生成的整数将被传递其中。

$ python -m pytest test_hypothesis.py --hypothesis-show-statistics

================================== test session starts ===================================
platform darwin -- Python 3.8.5, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/johndoe/sample-project
plugins: hypothesis-5.37.3
collected 1 item

test_hypothesis.py .                                                               [100%]
================================= Hypothesis Statistics ==================================

test_hypothesis.py::test_add_one:

  - during generate phase (0.06 seconds):
    - Typical runtimes: < 1ms, ~ 50% in data generation
    - 100 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100


=================================== 1 passed in 0.08s ====================================

4 类型检查

运行时(或动态)类型检查器,如Typeguard[3]pydantic[4],可以帮助最小化测试的数量。让我们看看pydantic的一个例子:

class User:

    def __init__(self, email: str):
        self.email = email

user = User(email='JKL@123.com')

我们要确保提供的email是真正有效的email。所以,为了验证它,我们必须在某处添加一些帮助程序代码。在编写测试的同时,我们还必须花时间为此编写正则表达式。pydantic可以帮上忙。我们可以用它来定义我们的用户模型:

from pydantic import BaseModel, EmailStr

class User(BaseModel):
    email: EmailStr

user = User(email='john@doe.com')

现在,在创建每个User实例之前,pydantic都将验证email参数。当它不是有效的email时,例如,User(email='something'),将引发ValidationError。这样就不需要编写我们自己的验证器了。

下面来看一个flask app栗子:


from flask import Flask, jsonify
from pydantic import ValidationError, BaseModel, EmailStr, Field

app = Flask(__name__)

@app.errorhandler(ValidationError)
def handle_validation_exception(error):
    response = jsonify(error.errors())
    response.status_code = 400
    return response

class Blog(BaseModel):
    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    author: EmailStr
    title: str
    content: str

测试:

import json

def test_create_blog_bad_request(client):
    response = client.post(
        '/create-blog/',
        data=json.dumps(
            {
            'author''JKL',
            'title'None,
            'content''Some extra awesome content'
        }
        ),
        content_type='application/json',
    )

    assert response.status_code == 400
    assert response.json is not None

5 总结

测试常常让人觉得是一项艰巨的任务。本文提供了一些工具,我们可以使用这些工具简化测试。我们的测试也应该是快速的、独立的、确定性的/可重复的。愉快的写测试吧!

更多文档请参考 www.testdriven.io[5]

参考资料

[1]

pytest: https://docs.pytest.org/en/stable/

[2]

Hypothesis: https://hypothesis.readthedocs.io/en/latest/

[3]

Typeguard: https://typeguard.readthedocs.io/en/latest/

[4]

pydantic: https://pydantic-docs.helpmanual.io/

[5]

更多文档: https://testdriven.io/blog/testing-python/#pytest

 wechat
您的支持将鼓励我继续创作!
您的支持将鼓励我继续创作!
--------------本文结束,感谢您的阅读--------------