一. Pytest默认测试用例的规则以及基础应用

1.规则

  1. 模块名必须以test_开头或者_test结尾
  2. 测试类名必须以Test开头,而且不能带有init方法
  3. 测试用例必须以test_开头

2.执行方式

  1. 通过命令行方式执行:pytest
    执行的参数:
         pytest  -vs     -v:输出详细信息    -s:输出调试信息
         pytest  -vs  -n     多线程运行(前提安装插件:pytest-xdist)
         pytest  -vs  - -reruns=2     失败重跑(前提安装插件:pytest-rerunfailres)
         pytest  -vs  -x     出现一个用例失败则停止测试
         pytest  -vs  - -maxfail=2     出现几个失败才终止
         pytest  -vs  - -html     生成html的简易测试报告(前提是安装插件:pytest-html)
         pytest  -vs  -k “get_token”     运行测试用例名称中包含某个字符串的测试用例(get_token是用例的类名)
  2. 通过主函数main的方式执行
1
2
if __name__ == '__main__':
pytest.main(["-vs"])
  1. 通过全局配置文件pytest.ini执行
    注意:
         1.一般放在项目的根目录下,名称必须是pytest.ini
         2.配置文件中当有中文时需要改变编码格式为GB2312
         3.pytest.ini文件可以改变默认的测试用例规则
         4.不管是命令行运行还是主函数运行,都会加载这个配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[pytest]
# 参数 其中 -m "smoke" 表示只执行被标记的smoke用例,结合下面的[标记]使用
addopts = -vs -m "smoke"
# 指定执行测试用例的文件夹位置
testpaths = ./test_case
# 执行指定名称开头的测试用例(如下,只执行文件名为ms开头的py文件测试用例)
python_files = ms_*.py
# 执行指定测试类的测试用例(如下,只执行类名为Test开头的测试用例)
python_classes = Test*
# 执行指定函数的测试用例(如下,只执行函数为test开头的测试用例)
python_functions = test_*

# 标记(在函数上方使用@pytest.mark.smoke或@pytest.mark.user_manage来标记用例,可添加多个标记)
markers =
smoke:冒烟测试
user_manage:用例管理模块
...
...

3.pytest跳过测试用例

  1. 无条件跳过
1
2
3
@pytest.mark.skip(reason="无理由跳过")
def test_get_token(self):
print('测试1')
  1. 有条件跳过
1
2
3
4
workage = 8
@pytest.mark.skipif(workage<10,reason="工作经验少于10年跳过")
def test_get_token3(self):
print("测试2")

4.pytest测试用例的前后置,固件

1
2
3
4
5
6
7
8
9
10
11
12
# 可在每个类中使用,但是代码会比较冗余
def setup_method(self):
print("每个用例之前执行一次")

def teardown_method(self):
print("每个用例之后执行一次")

def setup_class(self):
print("每个类之前执行一次")

def teardown_class(self):
print('每个类之后执行一次')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 也可以将他们封装到一个类中,让其他类继承,但是继承的类中所有的测试用例都会添加前后置,在只有一个用例需要前后置时(部分前后置),此方法不适合,则需要用到fixture
# common/common.util.py
class TestQH:
def setup_method(self):
print("每个用例之前执行一次")

def teardown_method(self):
print("每个用例之后执行一次")

def setup_class(self):
print("每个类之前执行一次")

def teardown_class(self):
print('每个类之后执行一次')

# test_case/test_case.py
from common.common_util import TestQH
class TestDH(TestQH):
def test_1(self):
print("测试111")

def test_2(self):
print("测试222")

5.使用fixtrue实现部分前后置

   @pytest.fixture(scope=None,autouse=False,params=None,ids=None,name=None)

  • 参数:

    1. scope: 作用域
         scope=“function”   在函数之前和之后执行
              1.手动调用的方式是在测试用例的参数里面加入fixtrue的名称
              2.如果说fixtrue有通过return或yield返回值的话,那么可以把这个值传递到测试用例中,并且值是通过固件的名称传递的

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      import pytest

      @pytest.fixture(scope="function") # function:函数
      def exe_database_sql():
      print("执行sql查询语句")
      yield # yield之后是后置
      print('关闭数据库')

      class TestDH:
      def test_1(self):
      print("测试111")

      def test_2(self,exe_database_sql): # 在函数的参数中直接调用
      print("测试222")

         scope=“class”   在类之前和之后执行
              1.手动调用的方式是在类的上面加上@pytest.mark.usefixtures(“exe_database_sql”)装饰器调用

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      import pytest

      @pytest.fixture(scope="class",autouse=False)
      def exe_database_sql():
      print("执行sql查询语句")
      yield
      print('关闭数据库')

      @pytest.mark.usefixtures("exe_database_sql") # 在此装饰器参数中写入fixture名称调用,在这个类之前和之后执行
      class Testjiao:
      def test_1(self):
      print('第1个类')

         scope=“package/session”   在整个项目会话之前和之后执行
              1.一般会结合conftest.py文件来实现

    2. autouse: 自动执行,默认是False

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import pytest

    @pytest.fixture(scope="function",autouse=True) # 自动在每个函数前后执行
    def exe_database_sql():
    print("执行sql查询语句")
    yield # yield之后是后置
    print('关闭数据库')

    class TestDH:
    def test_1(self):
    print("测试111")

    def test_2(self):
    print("测试222")
    1. params:实现参数化
          1.如何把值传到fixtrue? 通过在fixtrue函数的参数里面加入request来接受参数,然后通过request.param来取值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import pytest

    # 读取数据的方法
    def read_yaml():
    return ['qq','wx','wb']

    @pytest.fixture(scope="function",autouse=False,params=read_yaml()) # 使用params传入读取的list数据
    def exe_database_sql(request): # 加入requests接受参数
    print("执行sql查询语句")
    yield request.param # 使用 request.param 返回参数值
    print('关闭数据库')

    class TestDH:
    def test_1(self):
    print("测试111")

    def test_2(self,exe_database_sql): # 手动使用fixture
    print(exe_database_sql) # 打印传递后的参数值
    print("测试222")

    1. ids:不能单独使用,必须和params一起使用,作用是对参数起别名(一般不使用)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import pytest

    # 读取数据的方法
    def read_yaml():
    return ['qq','wx','wb']

    @pytest.fixture(scope="function",autouse=False,params=read_yaml(),ids=['1','2','3']) # 将读取到的数据换个参数名称
    def exe_database_sql(request):
    print("执行sql查询语句")
    yield request.param
    print('关闭数据库')


    class TestDH:
    def test_1(self):
    print("测试111")

    def test_2(self,exe_database_sql):
    print(exe_database_sql)
    print("测试222")
    1. name:给fixtrue起别名(一般没用)
          1.一旦使用了别名,那么fixtrue的名称就不能再使用了,只能使用别名
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
     import pytest

    # 读取数据的方法
    def read_yaml():
    return ['qq','wx','wb']

    @pytest.fixture(scope="function",autouse=False,params=read_yaml(),ids=['1','2','3'],name="db") # 修改fixtrue别名为db
    def exe_database_sql(request):
    print("执行sql查询语句")
    yield request.param
    print('关闭数据库')


    class TestDH:
    def test_1(self):
    print("测试111")

    def test_2(self,db): # 此时这里调用的fixtrue别名就只能调用db
    print(db)
    print("测试222")

如果希望在另一个py文件中调用fixtrue需要结合到contest.py文件中使用

二. fixtrue结合conftest.py使用

  1. conftest.py:它是专门用于存放fixtrue的配置文件,名称是固定的,不能变
  2. 在conftest.py文件里面所有的方法在调用时都不需要导包
  3. conftest.py文件可以有多个,并且多个conftest.py文件里面的多个fixtrue可以被一个用例调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 # conftest.py
import pytest

@pytest.fixture(scope="function",autouse=False,name="db")
def exe_database_sql(request):
print("执行sql查询语句")
yield "success"
print('关闭数据库')

# test_api.py
class TestDH:
def test_1(self,db): # 无需导入包,直接调用fixtrue
print("测试111")

def test_2(self,db):
print("测试222")
  1. setup_method,teardown_method,setup_class,teardown_class,fixtrue,conftest优先级
  • 会话:fixtrue的session级别优先级最高
  • 类:fixtrue的class级别优先级别最高
  • 类:setup_class
  • 函数:fixtrue的function级别优先级别最高
  • 函数:setup_method

三. 总结:pytest执行过程

  1. 查询当前目录下的conftest.py文件
  2. 查询当前目录下的pytest.ini文件,找到测试用例的位置
  3. 查询用例目录下的conftest.py文件
  4. 查询py文件中是否有setup_method,teardown_method,setup_class,teardown_class
  5. 再根据pytest.ini文件的测试用例的规则去查找用例并执行

四. pytest的断言

  1. 就是使用python自己的断言,assert
  • assert flag is true
  • assert 1==1
  • assert “a” in “abc”

五. pytest结合allure-pytest插件生成美观的报告

  1. 安装allure-pytest插件
  2. 下载allure,下载之后解压,解压之后配置环境变量(把allure目录下的bin目录配置到系统变量的path路径),下载地址:https://github.com/allure-framework/allure2/releases
  3. 验证allure是否安装成功:allure --version ,在pycharm验证
  4. 生成allure报告
  • a.生成临时的json报告,在pytest.ini文件里面加入:addopts = -vs --alluredir=./temps --clean-alluredir(--alluredir=./temps:生成临时报告 --clean-alluredir:清空临时报告
  • b.生成正式的allure报告:在run.py添加参数:os.system("allure generate ./temps -o ./reports --clean")
1
2
3
4
5
6
import os
import pytest

if __name__ == '__main__':
pytest.main()
os.system("allure generate ./temps -o ./reports --clean")

六. pytest通过parametrize()实现数据驱动

  1. 方法:@pytest.mark.parametrize(args_name,args_value)
        args_name:参数名称,用于将参数值传递给函数
        args_value:参数值:(列表和字典列表,元组和字典元组),有n个值那么用例执行n次
1
2
3
@pytest.mark.parametrize("caseinfo",['孙悟空','猪八戒','唐僧'])
def test_01_get_token(self,caseinfo): # 传入的参数名称必须与上面一致
print('获取统一接口鉴权码' + caseinfo)
1
2
3
@pytest.mark.parametrize("name,age",[['name','孙悟空'],['age','18']])
def test_01_get_token(self,name,age):
print('获取统一接口鉴权码' + str(name)+ " " + str(age))

七. pytest通过yaml格式实现测试用例读、写、封装

  1. yaml是一种数据格式,扩展名可以使yaml、yml,支持#注释,通过缩进表示层级关系,区分大小写。yaml读取出来是一个字典列表格式
  • 用途:
        1.用于做配置文件
        2.编写自动化测试用例
  • 数据组成:
        1.map对象,键(空格)
    1
    name: 孙悟空
        2.数组(list),使用’-'表示列表
    1
    2
    3
    4
    5
    6
    msjy:
    - name1: 孙悟空
    - name2:
    - age1: 18
    - age2: 19
    - name3: 玉皇大帝
  1. 封装读取yaml文件代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # get_token.yaml   yaml文件数据
    - name: 获取统一接口鉴权码
    request:
    method: post
    url: http://127.0.0.1:5566/overloadcontrolapi/api/v1/login
    headers:
    Authorization: authorization_token
    data:
    userName: donghao
    password: 123456
    validate:
    status_code: 200
    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
    # yaml.util.py   封装读取yaml
    import os.path
    import yaml

    # 获取项目的根目录
    def get_obj_path():
    return os.path.dirname(__file__).split('common')[0]

    # 读取yaml,需要安装 pip install pyyaml
    def read_yaml(yamlpath):
    with open(get_obj_path() + yamlpath ,mode='r',encoding='utf-8') as f:
    value = yaml.load(stream=f,Loader=yaml.FullLoader)
    return value

    # 下面代码仅做演示测试使用,实际无需下面代码,直接调用上面代码即可
    if __name__ == '__main__':
    # 可在其他调用此文件的地方传入yaml文件路径
    print(read_yaml('test_case/get_token.yaml'))


    # test_get_token.py # 用例读取yaml数据
    import pytest
    import requests
    from common.yaml_util import read_yaml

    class TestApi:
    # 自动关联cookis
    session = requests.session()
    @pytest.mark.parametrize("caseinfo",read_yaml("test_case/get_token.yaml"))
    def test_01_get_token(self,caseinfo):
    name = caseinfo['name']
    method = caseinfo['request']['method']
    url = caseinfo['request']['url']
    data = caseinfo['request']['data']
    headers = caseinfo['request']['headers']
    validate = caseinfo['validate']['status_code']
    res = TestApi.session.request(method=method,url=url,json=data,headers=headers)
    all_dict = res.json()
    authorization_token = all_dict['data']['token']
    assert res.status_code == validate

八. 统一接口请求封装

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
# requests_util.py  接口请求封装
import requests

class RequestsUtil:
session = requests.Session()
def send_request(self,method,url,data,**kwargs):
method = str(method).lower()
res = ""
if method == "get":
res = RequestsUtil.session.request(method,url,params=data, **kwargs)
elif method == "post":
res = RequestsUtil.session.post(method,url,json=data,**kwargs)
return res


# test_get_token.py 用例中使用封装的接口请求
import pytest
from common.requests_util import RequestsUtil
from common.yaml_util import read_yaml

class TestApi:
@pytest.mark.parametrize("caseinfo",read_yaml("test_case/get_token.yaml"))
def test_01_get_token(self,caseinfo):
name = caseinfo['name']
method = caseinfo['request']['method']
url = caseinfo['request']['url']
data = caseinfo['request']['data']
headers = caseinfo['request']['headers']
validate = caseinfo['validate']['status_code']
res = RequestsUtil.session.request(method=method,url=url,json=data,headers=headers) # 使用封装的接口请求
all_dict = res.json()
authorization_token = all_dict['data']['token']
assert res.status_code == validate
assert authorization_token is not None and authorization_token != ''

九. Allure测试报告定制

  1. @allure.epic("电商系统")    代表整个产品 / 项目 / 系统层级,通常对应公司的一条产品线或一个完整系统
  2. @allure.feature("用户管理")    epic下的子模块,对应产品的核心功能模块(比如用户管理、订单管理、商品管理)
  3. @allure.story("用户登录")    feature下的具体业务场景(最小功能单元),对应产品需求中的一个具体场景。“用户登录” 是 “用户管理” 模块下的一个具体场景。
  4. 用例标题
        - 静态装饰器:@allure.title("测试用户登录功能")    在测试用例加载阶段(代码解析时)就确定了标题,是固定不变的。它的值在定义测试函数时就已经确定,无法在测试用例执行过程中修改。
        - 动态装饰器:allure.dynamic.title(casename)    在测试用例执行过程中生效,可以根据测试逻辑、参数、执行结果等动态修改标题,灵活性更高。
  5. @allure.link("https://httpbin.org/post",name="需求文档")  通用链接:关联任意外部链接(需求文档、设计文档、接口文档等)。name是链接的展示名称(报告中显示),第一个参数是实际链接地址。
  6. @allure.issue("BUG-123","登录失败问题")    缺陷 / BUG 链接:专门关联 bug 管理系统(如 Jira、Bugzilla)的 BUG。第一个参数是 BUG 编号 / BUG 地址,name是 BUG 的简要描述。
  7. @allure.testcase("TC-456","测试用例链接")    官方测试用例链接:专门关联测试管理系统(如 TestLink、Zephyr、禅道)的官方用例。用于追溯手工测试用例或官方用例库,方便自动化用例和官方用例对应。
  8. @allure.severity(allure.severity_level.CRITICAL)    用例优先级 / 严重级别:标记用例的重要程度,Allure 内置 5 个级别(从高到低):
    ✅ CRITICAL(致命):核心功能(如支付、登录)
    ✅ BLOCKER(阻塞):阻塞后续测试的问题
    ✅ NORMAL(普通):常规功能(默认级别)
    ✅ MINOR(次要):界面样式、小优化
    ✅ TRIVIAL(轻微):文字错别字等
    报告中会显示优先级标签(如🔴CRITICAL),可按级别筛选用例
  9. 用例失败自动截图+日志记录
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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# log_util.py 封装日志
import os
import sys
import logging
from logging.handlers import BaseRotatingHandler
from typing import Optional, Dict, Any, List
import allure
from datetime import datetime

# ========== 基础配置 ==========
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 根日志目录
ROOT_LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
# 各级别专属文件夹(key=级别值,value=文件夹名)
LEVEL_DIR_MAP = {
logging.DEBUG: "debug_log",
logging.INFO: "info_log",
logging.WARNING: "warning_log",
logging.ERROR: "error_log",
logging.CRITICAL: "critical_log"
}


# 确保所有级别文件夹存在
def _ensure_level_dirs():
# 创建根日志目录
if not os.path.exists(ROOT_LOG_DIR):
os.makedirs(ROOT_LOG_DIR, exist_ok=True)
# 为每个级别创建专属文件夹
for level_dir in LEVEL_DIR_MAP.values():
level_dir_path = os.path.join(ROOT_LOG_DIR, level_dir)
if not os.path.exists(level_dir_path):
os.makedirs(level_dir_path, exist_ok=True)


# ========== 1. Allure日志处理器 ==========
class AllureLogHandler(logging.Handler):
"""同步日志到Allure报告"""

def emit(self, record):
try:
if record.levelno < logging.INFO:
return
log_msg = self.format(record)
allure.attach(
log_msg,
name=f"[{record.levelname}] {record.filename}:{record.lineno}",
attachment_type=allure.attachment_type.TEXT
)
except Exception:
pass


# ========== 2. 核心:按级别+日期+文件夹存储的处理器 ==========
class LevelDirDailyFileHandler(BaseRotatingHandler):
"""
按「级别文件夹+日期」存储日志
例如:logs/info_log/info_2026-02-27.log、logs/error_log/error_2026-02-27.log
"""

def __init__(self, target_level, encoding='utf-8', backupCount=7):
# 获取当前级别对应的文件夹
self.target_level = target_level
self.target_level_name = logging.getLevelName(target_level).lower()
self.level_dir_name = LEVEL_DIR_MAP[target_level]
self.level_dir_path = os.path.join(ROOT_LOG_DIR, self.level_dir_name)

self.backupCount = backupCount
self.current_date = self._get_today()
# 生成「级别文件夹/级别_日期.log」的完整路径
self.current_filename = os.path.join(
self.level_dir_path,
f"{self.target_level_name}_{self.current_date}.log"
)
# 初始化父类(追加模式写入)
super().__init__(self.current_filename, 'a', encoding=encoding)

def _get_today(self):
"""获取当天日期(YYYY-MM-DD)"""
return datetime.now().strftime("%Y-%m-%d")

def shouldRollover(self, record):
"""仅跨天时切换文件"""
today = self._get_today()
if today != self.current_date:
return True
return False

def emit(self, record):
"""严格过滤:仅写入指定级别的日志"""
if record.levelno != self.target_level:
return
super().emit(record)

def doRollover(self):
"""跨天切换文件,清理当前级别文件夹下的过期日志"""
# 关闭当前文件
if self.stream:
self.stream.close()
self.stream = None

# 更新日期和文件名
self.current_date = self._get_today()
self.current_filename = os.path.join(
self.level_dir_path,
f"{self.target_level_name}_{self.current_date}.log"
)

# 清理当前级别文件夹下超过保留天数的日志
self._clean_old_logs()

# 打开新文件
self.stream = self._open()

def _clean_old_logs(self):
"""清理当前级别文件夹下的过期日志"""
try:
# 筛选当前级别文件夹下的日志文件:如 info_*.log
log_files = [
f for f in os.listdir(self.level_dir_path)
if f.startswith(f"{self.target_level_name}_") and f.endswith('.log')
]
# 按日期排序
log_files.sort(
key=lambda x: datetime.strptime(
x.replace(f"{self.target_level_name}_", "").replace(".log", ""),
"%Y-%m-%d"
)
)
# 删除超过保留天数的文件
if len(log_files) > self.backupCount:
for old_file in log_files[:-self.backupCount]:
os.remove(os.path.join(self.level_dir_path, old_file))
except Exception as e:
print(f"清理{self.target_level_name}级别过期日志失败: {e}", file=sys.stderr)


# ========== 3. 核心日志类 ==========
class Logger:
_instance: Optional['Logger'] = None
_logger: Optional[logging.Logger] = None

# 单例模式
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_logger()
return cls._instance

def _init_logger(self):
if Logger._logger is not None:
return

# 确保所有级别文件夹存在
_ensure_level_dirs()

# 基础日志器配置
Logger._logger = logging.getLogger("LeopardTestLogger")
Logger._logger.setLevel(logging.DEBUG) # 总级别设为最低
Logger._logger.propagate = False

# 统一日志格式
formatter = logging.Formatter(
'[%(asctime)s] [%(process)d-%(threadName)s] [%(filename)s:%(lineno)d] - %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)

# ========== 为每个级别创建独立处理器(按文件夹存储) ========== backupCount为日志保存天数
# 1. DEBUG级别:logs/debug_log/debug_2026-02-27.log
debug_handler = LevelDirDailyFileHandler(logging.DEBUG, backupCount=7)
debug_handler.setFormatter(formatter)

# 2. INFO级别:logs/info_log/info_2026-02-27.log
info_handler = LevelDirDailyFileHandler(logging.INFO, backupCount=7)
info_handler.setFormatter(formatter)

# 3. WARNING级别:logs/warning_log/warning_2026-02-27.log
warning_handler = LevelDirDailyFileHandler(logging.WARNING, backupCount=7)
warning_handler.setFormatter(formatter)

# 4. ERROR级别:logs/error_log/error_2026-02-27.log
error_handler = LevelDirDailyFileHandler(logging.ERROR, backupCount=7)
error_handler.setFormatter(formatter)

# 5. CRITICAL级别:logs/critical_log/critical_2026-02-27.log
critical_handler = LevelDirDailyFileHandler(logging.CRITICAL, backupCount=7)
critical_handler.setFormatter(formatter)

# 6. 彩色控制台处理器(所有级别都输出)
class ColoredStreamHandler(logging.StreamHandler):
COLORS = {
logging.DEBUG: '\033[0;36m', # 青色
logging.INFO: '\033[0;32m', # 绿色
logging.WARNING: '\033[0;33m', # 黄色
logging.ERROR: '\033[0;31m', # 红色
logging.CRITICAL: '\033[0;35m' # 紫色
}
RESET = '\033[0m'

def format(self, record):
msg = super().format(record)
color = self.COLORS.get(record.levelno, self.RESET)
return f"{color}{msg}{self.RESET}"

console_handler = ColoredStreamHandler(stream=sys.stdout)
console_handler.setLevel(logging.DEBUG)
console_handler.setFormatter(formatter)

# 7. Allure报告处理器
allure_handler = AllureLogHandler()
allure_handler.setFormatter(formatter)

# ========== 添加所有处理器 ==========
if not Logger._logger.handlers:
Logger._logger.addHandler(debug_handler)
Logger._logger.addHandler(info_handler)
Logger._logger.addHandler(warning_handler)
Logger._logger.addHandler(error_handler)
Logger._logger.addHandler(critical_handler)
Logger._logger.addHandler(console_handler)
Logger._logger.addHandler(allure_handler)

# ========== 封装日志方法 ==========
def debug(self, message: str) -> None:
"""DEBUG → logs/debug_log/debug_2026-02-27.log"""
try:
self._logger.debug(message.strip())
except Exception as e:
print(f"[日志错误] 记录DEBUG日志失败: {str(e)}", file=sys.stderr)

def info(self, message: str) -> None:
"""INFO → logs/info_log/info_2026-02-27.log"""
try:
self._logger.info(message.strip())
except Exception as e:
print(f"[日志错误] 记录INFO日志失败: {str(e)}", file=sys.stderr)

def warning(self, message: str) -> None:
"""WARNING → logs/warning_log/warning_2026-02-27.log"""
try:
self._logger.warning(message.strip())
except Exception as e:
print(f"[日志错误] 记录WARNING日志失败: {str(e)}", file=sys.stderr)

def error(self, message: str, exc_info: bool = False) -> None:
"""ERROR → logs/error_log/error_2026-02-27.log"""
try:
self._logger.error(message.strip(), exc_info=exc_info)
except Exception as e:
print(f"[日志错误] 记录ERROR日志失败: {str(e)}", file=sys.stderr)

def critical(self, message: str, exc_info: bool = True) -> None:
"""CRITICAL → logs/critical_log/critical_2026-02-27.log"""
try:
self._logger.critical(message.strip(), exc_info=exc_info)
except Exception as e:
print(f"[日志错误] 记录CRITICAL日志失败: {str(e)}", file=sys.stderr)

def api_logs(
self,
url: str,
data: Dict[str, Any],
codes: int,
responses: Dict[str, Any],
data_name: str,
data_list: List[Any]
) -> None:
"""接口日志 → logs/info_log/info_2026-02-27.log"""
log_msg = (
"\n" + "=" * 80 +
f"\n【请求URL】:{url}"
f"\n【请求参数】:{data}"
f"\n【返回状态码】:{codes}"
f"\n【返回结果】:{responses}"
f"\n【数据库验证字段】:{data_name}"
f"\n【数据库查询数据】:{data_list}"
+ "\n" + "=" * 80 + "\n"
)
self.info(log_msg)


# 全局实例
log = Logger()

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
50
51
52
53
54
55
56
57
58
59
# conftest.py  pytest 框架的专属配置文件
import pytest
import allure
from selenium import webdriver

# 全局driver变量初始化
driver = None

@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
# 在函数最外层声明使用全局driver(赋值前必须声明)
global driver
# 执行其他钩子获取报告对象
outcome = yield
rep = outcome.get_result()

# 只要用例失败(无论setup/call/teardown阶段),就触发截图
if rep.failed:
current_driver = None # 临时变量存储要截图的driver,避免覆盖全局变量
try:
# 优先从fixture参数中获取driver(推荐方式)
current_driver = item.funcargs["browser"]
except KeyError:
# 备用方案:使用全局driver变量
current_driver = driver

# 增加异常捕获,避免截图失败导致用例额外报错
if current_driver and hasattr(current_driver, "get_screenshot_as_png"):
try:
# 优化截图名称:替换特殊字符(避免allure报告显示异常)
clean_nodeid = item.nodeid.replace("/", "_").replace("::", "_")
screenshot_name = f"{clean_nodeid}_{rep.when}_失败截图"
# 附加截图到allure报告
allure.attach(
current_driver.get_screenshot_as_png(),
name=screenshot_name,
attachment_type=allure.attachment_type.PNG
)
except Exception as e:
print(f"【截图失败】用例:{item.nodeid},原因:{str(e)}")


@pytest.fixture(scope="session")
def browser():
global driver
if driver is None:
# 可选:添加ChromeOptions配置(解决版本兼容、无头模式等问题)
options = webdriver.ChromeOptions()
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
driver = webdriver.Chrome(options=options)
driver.maximize_window()
driver.implicitly_wait(10)
yield driver
# 所有用例执行完毕退出浏览器(增加异常捕获,避免退出失败)
try:
driver.quit()
except Exception as e:
print(f"【浏览器退出失败】原因:{str(e)}")
1
2
3
4
5
6
7
# test_a_login_logout.py  # 测试用例
from leopardv5_Test.page.log_util import log

log = Logger()
def test_01_login():
log.info("日志内容" ) # 这样也能同时将日志写入报告中