写给不常写Python的人的pytest实用教程

先利其器

虚拟环境与pytest配置

首先你需要准备好pycharm,并且安装好python环境。(mac基本都自带python,3.7.8及其以后的版本都可用)。然后按照以下步骤,配置好虚拟环境。

本文档是教程,不会涉及到具体的业务逻辑与代码。但是可能部分知识建议你配合本地调试来理解。因此建议建立一个学习项目,比如我的学习项目叫MyXXX(比如MyPython)。在开始下面的内容前,您只需要建好一个文件夹,然后用pycharm打开它就好了。

本文不涉及python3的语法(除非是一些高级特性对使用pytest有帮助的),不过python语法很简单,照着写也能写,如果想系统学习,我推荐这个教程:www.liaoxuefeng.com/wiki/101695...

此时,这个虚拟环境就创建好了,我们可以看到根目录下多了一个橙红色的venv文件夹。

golang会把所有的包都下载到同一个目录,然后使用包名+版本唯一指定一个包,并在编译时读取go.mod,获取对应的包+版本。而python不同,python的包的目录下,一个包只会有一个文件夹,不会有多版本共存的情况。因此虚拟环境就是帮你创建好一个仅有这个项目可用的,下载第三方包的文件夹。

然后我们再创建一个requirements.txt文件,一个pytest.ini文件

requirements.txt这个文件类似golang的go.mod文件,用来指定依赖与版本的。不过requirements.txt是一个给人看的文件,python在运行时不会检查requirements.txt。

requirements.txt 复制代码
allure-pytest==2.8.6
allure-python-commons==2.8.6
pytest==6.2.3
pytest-assume==2.2.1
pytest-cov==2.8.1
pytest-cover==3.0.0
pytest-coverage==0.0
pytest-dependency==0.5.1
pytest-forked==1.4.0
pytest-pythonpath==0.7.4
pytest-ordering==0.6
pytest-repeat==0.9.1
pytest-rerunfailures==11.0
pytest-xdist==1.30.0
python-dateutil==2.8.2
retry==0.9.2
retrying==1.3.4

这个是pytest的运行配置文件。pytest会在运行目录中自动找到文件,并读取配置。这里面预设了一些配置,能省去我们之后在命令行里敲配置。

ini 复制代码
[pytest]
markers =
    p0: 优先级标 marks tests as p0
    p1: 优先级标 marks tests as p1
    p2: 优先级标 marks tests as p2

python_paths = .
addopts = -v -s --alluredir=reports/ecs --junit-xml=reports/result.xml --import-mode=importlib

python_classes = Test*
python_files = test*
python_functions = test*

# 这里配置一下之后,在allure的界面上,可以看到格式化之后的日志
log_cli = True
log_level = INFO
log_format = %(asctime)s.%(msecs)03d[%(levelname)s]%(pathname)s:%(funcName)s:%(lineno)d: %(message)s
log_date_format = %Y-%m-%d %H:%M:%S
# 虽然pytest自己就可以设置日志相关参数,但是有个大问题,在启用pytest-xdist之后,日志无法输出到控制台
# 这是相关的issue:https://github.com/pytest-dev/pytest-xdist/issues/574
# 因此,我们不得不设置两遍:
# 1. 在pytest侧设置一遍,用于格式化allure中的日志
# 2. 自己用logger.conf来设置一遍logger,

最后两步,我们安装一下依赖,并检查一下

  1. 目前根目录下应该有这些东西(如图左)
  2. 打开pycharm自带的命令行工具,并运行pip3 install -r requirements.txt(如图右)

  1. 接着在命令行里输入pytest --version,确认一下pytest是否安装完成

pycharm的配置

打开preferences,按如下设置,然后重启打开这个项目

如果你之前已经有了一些运行配置,则需要把他们先全删掉

第一个测试

我们创建一个test_my_heart.py文件,编辑这些内容

Python 复制代码
import logging


def my_heart_status():
    return "bad"


def test_my_heart():
    """
    测试一下我的心脏是否在跳动
    """
    logging.info("即将检查我的心脏")
    assert my_heart_status() == "healthy"

在命令行中输入pytest得到这些输出:

是不是神奇,又很简单?我猜你可能有这些疑问:

pytest怎么知道哪些函数是测试?

这里,pytest在目录下递归寻找测试函数,并运行。查找的规则如下:

  1. 寻找以test开头或结尾的py文件
  2. 在第一步中找到的文件中寻找以Test开头的类,并且没有__init__方法(可省略)
  3. 在第一步中找到的文件中寻找以test_开头的函数,或者在第二步中找到的类中寻找以test开头的方法。

assert是什么?

assert是断言,简而言之,assert后面跟一个表达式,并且这个表达式返回bool值。如果bool值是True,则测试通过;如果是False,则测试失败。

注意!在pytest中写测试的时候,一定要使用assert去做测试结果的断言,而不要主动抛出错误(比如raise error)。pytest mock了assert的实现,因此可以捕捉到用例的失败,以及失败时的上下文。注意看下图中pytest主动输出的上下文:

如果主动raise error,则不会有这么清晰的上下文输出。

优雅使用assert

Python 复制代码
import logging


def my_heart_status():
    return "bad"


def test_my_heart():
    """
    测试一下我的心脏是否在跳动
    """
    logging.info("即将检查我的心脏")
    assert my_heart_status() == "healthy", f"检查我的心脏失败,实际结果为{my_heart_status()}"

报错信息对比

生成测试报告

可以注意到,刚刚运行完之后,在根目录多了一个reports文件夹。这个文件夹存放了测试结果,但是这个测试结果不是人类可读的,因此我们需要生成一个人类可读的测试报告。虽然命令行的输出也能看,但是生成的测试报告实在太方便了,因此我强烈建议用网页版的测试报告来查看测试结果。

我们首先安装一下allure:brew install allure。然后运行allure generate reports/ecs -o reports/html --clean。我们就生成了人类可读的html测试报告。然后打开这个html(如图左),并把语言转成中文(如图右)

如果想了解更多关于allure的细节,可以自己摸索与参考这个连接~:qualitysphere.gitee.io/ext/allure/...

类别、测试套、功能、包,这四个title都可以查看到所有的测试用例执行情况。但是我最推荐**"功能"**来查看,因为这个title展示起来最清晰,需要点击的次数最少。下面是在面对几十个测试用例下,几个tab页的区别。

查看失败的原因

回到我们自己的例子上,我们点击功能,点击我们刚刚失败的case(如左图),我们可以看到是哪个case失败了。更进一步,我们点开几个框框,可以看到具体的日志和测试详情。

Mark

docs.pytest.org/en/7.1.x/ho...

打标签

使用pytest的mark能力,我们可以给case打tag,然后按tag来运行不同的case。比如最基础的,区分P0与P1 case。我们来修改一下之前的代码:

Python 复制代码
import logging
import pytest


def my_heart_status():
    return "stopped"


def my_heartbeat():
    return -1


@pytest.mark.p0
def test_my_heart_status():
    """
    测试一下我的心脏是否在跳动
    """
    logging.info("即将检查我的心脏")
    assert my_heart_status() == "running"


@pytest.mark.p1
def test_my_heartbeat():
    """
    测试一下我的心脏跳动是否正常
    """
    logging.info("即将检查我的心脏")
    assert 50 < my_heartbeat() < 180

然后我们运行pytest -m p0,会得到如右图的结果:

可以看到pytest把两个用例都识别出来了,但是只跑了标记了p0的用例,而标记了p1的用例没有跑:

此外,所有的mark需要提前声明。在这个case中,我们在【先利其器】中创建的pytest.ini文件中已经提前声明好了一些mark(如图下一),如果没有提前声明,那么pytest会有报警(如图下二):


暂时不运行

如果某个用例暂时有问题,或者用例写好了,但是功能没写好,我们可以标记先跳过这个用例(如图左)

更进一步,我们可以有条件得跳过某些case,比如我们先看看当前的环境,是否在ICU,如果在的话,就不管心跳了。

同一个用例不同的参数

比如现在有一个查询的测试接口,我们要测试不同查询条件下接口是否符合预期。从最简单的开始:不同的page size:

可以看到,虽然我们只写了一个用例,但是利用pytest.mark.parametrize,我们实现了类似表驱动测试的效果。

下面使用了pytest -k参数。使用细节可以参考FQA-如何只跑一个测试?

我们也可以在一个pytest.mark.parametrize内设置多个参数:

我们还可以让多个参数排列组合,只需要添加多个pytest.mark.parametrize即可:

Fixture

前置依赖

fixture(中文名叫做夹具)是pytest最重要的一块功能,pytest可以通过fixture来指定前置依赖,并且pytest将解析依赖顺序,然后按照顺序一个一个函数执行。如果前置依赖执行失败了,那么后续的操作就自动不会执行。以上是fixture的特点。而我们使用fixture,主要看中其三个功能:

  1. 声明前置依赖(可以在测试报告里看到,而不用看代码了)。
  2. 缓存同一作用域下的前置依赖,并让多个用例共用一个前置依赖(通过复用资源,提高运行速度,减少资源占用)。
  3. 在case运行完之后清理资源。(方便~)

先来一个简单的例子,我们把之前主动调用的my_heart_statusmy_heartbeat改成前置依赖。代码如下:

Python 复制代码
import logging
import pytest


@pytest.fixture()
def my_heart_status():
    return "running"


@pytest.fixture()
def my_heartbeat():
    return 80


@pytest.mark.p0
def test_my_heart_status(my_heart_status):
    """
    测试一下我的心脏是否在跳动
    """
    logging.info("即将检查我的心脏")
    assert my_heart_status == "running"


@pytest.mark.p1
def test_my_heartbeat(my_heartbeat):
    """
    测试一下我的心脏跳动是否正常
    """
    logging.info("即将检查我的心脏")
    assert 50 < my_heartbeat < 180

解释一下,上述代码,通过给函数加上@pytest.fixture这个装饰器,pytest可以收集到所有的fixture。然后pytest会分析每个测试用例的入参,按命名找到对应的fixture,执行fixture,并把fixture的结果传给测试用例。

同时,在我们使用了fixture之后,在测试报告中也能看到测试用例的前置依赖。如果前置依赖失败而导致用例失败,也能在测试中清晰看到:

conftest

刚刚我们把测试用例与fixture写在同一个py文件内,那么这些fixture只能在这个py文件内使用。如果要让fixture能被多个py文件使用,则需要把fixture写到conftest.py文件中。pytest会递归寻找目录下名为conftest.py的文件,并使这些fixture在其子目录都可用。

举个例子:共有三层目录,两个conftest.py文件,三个test.py文件。

Go 复制代码
- conftest.py # 有fixtureA
- test_x.py
- TestOtherDir  # 这是一个文件夹
  - conftest.py. # 有fixtureB
  - test_y.py
  - TestZDir  # 这是一个文件夹
    - test_z.py

上面的例子中,fixtureA可以被三个test.py文件使用。fixtureB只能被test_y.pytest_z.py使用。

让我们新建一个conftest.py文件,并把两个fixture都放进入。

作用域 & 缓存与共用fixture

fixture的一个大用途在于:在同一作用域下多个case共享同一个前置依赖。我们将上述的例子改一下,假设通过一个方法就可以获得heart的所有信息。

Python 复制代码
# 在 conftest.py中
import logging
import pytest
class Heart:
    def __init__(self, status, beat):
        self.status = status
        self.beat = beat


@pytest.fixture()
def my_heart():
    logging.info("获取heart信息")
    return Heart("running", 80)


# 在 test_heart.py中
@pytest.mark.p0
def test_my_heart_status(my_heart):
    """
    测试一下我的心脏是否在跳动
    """
    logging.info("检查心脏状态")
    assert my_heart.status == "running"


@pytest.mark.p1
def test_my_heartbeat(my_heart):
    """
    测试一下我的心脏跳动是否正常
    """
    logging.info("检查心跳")
    assert 50 < my_heart.beat < 180

然后如下图,my_heart函数调用了两次。这是因为每个fixture默认的作用域是function级别的,即每个测试用例都会重新执行一遍这个fixture。

作用域一共有四种:

  • function:每个测试用例都运行一次该fixture(默认)
  • class:class内所有方法只运行一次该fixture(一个类内可以有多个测试方法)
  • module:一个.py文件只执行一次该fixture
  • session:每次调用pytest命令下,只执行一次(跨多个py文件,多个文件夹)

我们将其作用域改为module,然后看看执行结果。可以看到这个fixture只会执行一次。

如果我们将两个测试用例放在两个py文件下,则又会执行两次。

如果我们将两个测试用例放在两个py文件下,但是作用域设置成session,则只会执行一次。

更多的例子可以参考这个文档:blog.csdn.net/Tangerine02...

嵌套

除了case可以通过fixture设置前置依赖,fixture本身也可以设置前置依赖~

Python 复制代码
# 在 conftest.py中
import logging
import pytest

class Heart:
    def __init__(self, status, beat):
        self.status = status
        self.beat = beat

@pytest.fixture()
def prepare():
    return "abc"

@pytest.fixture()
def my_heart(prepare):
    logging.info("获取heart信息" + prepare)
    return Heart("running", 80)

可传参Fixture

我们可以通过把函数调用转为fixture依赖,来获得更加直观的测试报告与前置依赖缓存。但是在函数调用时可以传递参数,那我们怎么给fixture传递参数呢?

接着上面的例子,我们需要检查不同年龄、不同性别的心脏:

在fixture中,需要将第一个参数设置为request,然后可以通过request.param拿到测试用例传来的所有参数。

在测试用例中,用法基本pytest.mark.parametrize相同,只需要添加indirect=True即可。

Python 复制代码
# 在 conftest.py 中
import pytest

class Heart:
    def __init__(self, status, sex, beat):
        self.status = status
        self.sex = sex
        self.beat = beat

@pytest.fixture(scope="module")
def my_heart(request):
    logging.info("获取heart信息")
    return Heart("running", request.param["sex"], request.param["age"] * 5)

# 在 test_my_heart.py 中
import logging
import pytest

@pytest.mark.parametrize("my_heart", [{"sex": "female", "age": 13}, {"sex": "male", "age": 15}], indirect=True) # 注意这里!
def test_my_heartbeat(my_heart):
    """
    测试一下我的心脏跳动是否正常
    """
    logging.info("检查心跳")
    assert 50 < my_heart.beat < 180

可传参Fixture的作用域

前文提到,在同一作用域下的fixture只会被执行一次。那如果我们给fixture传递了不同的参数参数,作用域会发生什么样的变化呢?

接着上面的例子,conftest.py的代码不变,我们修改test_my_heart.py的代码:

Python 复制代码
import logging
import pytest

user1 = {"sex": "male", "age": 15}
user2 = {"sex": "female", "age": 13}

@pytest.mark.parametrize("my_heart", [user1], indirect=True)
def test_my_heart_status(my_heart):
    """
    测试一下我的心脏是否在跳动
    """
    logging.info("检查心脏状态")
    assert my_heart.status == "running"


@pytest.mark.parametrize("my_heart", [user2], indirect=True)
def test_my_heartbeat(my_heart):
    """
    测试一下我的心脏跳动是否正常
    """
    logging.info("检查心跳")
    assert 50 < my_heart.beat < 180

这样运行一遍,我们会发现fixture会执行两次。而如果我们把user2改成user1,则只会运行一遍 。因此,我们可以得出结论:对于可传参的fixture,参数+fixture共同构成唯一一个前置依赖。

有个小问题,Python如何判断两个值是否相等呢?对于Python而言,判断两个值相等有两个操作符,==is==用来判断值是否相等;is用来判断地址是否相等。对Pytest的fixture而言,其使用is来判断是否相等。因此两个相同的dict也会让fixture执行两遍,只有声明一个全局变量,然后两者都使用这个全局变量,才会让fixture只执行一遍。

更多关于Python ==is的区别,可以参考这个讨论:www.zhihu.com/question/20...

虽然@pytest.mark.parametrize可以设置scope(作用域),并且可以覆盖原有fixture的作用域。但是并不推荐这么做,因此这里不展开。

资源清理

比如我们想观测Heart,得先连接一个监听器到Heart上,在我们测试结束之后,再把监听器给卸载掉。首推的做法是使用yield,这个写法更加简单清晰

Python 复制代码
@pytest.fixture(scope="module")
def my_heart(request):
    logging.info("连接监听器")
    logging.info("获取heart信息")

    yield Heart("running", request.param["sex"], request.param["age"] * 5)

    logging.info("卸载监听器")

其次推荐的是使用fixture的request参数,request.addfinalizer()。我们来修改一下之前的my_heart fixture。

Python 复制代码
@pytest.fixture(scope="module")
def my_heart(request):
    logging.info("连接监听器")

    def teardown():  # 这个函数的名字是随意的,也可以叫别的
        logging.info("卸载监听器")

    request.addfinalizer(teardown) # 这一行,注册一个fixture生命周期结束后运行的函数

    logging.info("获取heart信息")
    return Heart("running", request.param["sex"], request.param["age"] * 5)

有两个点需要注意:

第一是两种方式存在细微的逻辑差别,当下图中红色框框内的代码段报错时(raise error),使用addfinalizer会执行teardown的逻辑(前提是addfinalizer先于报错代码段运行),而使用yield则不会。

第二是要注意,写teardown的时候,要考虑红色框框内失败的情况。举一个例子,有一个创建资源的fixture,并且内置删除资源的teardown逻辑。当创建资源失败时,teardown去删除资源,可能会报错NotFound,此时不应该raise error。

执行顺序与清理顺序

首先需要注意的是,由于并发跑测试、每次跑的测试集不同,pytest跑用例的顺序总是没有规律的。因此case与case之间最好不要有任何的顺序依赖关系。如果case之间依赖相同的fixture,最好在每次执行完之后,把这个fixture还原到最开始的状态。

其次,fixture自身的执行顺序和清理顺序是有迹可循的,其按照以下顺序以此执行。

  1. 首先执行autouse=True的fixture
  2. 其次执行某个case依赖的fixture,如果一个case依赖多个fixture,则按顺序,从左到右依次执行。
  3. 其次执行fixture依赖的fixture,如果一个fixture依赖多个fixture,则按顺序,从左到右依次执行,并按DFS(深度优先)递归解析依赖。

而对于清理操作,则会按上述顺序反着来,即最先被执行的fixture,最后被清理。

我们新起一个py文件来看看,左侧展示了DFS的规则,右侧展示autouse的影响。对于这个case,大家可以自行修改代码并执行,体会一下依赖关系。

Python 复制代码
# test_order.py
import pytest

@pytest.fixture()
def a():
    print("准备AAA")
    yield "a"
    print("清理AAA")

@pytest.fixture()
def b():
    print("准备BBB")
    yield "b"
    print("清理BBB")

@pytest.fixture()
def c(a, b):
    print("准备CCC")
    yield "c"
    print("清理CCC")

def test_order_1(c):
    pass

准备AAA
准备BBB
准备CCC
PASSED
清理CCC
清理BBB
清理AAA
# test_order.py
import pytest

@pytest.fixture()
def a():
    print("准备AAA")
    yield "a"
    print("清理AAA")

@pytest.fixture(autouse=True)
def b():
    print("准备BBB")
    yield "b"
    print("清理BBB")

@pytest.fixture()
def c(a, b):
    print("准备CCC")
    yield "c"
    print("清理CCC")

def test_order_1(c):
    pass

准备BBB
准备AAA
准备CCC
PASSED
清理CCC
清理AAA
清理BBB

FAQ

如何只跑一个测试?

有三种方式

一种是直接点击pycharm的这个箭头,就可以。如果点击后有些问题,请参考【先利其器】一章节的内容设置一下pycharm

一种是直接在命令行中调用pytest -k XXX,XXX是你的测试函数或者测试类。如果你的测试函数名与其他地方的测试函数名重合,则可以加上路径,比如pytest FFF -k XXX。其中FFF是你所要运行的用例的路径,精确到文件夹或者文件都可以。

一种是不使用-k,直接pytest XXX,XXX的格式可以参考下图

某个fixture找不到

python的函数很容易被同名的变量、或者其他函数覆盖。有一个很诡异的例子:

Python 复制代码
# 在 conftest.py 中
from a_fixture import *
from a import *

# 在 a_fixture.py 中
@pytest.fixture
def aaa():
    pass

# 在 a.py 中
aaa = None

在上述例子中,运行pytest --fixtures是找不到aaa这个fixture的。但是如果把两个import的顺序替换一下,就又能看到了。

只运行上次失败的case

在运行完之后,运行结果会保存在reports目录下。我们可以运行pytest --lf来只运行上次失败的case。

allure的报告很混乱

因为每次运行的结果都会保存在reports目录下,所以reports的东西会越来越多,让allure生成的报告也越来越复杂。因此过一段时间可以把reports目录全部删掉,这样报告就清晰了。

单个case下的日志文件是按什么规则收集的

通常而言,一个case执行了什么(包括前置依赖、自身的逻辑、资源清理的日志),都会在一个case的日志中。

但是,如果多个case依赖了同一个fixture,那么这个fixture的创建日志只会出现在其第一次被执行的case的日志中,后续的case就没有这部分日志了。

allure报告中的黄颜色的item是什么意思?

意思是代码发生了panic(raise error)。通常是代码没有做错误处理导致的。

pytest 多线程插件 pytest-parallel 不能和测试报告插件 allure-pytest兼容的问题

版本pytest-multithreading-allure-1.0.5,使用:requirement.txt

解决办法:testerhome.com/topics/3274...

相关问题:github.com/allure-fram...

参考文档

docs.pytest.org/en/7.1.x/in...

blog.csdn.net/tangerine02...

blog.csdn.net/totorobig/a...

qualitysphere.gitee.io/ext/allure/

相关推荐
程序员爱钓鱼几秒前
Go语言实战案例-创建模型并自动迁移
后端·google·go
javachen__6 分钟前
SpringBoot整合P6Spy实现全链路SQL监控
spring boot·后端·sql
uzong6 小时前
技术故障复盘模版
后端
GetcharZp6 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
桦说编程6 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研6 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi7 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
阿华的代码王国8 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
Jimmy8 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
AntBlack8 小时前
不当韭菜V1.1 :增强能力 ,辅助构建自己的交易规则
后端·python·pyqt