文章目录

  • 前言🥁
  • 一、构造测试用例 🏀
  • 如何构造🤔
  • 如何运行🥱
  • 简单目录结构示例🕸
  • 二、基础用法🐢
  • 使用断言🤨
  • 捕获异常🧐
  • 指定运行测试用例🐗
  • 跳过测试用例 `SKIPPED`💦
  • 预见的错误 `XPASS`😅
  • 参数化💡
  • 三、Fixture🔗
  • 简单范例🔔
  • 预处理和后处理🙈
  • fixture作用域🥅
  • pytest.mark.usefixtures🔗
  • fixture自动化🐝
  • fixture参数化🔧
  • 内置fixture🪛
  • 四、Hooks
  • 五、配置文件⚙️
  • 六、一个完整的测试用例应该是什么样的?🤔


前言🥁

本文从六个部分总结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_fun1test_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

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