当前位置: 首页 > 工具软件 > pytest-flask > 使用案例 >

Pytest-Python单元测试

谭翔
2023-12-01

前言論

本文从六个部分总结pytest库的用法和使用场景

一、构造测试用例 

如何构造樂

pytest在test*.py 或者 *test.py 文件中; 寻找以 test开头或结尾的函数,以Test开头的类里面的 以 test开头或结尾的方法,将这些作为测试用例。
所以需要满足以下

  • 1.文件名以 test开头或结尾;

  • 2.函数/方法以test开头;

  • 3.class类名以Test开头, 且不能有__init__方法

    ps: 基于PEP8规定一般我们的测试用例都是 test_xx, 类则以Test_; 即带下划线的,但是要注意的是不要下划线也是可以运行的!

如何运行辰

  • pytest.main() 作为测试用例的入口
    • 当前目录
      pytest.main(["./"])
      
    • 指定目录/模块
      pytest.main(["./sub_dir"]) # 运行sub_dir目录
      pytest.main(["./sub_dir/test_xx.py"]) # 运行指定模块
      
    • 指定用例
      pytest.main(["./sub_dir/test_xx.py::test_func"]) # 运行指定函数用例
      pytest.main(["./sub_dir/test_xx.py::TestClass::test_method"]) # 运行指定类用例下的方法
      
    • 参数
      其实和pytest命令行参数一样,只是将参数按照顺序放到列表中传参给main函数
  • 命令行
    (参下 - 以下用例都使用的命令行)

简单目录结构示例

  • 目录树
    tests
    ├── test_class.py

    class TestClass:
    	def test_pass():
    		assert 1
    	
    	def test_faild():
    		assert 0
    

    └── test_func.py

    def test_pass():
    	assert 1
    
    def test_faild():
    	assert 0
    

    项目目录下会建立一个tests目录,里面存放单元测试用例,如上所示 ,两个文件 test_class.py是测试类,test_func.py是测试函数
    在如上目录下运行pytest, pytest会在当前目录及递归子目录下按照上述的规则运行测试用例;
    如果只是想收集测试用例,查看收集到哪些测试用例可以查看--collect-only 命令选项

    [python -m] pytest --collect-only
    

    输出, 可以看到collected 3 items 即该命令收集到3个测试用例

    ============================================================= test session starts ==============================================================
    platform darwin -- Python 3.7.3, pytest-4.6.5, py-1.11.0, pluggy-0.13.1
    rootdir: /Users/huangxiaonan/PycharmProjects/future
    plugins: anyio-3.5.0
    collected 3 items                                                                                                                              
    <Module tests/test_class.py>
      <Class Test_ABC>
          <Function test_a>
          <Function test_b>
    <Module tests/test_func.py>
      <Function test_f>
    
    ========================================================= no tests ran in 0.15 seconds =========================================================
    

二、基础用法

使用断言廊

测试用例基础工具assert

  • 自定义断言
    contest.py自定义pytest_assertrepr_compare该函数有三个参数
    • op
    • left
    • right
    class Foo:
    def __init__(self, val):
        self.val = val
    
    def __eq__(self, other):
        return self.val == other.val
    
    
    def pytest_assertrepr_compare(op, left, right):
        if isinstance(left, Foo) and isinstance(right, Foo) and op == "==":
            return [
                "Comparing Foo instances:",
                "   vals: {} != {}".format(left.val, right.val),
            ]
    
    test_assert.py
    from conftest import Foo
    
    
    def test_compare():
        f1 = Foo(1)
        f2 = Foo(2)
        assert f1 == f2
    
    输出
    ============================================================= test session starts ==============================================================
    platform darwin -- Python 3.7.3, pytest-4.6.5, py-1.11.0, pluggy-0.13.1 -- /Library/Developer/CommandLineTools/usr/bin/python3
    cachedir: .pytest_cache
    rootdir: /Users/PycharmProjects/future/tests, inifile: pytest.ini
    plugins: anyio-3.5.0
    collected 1 item                                                                                                                               
    
    tests/test_assert.py::test_compare FAILED
    
    =================================================================== FAILURES ===================================================================
    _________________________________________________________________ test_compare _________________________________________________________________
    
        def test_compare():
            f1 = Foo(1)
            f2 = Foo(2)
    >       assert f1 == f2
    E       assert Comparing Foo instances:
    E            vals: 1 != 2
    
    tests/test_assert.py:7: AssertionError
    =========================================================== 1 failed in 0.05 seconds ===========================================================	
    

捕获异常類

  • 使用pytest.raises()捕获指定异常,以确定异常发生
    import pytest
    
    
    def test_raises():
        with pytest.raises(TypeError) as e:
            raise TypeError("TypeError")
        exec_msg = e.value.args[0]
        assert exec_msg == 'TypeError'
    

指定运行测试用例

  • 1.命令行:: 显示指定
    pytest test__xx.py::test_func
    
  • 2.命令行-k模糊匹配
    pytest -k a # 调用所有带a字符的测试用例
    
  • 3.pytest.mark.自定义标记 装饰器做标记
    • test_mark.py
      @pytest.mark.finished
      def test_func1():
          assert 1 == 1
      
      @pytest.mark.unfinished
      def test_func2():
          assert 1 != 1
      
      @pytest.mark.success
      @pytest.finished
      def test_func3():
          assert 1 == 1
      
      
    • 注册标记到配置
      • 方式1: conftest.py
        def pytest_configure(config):
            marker_list = ["finished", "unfinished", "success"]  # 标签名集合
            for markers in marker_list:
                config.addinivalue_line("markers", markers)
        
      • 方式2: pytest.ini
        [pytest]
        markers=
            finished: finish
            error: error
            unfinished: unfinished
        
      ps: 如果不注册也可以运行,但是会有 warnning <PytestUnknownMarkWarning>
    • 运行方式注意: 标签要用双引号
      • 运行带 finished标签的用例
        pytest -m "finished" # 
        
      • 多选 运行 test_fun1 test_fun2
        pytest -m "finished or unfinished" 
        
      • 多标签用例运行 test_fun3
        pytest -m "finished and success""
        

跳过测试用例 SKIPPED

装饰测试函数或者测试类

  • pytest.mark.skip(reason="beause xxx") 直接跳过
  • pytest.mark.skipif(a>=1, reason="当a>=1执行该测试用例") 满足条件跳过
    • code
      import pytest
      
      
      @pytest.mark.skip(reason="no reason, skip")
      class TestB:
      
          def test_a(self):
              print("------->B test_a")
              assert 1
      
          def test_b(self):
              print("------->B test_b")
      
      @pytest.mark.skipif(a>=1, reason="当a>=1执行该测试用例")
      def test_func2():
          assert 1 != 1
      
    • 输出 (运行 pytest tests/test_mark.py -v)
      ============================================================= test session starts ==============================================================
      platform darwin -- Python 3.7.3, pytest-4.6.5, py-1.11.0, pluggy-0.13.1 -- /Library/Developer/CommandLineTools/usr/bin/python3
      cachedir: .pytest_cache
      rootdir: /Users/huangxiaonan/PycharmProjects/future/tests, inifile: pytest.ini
      plugins: anyio-3.5.0
      collected 3 items                                                                                                                              
      
      tests/test_mark.py::TestB::test_a SKIPPED                                                                                                [ 33%]
      tests/test_mark.py::TestB::test_b SKIPPED                                                                                                [ 66%]
      tests/test_mark.py::test_func2 SKIPPED                                                                                                   [100%]
      
      ========================================================== 3 skipped in 0.02 seconds ===========================================================
      

预见的错误 XPASS

可预见的错误,不想skip, 比如某个测试用例是未来式的(版本升级后)

  • pytest.mark.xfail(version < 2, reason="no supported until version==2")

参数化

  • pytest.mark.parametrize(argnames, argvalues)
  • code
    @pytest.mark.parametrize('passwd',
                          ['123456',
                           'abcdefdfs',
                           'as52345fasdf4'])
    def test_passwd_length(passwd):
        assert len(passwd) >= 8
    
  • 输出
    ============================================================= test session starts ==============================================================
    platform darwin -- Python 3.7.3, pytest-4.6.5, py-1.11.0, pluggy-0.13.1 -- /Library/Developer/CommandLineTools/usr/bin/python3
    cachedir: .pytest_cache
    rootdir: /Users/huangxiaonan/PycharmProjects/future/tests, inifile: pytest.ini
    plugins: anyio-3.5.0
    collected 3 items                                                                                                                              
    
    tests/test_params.py::test_passwd_length[123456] FAILED                                                                                  [ 33%]
    tests/test_params.py::test_passwd_length[abcdefdfs] PASSED                                                                               [ 66%]
    tests/test_params.py::test_passwd_length[as52345fasdf4] PASSED                                                                           [100%]
    
    =================================================================== FAILURES ===================================================================
    
    多参数情况
    @pytest.mark.parametrize('user, passwd', [('jack', 'abcdefgh'), ('tom', 'a123456a')])

三、Fixture

常见的就是数据库的初始连接和最后关闭操作

简单范例

  • code
    @pytest.fixture()
    def postcode():
        return '010'
    
    
    def test_postcode(postcode):
        assert postcode == '010'
    
    在生产环境中一般定义在conftest.py集中管理;
    可以看到如上的范例中定义了一个 fixture 名称以被pytest.fixture()装饰的函数此处为 postcode , 如果想要使用它,需要给测试用例添加同名形参;也可以自定义固件名称pytest.fixture(name="code")此时 fixture的名称为code.

预处理和后处理

yield分割,预处理在yield之前,后处理在yield之后

  • code
    import pytest
    
    
    @pytest.fixture(scope="module")
    def db_conn():
        print(f"mysql conn")
        conn = None
        yield conn
        print(f"mysql close")
        del conn
    
    
    def test_postcode(db_conn):
        print("db_conn = ", db_conn)
        assert db_conn == None
    

fixture作用域聾

scope可接受如下参数

  • function: 函数级(默认),每个测试函数都会执行一次固件;
  • class: 类级别,每个测试类执行一次,所有方法都可以使用;
  • module: 模块级,每个模块执行一次,模块内函数和方法都可使用;
  • session: 会话级,一次测试只执行一次,所有被找到的函数和方法都可用。

pytest.mark.usefixtures

装饰类或者函数用例,完成一些用例的预处理和后处理工作

  • code
    import pytest
    
    
    @pytest.fixture(scope="module")
    def db_conn():
       print(f"mysql conn")
       conn = None
       yield conn
       print(f"mysql close")
       del conn
    
    
    @pytest.fixture(scope="module")
    def auth():
       print(f"login")
       user = None
       yield user
       print(f"logout")
       del user
    
    
    @pytest.mark.usefixtures("db_conn", "auth")
    class TestA:
       def test_a(self):
           assert 1
    
       def test_b(self):
           assert 1
    
    执行python3 -m pytest tests/test_fixture.py -vs
    以上两个fixture将以 db_conn -> auth 的顺序(即位置参数的先后顺序)在用例TestA构建作用域
    另外:也可以多个以多个pytest.mark.usefixtures的方式构建,此时靠近被装饰对象的fixture优先
  • 输出
    ============================================================= test session starts ==============================================================
    platform darwin -- Python 3.7.3, pytest-4.6.5, py-1.11.0, pluggy-0.13.1 -- /Library/Developer/CommandLineTools/usr/bin/python3
    cachedir: .pytest_cache
    rootdir: /Users/PycharmProjects/future/tests, inifile: pytest.ini
    plugins: anyio-3.5.0
    collected 2 items                                                                                                                              
    
    tests/test_fixture.py::TestA::test_a mysql conn
    login
    PASSED
    tests/test_fixture.py::TestA::test_b PASSEDlogout
    mysql close
    
    
    =========================================================== 2 passed in 0.06 seconds ===========================================================
    

fixture自动化

fixture参数autouse=True, 则fixture将自动执行
新建一个conftest.py文件,添加如下代码,以下代码是官方给出的demo,计算 session 作用域(总测试),及 function 作用域(每个函数/方法)的运行时间

  • code
    import time
    import pytest
    
    
    DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
    
    
    @pytest.fixture(scope='session', autouse=True)
    def timer_session_scope():
       start = time.time()
       print('\nstart: {}'.format(time.strftime(DATE_FORMAT, time.localtime(start))))
    
       yield
    
       finished = time.time()
       print('finished: {}'.format(time.strftime(DATE_FORMAT, time.localtime(finished))))
       print('Total time cost: {:.3f}s'.format(finished - start))
    
    
    @pytest.fixture(autouse=True)
    def timer_function_scope():
       start = time.time()
       yield
       print(' Time cost: {:.3f}s'.format(time.time() - start))
    
    当执行conftest.py当前目录及递归子目录下的所有用例时将自动执行以上fixture

fixture参数化

区别于参数化测试, 这部分主要是对固件进行参数化,比如连接两个不同的数据库
固件参数化需要使用 pytest 内置的固件 request,并通过 request.param 获取参数。

  • code
    import pytest
    
    
    @pytest.fixture(params=[
        ('redis', '6379'),
        ('mysql', '3306')
    ])
    def param(request):
        return request.param
    
    
    @pytest.fixture(autouse=True)
    def db(param):
        print('\nSucceed to connect %s:%s' % param)
    
        yield
    
        print('\nSucceed to close %s:%s' % param)
    
    
    def test_api():
        assert 1 == 1
    
  • 输出
    ============================================================= test session starts ==============================================================
    platform darwin -- Python 3.7.3, pytest-4.6.5, py-1.11.0, pluggy-0.13.1 -- /Library/Developer/CommandLineTools/usr/bin/python3
    cachedir: .pytest_cache
    rootdir: /Users/PycharmProjects/future/tests, inifile: pytest.ini
    plugins: anyio-3.5.0
    collected 2 items                                                                                                                              
    
    tests/test_fixture.py::test_api[param0] 
    Succeed to connect redis:6379
    PASSED
    Succeed to close redis:6379
    
    tests/test_fixture.py::test_api[param1] 
    Succeed to connect elasticsearch:9200
    PASSED
    Succeed to close elasticsearch:9200
    
    
    =========================================================== 2 passed in 0.01 seconds ===========================================================
    

内置fixture瀞

  • tmpdir
    作用域 function
    用于临时文件和目录管理,默认会在测试结束时删除
    tmpdir.mkdir() 创建目临时目录返回创建的目录句柄,tmpdir.join() 创建临时文件返回创建的文件句柄

    • code
      def test_tmpdir(tmpdir):
          a_dir = tmpdir.mkdir('mytmpdir')
          a_file = a_dir.join('tmpfile.txt')
      
          a_file.write('hello, pytest!')
      
          assert a_file.read() == 'hello, pytest!'
      
  • tmpdir_factory
    作用于所有作用域

    • code
      @pytest.fixture(scope='module')
      def my_tmpdir_factory(tmpdir_factory):
          a_dir = tmpdir_factory.mktemp('mytmpdir')
          a_file = a_dir.join('tmpfile.txt')
      
          a_file.write('hello, pytest!')
      
          return a_file
      
  • pytestconfig
    读取命令行参数和配置文件; 等同于request.config

    • conftest.py定义 pytest_addoption用于添加命令行参数
      def pytest_addoption(parser):
          parser.addoption('--host', action='store',
                           help='host of db')
          parser.addoption('--port', action='store', default='8888',
                           help='port of db')
      
    • test_config.py 定义
      def test_option1(pytestconfig):
          print('host: %s' % pytestconfig.getoption('host'))
          print('port: %s' % pytestconfig.getoption('port'))
      
      
  • capsys
    临时关闭标准输出stdoutstderr

    • code
      import sys
      
      
      def test_stdout(capsys):
          sys.stdout.write("stdout>>")
          sys.stderr.write("stderr>>")
      
          out, err = capsys.readouterr()
      
          print(f"capsys stdout={out}")
          print(f"capsys stderr={err}")
      
    • 输出
      ============================================================= test session starts ==============================================================
      platform darwin -- Python 3.7.3, pytest-4.6.5, py-1.11.0, pluggy-0.13.1 -- /Library/Developer/CommandLineTools/usr/bin/python3
      cachedir: .pytest_cache
      rootdir: /Users/huangxiaonan/PycharmProjects/future/tests, inifile: pytest.ini
      plugins: anyio-3.5.0
      collected 1 item                                                                                                                               
      
      tests/test_assert.py::test_stdout capsys stdout=stdout>>
      capsys stderr=stderr>>
      PASSED
      
      =========================================================== 1 passed in 0.05 seconds ===========================================================
      
      
  • recwarn
    捕获程序中的warnnings告警

    • code (不引入recwarn)

      import warnings
      
      
      def warn():
          warnings.warn('Deprecated function', DeprecationWarning)
      
      
      def test_warn():
          warn()
      
    • 输出 可以看到有warnnings summary 告警

      ============================================================= test session starts ==============================================================
      platform darwin -- Python 3.7.3, pytest-4.6.5, py-1.11.0, pluggy-0.13.1
      rootdir: /Users/huangxiaonan/PycharmProjects/future/tests, inifile: pytest.ini
      plugins: anyio-3.5.0
      collected 1 item                                                                                                                               
      
      tests/test_assert.py .                                                                                                                   [100%]
      
      =============================================================== warnings summary ===============================================================
      test_assert.py::test_warn
        /Users/huangxiaonan/PycharmProjects/future/tests/test_assert.py:5: DeprecationWarning: Deprecated function
          warnings.warn('Deprecated function', DeprecationWarning)
      
      -- Docs: https://docs.pytest.org/en/latest/warnings.html
      ===================================================== 1 passed, 1 warnings in 0.05 seconds =====================================================
      
    • code 引入recwarn

      import warnings
      
      
      def warn():
          warnings.warn('Deprecated function', DeprecationWarning)
      
      
      def test_warn(recwarn):
          warn()
      

      此时将无告警,此时告警对象可在测试用例中通过recwarn.pop()获取到

    • code 以下方式也可以捕获warn

      def test_warn():
          with pytest.warns(None) as warnings:
              warn()
      
  • monkeypatch
    按照理解来说这些函数的作用仅仅是在测试用例的作用域内有效,参见setenv

    • setattr(target, name, value, raising=True),设置属性;
    • delattr(target, name, raising=True),删除属性;
    • setitem(dic, name, value),字典添加元素;
    • delitem(dic, name, raising=True),字典删除元素;
    • setenv(name, value, prepend=None),设置环境变量;
      import os
      
      
      def test_config_monkeypatch(monkeypatch):
          monkeypatch.setenv('HOME', "./")
          import os
          print(f"monkeypatch: env={os.getenv('HOME')}")
      
      
      def test_config_monkeypat():
          import os
          print(f"env={os.getenv('HOME')}")
      
      输出 可以看到monkeypath给环境变量大的补丁只在定义的测试用例内部有效
      ============================================================= test session starts ==============================================================
      platform darwin -- Python 3.7.3, pytest-4.6.5, py-1.11.0, pluggy-0.13.1 -- /Library/Developer/CommandLineTools/usr/bin/python3
      cachedir: .pytest_cache
      rootdir: /Users/huangxiaonan/PycharmProjects/future/tests, inifile: pytest.ini
      plugins: anyio-3.5.0
      collected 2 items                                                                                                                              
      
      tests/test_assert.py::test_config_monkeypatch monkeypatch: env=./
      PASSED
      tests/test_assert.py::test_config_monkeypat env=/Users/huangxiaonan
      PASSED
      
      =========================================================== 2 passed in 0.06 seconds ===========================================================
      
    • delenv(name, raising=True),删除环境变量;
    • syspath_prepend(path),添加系统路径;
    • chdir(path),切换目录。

四、Hooks


五、配置文件⚙️

pytest两类配置文件

  • 1.pytest.ini
  • 2.conftest.py

六、一个完整的测试用例应该是什么样的?樂

 类似资料: