• 「高效程序员的修炼」快速入门python主流测试框架pytest以及单元测试编写


    如果对你有帮助,就点个赞吧~

    本文主要介绍如果编写Python的单元测试,包括如何使用断言,如何考虑测试哪些情况,如何避免外部依赖对测试的影响,如果用数据驱动的方式简化重复测试的编写等等等等

    Code without tests is bad code. It doesn’t matter how well written it is; it doesn’t matter how pretty or object-oriented

    or well-encapsulated it is. With tests, we can change the behavior of our code quickly and verifiably. Without them,

    we really don’t know if our code is getting better or worse. ---- Michael C. Feathers Working Effective with Legacy Code

    缺少测试的代码不能称之为好代码。这无关于代码写得多好,也无关于代码是否简洁,是否是面对对象,是否进行了良好的封装。

    通过测试,我们可以迅速且可验证地改变代码的行为。而缺少测试的情况下,我们无从得知我们的代码是否得到优化,抑或是变得更糟。

    ---- 迈克尔·C·费瑟斯 《修改代码的艺术》

    单元测试相关说明

    • 基于测试的开发:
      • 编写测试并使测试失败
      • 实现功能
      • 使测试通过
    • 测试驱动开发:
      • 编写测试并使测试失败
      • 实现功能
      • 使测试通过
      • 重构代码,保证测试通过的情况下提高代码的可读性可维护性等
    • 一个单元测试够不够单元测试
      • 直接看测试运行得快不快(单元测试对发现性能问题的意义),如果一个单元测试运行速度过慢(比如0.5s),说明可能存在性能问题
      • 比如: 测试函数中使用了正则,如果正则出现大量的构造或解构,则会导致性能极其低下。具体原因可以是通过循环对大量短小的表达式进行正则匹配(可以考虑在不摧毁表达式可理解性的情况下把所有短小表达式合并为一个大的表达式);相同表达式进行反复的compile(考虑优化成一次compile后多次匹配)

    1. 测试的基础:使用断言

    from typing import Union
    
    def is_leap_year(cls, year: Union[str, int]):
        """ 检验某个年份是否是闰年
            校验规则: 四年一润,百年不润,四百年再润
    
        Args:
            year (int): 要校验的年份
        
        Notices:
            1. 存在对传入参数的检测, 不允许传入参数类型为str且以0开头
            2. 当前没有对传入参数为负数的情况进行限制
    
        Returns:
            bool:   True  - 闰年
                    False - 平年
        """
        if isinstance(year, str):
            assert len(str(int(year))) == len(year), f"Invalid parameter: year, value: {year}"
    
        year = int(year)
        if year % 400 == 0:
            return True
        elif year % 100 == 0:
            return False
        elif year % 4 == 0:
            return True
        return False
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    假设要测试4种情况:

    • 输入为int类型,且对应闰年
    • 输入为int类型,且对应平年
    • 输入为str类型,且对应闰年
    • 输入为str类型,且对应平年

    测试样例如下

    def test_is_leap_year_true_input_int():
        """ 输入为int类型,且对应闰年
            预期: 返回True
        """
        assert is_leap_year(2008) == True
    
    def test_is_leap_year_false_input_int():
        """ 输入为int类型,且对应平年
            预期: 返回True
        """
        assert is_leap_year(2007) == False
    
    def test_is_leap_year_false_input_str():
        """ 输入为str类型,且对应闰年
            预期: 返回True
        """
        assert is_leap_year("2007") == False
    
    def test_is_leap_year_true_input_str():
        """ 输入为str类型,且对应平年
            预期: 返回True
        """
        assert is_leap_year("2008") == True
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    2. 多组测试数据的简洁写法

    上述4个测试函数是根据函数传入参数的类型以及值是否对应闰年两两组合得到的四个测试样例,也分别对应四个测试等价类
    由于测试需要划分测试等价类,于是会有多组测试用例. 比如对于一个判断分数是否大于等于60从而给出是否及格的判断函数,小于60分与大于等于60分就是该方法的测试等价类
    一个测试方法对于一个测试等价类得到的结果应该是相同的,而测试样例需要从每一个测试等价类中取一个具体的数据作为测试该等价类的测试用例
    上述is_leap_year的测试等价类自然不止4种,但划分等价测试类不是我们这部分要讨论的重点
    这里要讨论的是对于此类传入参数极其简单,测试描述可以完全直接反映在测试函数名称,测试数据以及测试预期结果的情况,有如下方法可以简化代码

    2.1 将多个测试数据写在函数中, 以遍历的方式逐一调用测试函数进行测试

    极其不推荐,一个测试函数中最好只有一个assert, 否则测试不通过时定位错误需要额外定位是哪组测试数据通过)
    (适合一个测试函数多个assert的情况:为提高可读性将一个assert拆分成多个assert,本质上是对一次函数测试进行多层次的assert)

    def test_is_leap_year():
        test_data = (
            (2008, True),
            (2007, False),
            ("2008", True),
            ("2007", False),
        )
        for test_input, expected_result in test_data:
            assert is_leap_year(test_input) == expected_result
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    2.2 使用pytest框架的parameterize装饰器

    (与方法1的差别在于,pytest会自动对测试数据进行分拆且以类似测试函数后缀的形式区分不同样例, 同时可以通过传入ids参数给@pytest.mark.parametrize()为用例名称赋值)

    import pytest
    
    @pytest.mark.parametrize("test_year,expected_result", [("2000", True),
                                                           ("2008", True),
                                                           (2008, True),
                                                           (400, True),
                                                           ("1900", False)])
    def test_is_leap_year(test_year, expected_result):
        assert is_leap_year(test_year) == expected_result
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    3. 测试程序崩溃的情况

    在上述is_leap_year()中存在一种异常情况,即当传入参数为字符串且以0开头时, 会直接抛出异常, 这种情况程序会在运行时报错。而我们可以在单元测试中测试在异常发生时,程序的处理行为是否与预期一致。

    测试程序按预期抛出异常或处理异常的方法如下:

    3.1 直接捕获异常, 进而验证异常是否为预期异常

    def test_is_leap_year():
        try:
            is_leap_year("0400")
        except Exception as excep:
            assert isinstance(excep,AssertionError)
            assert str(excep) == "Invalid parameter: year, value: 0400"
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    3.2 使用unitest框架

    import unittest
    
    class TestExceptionCase(unittest.TestCase):
        def test_invalid_input(self):
            self.assertRaises(AssertionError, is_leap_year, "0400")
            self.assertRaisesRegex(AssertionError, "Invalid parameter: year, value: 0400", is_leap_year, "0400")
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    3.3 使用pytest框架

    import pytest
    
    @pytest.mark.xfail(raises=AssertionError)
    def test_invalid_input():
        is_leap_year("0400")
    
    def test_invalid_input_2():
        with pytest.raises(AssertionError, match='Invalid parameter: year, value: 0400'):
            is_leap_year("0400")
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    4. 简化测试代码的组合技

    • 使用「2.2 」中展示的pytest.mark.paramtrize装饰器来进行多组测试数据的输入
    • 使用pytest.param(), 并将marks参数传入pytest.markd.xfail来预期该组测试数据将使单元测试的结果不通过,即assert语句抛出AssertError
    @pytest.mark.parametrize("test_year,expected_result", [("2000", True),
                                                           ("2008", True),
                                                           (2008, True),
                                                           (400, True),
                                                           (-400, True),
                                                           ("-400", True),
                                                           pytest.param("0400", False, marks=pytest.mark.xfail),
                                                           pytest.param("0400", True, marks=pytest.mark.xfail),
                                                           ("1900", False)])
    def test_is_leap_year(test_year, expected_result):
        assert is_leap_year(test_year) == expected_result
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    5. 使用Mock对象及方法以解除外部依赖(如网页接口或数据库请求,Redis请求,时间戳生成,随机数生成等)

    5.1 对Request请求进行Mock(以httpbin.org作为辅助进行说明)

    以下例程为不进行Mock,完全依赖于httpbin.org这个网站返回情况的情况

    """
    对https://httpbin.org/get进行请求会返回所有请求中的参数,在返回体的args中
    https://httpbin.org/get?text=Hello World的返回如下
    {
        "args": {
            "text": "Hello World"
        }, 
        "headers": {
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 
            "Accept-Encoding": "gzip, deflate, br", 
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", 
            "Host": "httpbin.org", 
            "Sec-Ch-Ua": "\"Google Chrome\";v=\"87\", \" Not;A Brand\";v=\"99\", \"Chromium\";v=\"87\"", 
            "Sec-Ch-Ua-Mobile": "?0", 
            "Sec-Fetch-Dest": "document", 
            "Sec-Fetch-Mode": "navigate", 
            "Sec-Fetch-Site": "none", 
            "Sec-Fetch-User": "?1", 
            "Upgrade-Insecure-Requests": "1", 
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36", 
            "X-Amzn-Trace-Id": "Root=1-6017d21d-395b4a050a9c34904fccf3ea"
        }, 
        "origin": "108.166.207.187", 
        "url": "https://httpbin.org/get?text=Hello World"
        }
    """
    
    import requests
    
    def test_request_parameters():
        parameters = {'Hello': 'World'}
        parameter_pairs = [f"{param_name}={param_value}" for param_name, param_value in parameters.items()]
        resp = requests.get(f"https://httpbin.org/get?{'&'.join(parameter_pairs)}")
        assert resp.status_code == 200, f"Expect Response Status Code 200, But Got {resp.status_code}"
        # assert resp.status_code == 500, f"Expect Response Status Code 500, But Got {resp.status_code}"
        assert resp.json()["args"] == parameters
    
    def test_request_parameters_poor_readability():
        parameters = {'text': 'Hello World'}
        resp = requests.get(f"https://httpbin.org/get?{'&'.join([key+'='+value for key, value in parameters.items()])}")
        assert resp.json()["args"] == parameters
    test_request_parameters()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    上述测试的问题在于: 其Assert语句是否为真完全依赖于httpbin.org的实际返回
    当我们希望测试请求成功,返回状态码为200的情况时,我们写下:

    assert resp.status_code == 200, f"Expect Response Status Code 200, But Got {resp.status_code}
    
    • 1

    当该网站服务正常时,我们的测试将顺利通过。但当网站的服务出现异常的时候,我们的单元测试将不通过。
    从另外一个角度,如果我们试图测试返回异常的情况,我们写下:

    assert resp.status_code == 500, f"Expect Response Status Code 500, But Got {resp.status_code}
    
    • 1

    则该测试只能在网站请求异常时通过。
    而我们希望的时我们的测试在任何时间,任何物理地点以及任何软件环境下能有相同的行为,能保证其测试的独立性, 于是我们需要使用Mock来实现这一点

    5.2 Introduction to Mock

    顾名思义,mock的本质是通过模仿指定对象的行为来实现对其解除依赖的目的。我们能自由定义用来替换外部依赖的mock对象的一切行为,包括且不限于属性,方法,方法的返回值。
    注意: python2.x中mock没有被纳入标准库, 需要pip install mock安装, 且是以import mock的方式导入,而不是from unittest import mock

    from unittest import mock
    mock_example = mock.Mock()
    print(mock_example)
    print(mock_example())
    # 输出:
    # 
    # 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    在python中, 利用unittest中的mock可以Mock出对象
    在我们为对其行为进行定义前,其行为都为各种mock对象,如上述mock_example,我们未对其定义__call__方法而直接调用的话将返回另一个mock对象
    而我们可以对其行为进行如下定义:

    定义mock对象被当作函数调用时的返回值

    mock_example.return_value = "Result when call mock_example" 
    mock_example()
    # 输出:
    # 'Result when call mock_example'
    
    • 1
    • 2
    • 3
    • 4

    定义Mock对象的属性

    mock_example.status_code = 200
    mock_example.status_code
    # 输出
    # 200
    
    • 1
    • 2
    • 3
    • 4

    定义Mock对象的方法

    需要注意的是, 由于我们定义的是方法,即当我们定义方法mock_method后,将以mock_method()的形式调用它, 这与刚才我们直接将mock对象当作函数调用时的形式一致。
    故我们刚才通过定义mock对象的return_value来定义函数返回值, 则此时也是通过定义mock_method.return_value来定义这个方法的返回值

    mock_example.say_hi.return_value = "Hi!"
    mock_example.say_hi()
    # 输出
    # 'Hi!'
    
    • 1
    • 2
    • 3
    • 4

    有了对mock的基本了解,重新分析5.1中模拟并替换requests库的get方法,则可以有如下实现:

    import requests
    from unittest import mock
    
    def test_request_parameters_mock_get():
        parameters = {'text': 'Hello World'}
        mock_response = mock.Mock(status_code=200)
        mock_response.json.return_value = {"args": parameters}
        requests = mock.Mock()
        requests.get.return_value = mock_response
        parameter_pairs = [f"{param_name}={param_value}" for param_name, param_value in parameters.items()]
        resp = requests.get(f"https://httpbin.org/get?{'&'.join(parameter_pairs)}")
        assert resp.status_code == 200, f"Expect Response Status Code 200, But Got {resp.status_code}"
        assert resp.json()["args"] == parameters
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    利用pytest,我们可以提升一下上述代码的简洁性和可读性

    import requests
    from unittest import mock
    
    """ usage: @mock.patch("the_import_path_of_the_object_you_wanna_mock")
        like: @mock.patch("src.utils_base.redis_client.RedisHub")       # 对应 from src.utils_base.redis_client import RedisHub
        like: @mock.patch("src.utils_base.redis_client.RedisHub.rpush") # 对应 from src.utils_base.redis_client import RedisHub; RedisHub.rpush()
    
        将用来以假乱真的mock对象的变量名作为传入参数添加进被测试函数. 如上一个代码块中的requests对象. 
        由于我们只需要用到requests的get方法, 可以直接mock该方法, 而不用去mock外层的requests库
        即我们将用requests_get来替换request.get方法, 以resp = requests_get(f"https://httpbin.org/get?{'&'.join(parameter_pairs)}")的形式调用
    
        为了避免混淆, 增加可读性, 我们增加一个mock_前缀给变量名来表示其性质, 故以下代码中变量名为mock_requests_get
    """
    
    @mock.patch("requests.get")
    def test_request_parameters_mock_get(mock_requests_get):
        parameters = {'text': 'Hello World'}
        mock_response = mock.Mock(status_code=200)
        mock_response.json.return_value = {"args": parameters}
        mock_requests_get.return_value = mock_response
    
        parameter_pairs = [f"{param_name}={param_value}" for param_name, param_value in parameters.items()]
        resp = requests.get(f"https://httpbin.org/get?{'&'.join(parameter_pairs)}")
        assert resp.status_code == 200, f"Expect Response Status Code 200, But Got {resp.status_code}"
        assert resp.json()["args"] == parameters
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    5.3 对当前IP或操作系统进行mock

    假设我们有一个代码块需要对当前运行程序的环境进行判断并对应不同环境执行不同操作,如下:

    import platform
    import socket
    import os
    
    def detect_environment():
        ENV_DEV = "DEV"
        ENV_TEST = "TEST"
        ENV_PROD = "PROD"
    
        OS_TYPE = platform.system().upper()
        HOST_NAME = socket.gethostname()
        IP_ADDR = socket.gethostbyname(HOST_NAME)
    
        if OS_TYPE == "WINDOWS" or HOST_NAME.lower().startswith('万物皆可MOCK'):
            ENV = ENV_DEV
        elif HOST_NAME.lower().find("sit") > -1 or IP_ADDR == "10.80.62.161":
            ENV = ENV_TEST
        else:
            ENV = ENV_PROD
        return ENV
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    则在不进行mock的情况下, 我们的OS类型, 主机名, IP地址将根据实际情况变化
    比如当前我在Windows操作系统中, IP地址为192.168.21.84
    则程序运行结果如下:

    print(f"Current ENV: {detect_environment()}")
    # 输出
    # Current ENV: DEV
    
    • 1
    • 2
    • 3

    假设我们希望测试其他环境的程序行为,则可以通过mock操作系统, IP, 主机名来实现

    5.3.1 Mock操作系统

    platform = mock.Mock()
    platform.system.return_value = "linux"
    print(platform.system())
    print(platform.system().upper()) # uppper()方法是字符串自带的不需要MOCK
    print(f"Current ENV: {detect_environment()}")
    
    • 1
    • 2
    • 3
    • 4
    • 5

    5.3.2 Mock主机名

    通过detect_environment()的实现逻辑, 我们知道就算当前的操作系统是linux, 但只要我们的主机名以"万物皆可MOCK"开头, 当前环境也会被识别为"DEV"

    于是,我们现在就将我们的主机名改为以"万物皆可MOCK"作为前缀的主机名

    socket = mock.Mock()
    socket.gethostname.return_value = "万物皆可MOCK,连MOCK都可以MOCK"
    print(f"Current HostName: {socket.gethostname()}")
    print(f"Current ENV: {detect_environment()}")
    # 输出
    # Current HostName: 万物皆可MOCK,连MOCK都可以MOCK
    # Current ENV: DEV
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    5.3.3 Mock本机IP (做法相似,此处不再给出具体编码实现,给读者留作练习)

    5.3.4 从其他角度进行mock达到相同目的

    如果我们只是希望detect_environment()能够将我们当前环境识别为TEST, 则除了mock当前IP、操作系统、主机名之外, 其实我们可以……直接mockdetect_environment, 令其return_value为"TEST"即可

    5.4 对Redis请求进行Mock并根据参数的不同返回不同的结果

    假设我们利用了redis来存储少数SVIP用户ID, 并在程序中通过检查用户ID是否存在于Redis中来决定是否执行SVIP行为, 如下:

    import redis 
    
    def check_whether_SVIP(user_id: str):
        redis_con = redis.StrictRedis(host="localhost", port=6039, db=0, password="nopassword")
        target_key = "the_place_stored_svip_id"
        if redis_con.sismember(target_key, user_id):
            print(f"{user_id} is SVIP.")
        else:
            print(f"{user_id} is not SVIP.")
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    目前为止, 我们mock函数或方法时都是定义其return_value使其在被调用时固定返回我们希望的值
    如果我们希望调用时根据传入参数的不同而有不同的返回,也即我们希望定义函数对参数进行判断后决定该返回何值时, 我们就可以使用「side_effect」, 如下:

    import pytest
    import redis
    
    def test_check_whether_SVIP():
        def judge_logic(user_id):
            return True if user_id == "ef30c771-eaa8-4bab-b666-8061eee53610" else False
        check_whether_SVIP = mock.Mock()
        check_whether_SVIP.side_effect = judge_logic
        assert check_whether_SVIP("ef30c771-eaa8-4bab-b666-8061eee53610") == True
        
    test_check_whether_SVIP()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    当然,也可以通过对redis连接以及redis对其数据结构的操作来达到我们的目的。但我们只希望能够在传入"ef30c771-eaa8-4bab-b666-8061eee53610"check_whether_SVIP得到True,其他情况返回False, 只对check_whether_SVIP进行mock就足够了。

    5.5 测试调用次数(或者redo次数)以及调用信息

    import pytest
    from unittest import mock
    import redis
    
    def test_check_whether_SVIP():
        """ 测试check_whether_SVIP是否只被调用一次
            同时测试check_whether是否以参数「ef30c771-eaa8-4bab-b666-8061eee53610」被调用
        """
        def judge_logic(user_id):
            return True if user_id == "ef30c771-eaa8-4bab-b666-8061eee53610" else False
        check_whether_SVIP = mock.Mock()
        check_whether_SVIP.side_effect = judge_logic
        check_whether_SVIP("ef30c771-eaa8-4bab-b666-8061eee53610")
        # check_whether_SVIP("ef30c771-eaa8-4bab-b666-8061eee53610")
        check_whether_SVIP.assert_called_once()
        check_whether_SVIP.assert_called_once_with("ef30c771-eaa8-4bab-b666-8061eee53610")
        
    test_check_whether_SVIP()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    assert_called_once()来判断check_whether_SVIP()是否只被调用一次

    assert_called_once_with("ef30c771-eaa8-4bab-b666-8061eee53610")来判断check_whether_SVIP()只被调用一次,且被调用时,传入参数是否为"ef30c771-eaa8-4bab-b666-8061eee53610"

    需要注意的是: assert_called_once()这类断言函数,本身自带assert的功能,check_whether_SVIP.assert_called_once()在断言成立时没有返回值(也即返回None),故该check_whether_SVIP.assert_called_once()不可以加assert关键字,否则会在check_whether_SVIP.assert_called_once()成立时外部抛出AssertError.

    我们可以尝试多次调用check_whether_SVIP,使得assert_called_once()失败来查看该情况下的报错信息:

    import pytest
    from unittest import mock
    import redis
    
    def test_check_whether_SVIP():
        def judge_logic(user_id):
            return True if user_id == "ef30c771-eaa8-4bab-b666-8061eee53610" else False
        check_whether_SVIP = mock.Mock()
        check_whether_SVIP.side_effect = judge_logic
        check_whether_SVIP("ef30c771-eaa8-4bab-b666-8061eee53610")
        check_whether_SVIP("ef30c771-eaa8-4bab-b666-8061eee53610")
        check_whether_SVIP.assert_called_once()
        check_whether_SVIP.assert_called_once_with("ef30c771-eaa8-4bab-b666-8061eee53610")
        
    test_check_whether_SVIP()
    
    # 输出
    ---------------------------------------------------------------------------
    AssertionError                            Traceback (most recent call last)
    <ipython-input-47-3674c6a2e28f> in <module>
         13     check_whether_SVIP.assert_called_once_with("ef30c771-eaa8-4bab-b666-8061eee53610")
         14 
    ---> 15 test_check_whether_SVIP()
    
    <ipython-input-47-3674c6a2e28f> in test_check_whether_SVIP()
         10     check_whether_SVIP("ef30c771-eaa8-4bab-b666-8061eee53610")
         11     check_whether_SVIP("ef30c771-eaa8-4bab-b666-8061eee53610")
    ---> 12     check_whether_SVIP.assert_called_once()
         13     check_whether_SVIP.assert_called_once_with("ef30c771-eaa8-4bab-b666-8061eee53610")
         14 
    
    E:\zhangzhaobin\software\anaconda3\lib\unittest\mock.py in assert_called_once(self)
        890                       self.call_count,
        891                       self._calls_repr()))
    --> 892             raise AssertionError(msg)
        893 
        894     def assert_called_with(self, /, *args, **kwargs):
    
    AssertionError: Expected 'mock' to have been called once. Called 2 times.
    Calls: [call('ef30c771-eaa8-4bab-b666-8061eee53610'),
     call('ef30c771-eaa8-4bab-b666-8061eee53610')].
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    我们也可以尝试断言check_whether_SVIP以与实际调用参数不一致的参数被调用,使得assert_called_once_with()失败来查看这种情况下的报错信息:

    import pytest
    from unittest import mock
    import redis
    
    def test_check_whether_SVIP():
        """ 由于我们调用check_whether_SVIP()时传入参数为"ef30c771-eaa8-4bab-b666-8061eee53610"而不是"XXXXXXXX"
         所以当我们断言heck_whether_SVIP以参数为"XXXXXXXX"被调用时将抛出AssertError
        """
        def judge_logic(user_id):
            return True if user_id == "ef30c771-eaa8-4bab-b666-8061eee53610" else False
        check_whether_SVIP = mock.Mock()
        check_whether_SVIP.side_effect = judge_logic
        check_whether_SVIP("ef30c771-eaa8-4bab-b666-8061eee53610")
        check_whether_SVIP.assert_called_once()
        check_whether_SVIP.assert_called_once_with("XXXXXXXXXXXXXXXXXXX")
        
    test_check_whether_SVIP()
    # 输出
    ---------------------------------------------------------------------------
    AssertionError                            Traceback (most recent call last)
    <ipython-input-48-f2f2a5b20e3d> in <module>
         12     check_whether_SVIP.assert_called_once_with("XXXXXXXXXXXXXXXXXXX")
         13 
    ---> 14 test_check_whether_SVIP()
    
    <ipython-input-48-f2f2a5b20e3d> in test_check_whether_SVIP()
         10     check_whether_SVIP("ef30c771-eaa8-4bab-b666-8061eee53610")
         11     check_whether_SVIP.assert_called_once()
    ---> 12     check_whether_SVIP.assert_called_once_with("XXXXXXXXXXXXXXXXXXX")
         13 
         14 test_check_whether_SVIP()
    
    E:\zhangzhaobin\software\anaconda3\lib\unittest\mock.py in assert_called_once_with(self, *args, **kwargs)
        923                       self._calls_repr()))
        924             raise AssertionError(msg)
    --> 925         return self.assert_called_with(*args, **kwargs)
        926 
        927 
    
    E:\zhangzhaobin\software\anaconda3\lib\unittest\mock.py in assert_called_with(self, *args, **kwargs)
        911         if expected != actual:
        912             cause = expected if isinstance(expected, Exception) else None
    --> 913             raise AssertionError(_error_message()) from cause
        914 
        915 
    
    AssertionError: expected call not found.
    Expected: mock('XXXXXXXXXXXXXXXXXXX')
    Actual: mock('ef30c771-eaa8-4bab-b666-8061eee53610')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49

    5.6 测试生成本地配置文件(利用fixture的tmpdir)

    5.7 更多玩法

    • 测试区分python版本

    6. pytest的fixtrue

    假设我们有一个测试数据会被用在多个格式化方法的测试中,如下:

    def clean_role(role: str) -> str:
        """ 对表示角色的文本进行数据清洗.
            规则: 去除文本末尾的?
        """
        return role.rstrip("?")
    
    def clean_role_type(role_type: str) -> str:
        """ 对表示角色类型的文本进行数据清洗.
            规则: 去除文本中的⚜
        """
        return role_type.replace("⚜", "")
    
    def test_clean_role():
        role_info = {
            "name": "游戏角色⚜",
            "role": "不死族???"
        }
        assert clean_role(role_info["role"]) == "不死族"
    
    def test_clean_role_type():
        role_info = {
            "name": "游戏角色⚜",
            "role": "不死族???"
        }
        assert clean_role_type(role_info["name"]) == "游戏角色"
    
    
    test_clean_role()
    test_clean_role_type()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    test_clean_role()test_clean_role_type()两个测试中使用了相同的测试数据,但是需要在两个测试函数中存在相同的局部变量来表示该测试数据。

    这种情况我们可以通过将上述role_info提取出来作为全局变量,但全局变量的存在意味变量的值中途变化导致测试输入与预期不一致的风险。

    为了避免测试输入发生意料之外的变化的同时减少重复代码,可以使用pytest的fixture,其创建语法及在上述例子中的替换方法如下:

    import pytest
    
    @pytest.fixture
    def role_info():
        return { "name": "游戏角色⚜", "role": "不死族???" }
    
    
    def clean_role(role: str) -> str:
        """ 对表示角色的文本进行数据清洗.
            规则: 去除文本末尾的?
        """
        return role.rstrip("?")
    
    def clean_role_type(role_type: str) -> str:
        """ 对表示角色类型的文本进行数据清洗.
            规则: 去除文本中的⚜
        """
        return role_type.replace("⚜", "")
    
    def test_clean_role(role_info):
        assert clean_role(role_info["role"]) == "不死族"
    
    def test_clean_role_type(role_info):
        assert clean_role_type(role_info["name"]) == "游戏角色"
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    fixture的默认作用范围是function级别,这意味着test_clean_role(role_info)与test_clean_role_type(role_info)这两个函数传入的参数是独立的,可以认为各自是role_info()返回值的一个深拷贝。

    fixture的可选级别有: function, class, module, package, session. 根据情况可以选择不同的作用域。

    7. pytest常用的marker

    • 常用的marker有: skip, skipif, xfail, parametrize

    • skip的作用是直接跳过指定的单元测试(加了@pytest.mark.skip(reason="why this test would be skipped")的测试都会被跳过)

    • skipif的作用是在if条件满足时跳过指定的单元测试,例如在python版本小于3.8.0时不运行指定测试,在项目版本小于2.0时不运行指定测试等等

    • xfail的作用是标记预期失败的测试,标记了@pytest.mark.xfail(True, reason="Observe XFAIL Behaviors", raises=AssertionError, strict=True)的测试需要不通过测试才算通过测试(这句话可真有意思Σ(゚Д゚))

    • parametrize的作用是将测试数据集参数化,即我们可以将测试输入和测试输出作为一组参数传入,该装饰器将自动将输入输出映射到测试函数的参数中并运行测试。通过该装饰器也可以实现文件输入,如下:

    current_dir = os.path.dirname(os.path.abspath(__file__))
    df = pd.read_csv(f"{current_dir}/change_item_to_change_type_statistics.csv")
    df = df.fillna("")
    """
    change_item_to_change_type_statistics.csv内容如下:
    "change_item","type","cnt"
    "经营范围变更","经营范围变更","19655404"
    "","","16487298"
    "其他事项备案","其他变更","14084373"
    "章程备案","其他变更","10999454"
    "经营范围变更(含业务范围变更)","经营范围变更","10552502"
    "经营范围","经营范围变更","10148886"
    """
    
    @pytest.mark.parametrize("change_item, expected_result", list(zip(df.change_item, df.type)))
    def test_classify_change_item(change_item, expected_result):
        result = ChangeRecFormatter.classify_change_item(change_item)
        assert result == expected_result
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    8. pytest的常用运行参数

    选项作用示例
    -k EXPRESSION只运行匹配给定表达式的测试. 匹配指的是传入关键词为单元测试函数名称或类名称的子串. 表达式中可以使用python语法的与或非。关键字匹配不区分大小写。-k 'test_method or test_other'将匹配并执行所有名称中包含test_method或包含test_other的单元测试
    -k 'not test_method'将运行名称中不包含test_method的单元测试
    -k 'not test_method and not test_other'将运行名称中既不包含test_method也不包含test_other的单元测试.
    -m MARKEXPR只运行带有给定marker的单元测试。 -m 'mark1 and not mark2'. 只运行带有mark1且不带有mark2的单元测试
    -x, --exitfirst当出现第一个FAILED的单元测试时直接结束测试。
    –pdb在出现第一个FAILED的地方或收到KeyboardInterrupt进入python的交互式调试模式
    –trace在测试最开始处直接进入pdb模式
    -s捕获标准输入,即显示程序中print()等一类标准输出的内容
    –cache-clear运行测试前清空测试缓存内容
    -q精简模式运行测试
    -v可视化模式运行测试
    -vv详细模式运行测试

    9. pytest的配置以及pre-process和post-process相同流程的自动化

    创建pytest的配置文件pytest.ini, pytest运行时会检查是否存在pytest.ini, 若存在该配置文件则会以配置文件中规定的配置运行测试。以下示例为每次以指定选项运行测试:

    [pytest]
    disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True
    addopts = -vv -s -k ""
    
    • 1
    • 2
    • 3

    disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True用于将测试输出信息中的unicode编码转义为其显式形式,即unicode转中文

    [pytest]
    filterwarnings =
        ignore:.*U.*mode is deprecated:DeprecationWarning
    addopts = -v -s --disable-warnings
    **filterwarnings = ignore:.*U.*mode is deprecated:DeprecationWarning**用于隐藏
    
    • 1
    • 2
    • 3
    • 4
    • 5

    python2.x中由于使用了即将废弃的方法等原因输出的depreciated warning信息。

    创建公共代码文件conftest.py

    • conftest.py用于存在多个测试文件共用的代码,比如测试数据,测试用的fixture等等,可以视为package的__init__.py

    • conftest.py的共享程度由该文件所在路径决定。若在整个代码库的根目录下,则根目录下的所有单元测试都可以共享其内容。若放在单个维度的测试目录下,则仅为该目录下的测试文件所共享。

    Appendix A

  • 相关阅读:
    保证Redis和数据库数据一致性的方法
    机器学习期中考试
    集成底座POC演示流程说明
    【FPGA】zynq 单端口RAM 双端口RAM 读写冲突 写写冲突
    事务的特性
    一文了解riscv软件系列之linux内核编译运行
    ORACLE设置快照回滚点
    Elasticsearch 如何实现时间差查询?
    在Linux下安装配置bochs,并成功跑一个简单的boot引导(超详细)
    五十二、BBS项目
  • 原文地址:https://blog.csdn.net/qq_41785288/article/details/127796712