当前位置:   article > 正文

python+pytest+selenium+PO+allure+DDT实现web自动化测试_python+selenium+pytest+po

python+selenium+pytest+po

前言

python:编程语言

pytest:独立的、全功能的python单元测试框架

selenium:用于web应用程序测试的工具

allure:测试报告展示

ddt:数据驱动

一、前置条件

1. 安装python开发环境

1.1 python解释器
3.10版本

1.2 pycharm集成开发环境
社区版

2.下载览器驱动

下载浏览器驱动,浏览器驱动版本要与浏览器版本一致。

下载地址:

Chrome:http://npm.taobao.org/mirrors/chromedriver/
Firefox:https://github.com/mozilla/geckodriver/releases
Edge:https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/
Safari:https://webkit.org/blog/6900/webdriver-support-in-safari-10/

  • 浏览器驱动加到python环境变量中: path
  • 或者chromedriver.exe与python.exe放在同一个目录
  • 运行时也可以指定Chromedriver的路径
    本项目中将驱动chromedriver.exe放到工程的driver目录下-固定目录。

注:MCX调度台 查看浏览器驱动的版本

ctrl + F12  调出element窗口
点击Console  输入 'navigator.userAgent'  注意区分大小写
  • 1
  • 2

3.安装allure

1)下载allure,下载网址 https://github.com/allure-framework/allure2/releases
2)解压到电脑本地(此目录后续使用,不要动),配置环境变量(把allure目录下的bin目录配置到系统环境变量中的path路径)
参考博客《allure报告框架介绍》https://blog.csdn.net/weixin_59868574/article/details/135502984。

4. 下载第三方库

工程新建requirements.txt,输入以下内容,之后安装requirements.txt文件,或单个安装

pytest== 7.4.2
selenium== 3.141.0
urllib3==1.26.18
PySocks~=1.7.1
PyYAML~=6.0.1
psutil~=5.9.7
allure-pytest~=2.13.2

注意:urllib3库与selenium版本之间的兼容性。

二、测试框架整体目录

web测试框架目录结构

三、测试工具类utils

3.1 管理时间timer.py

包括时间戳,日期等其他模块会使用的字符串,将时间封装成一个单独的模块,让其他模块来调用。

在 utils目录新建timer.py模块

"""
@File    : timer.py
@Author  : Sarah
@Date    : 2023/7/10 
@Desc    :时间字符串管理
"""
import time
import datetime
from functools import wraps


def timestamp():
    """时间戳"""
    return time.time()


def dt_strftime(fmt="%Y%m"):
    """
    datetime格式化时间
    :param fmt “%Y%m%d %H%M%S
    """
    return datetime.datetime.now().strftime(fmt)


def sleep(seconds=1.0):
    """睡眠时间"""
    time.sleep(seconds)


def running_time(func):
    """函数运行时间"""

    @wraps(func)
    def wrapper(*args, **kwargs):
        start = timestamp()
        res = func(*args, **kwargs)
        print("校验元素done!用时%.3f秒!" % (timestamp() - start))
        return res

    return wrapper


if __name__ == '__main__':
    print(dt_strftime("%Y%m%d %H%M%S"))
  • 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
'
运行

3.2 日志管理logger.py

测试脚本需要记录运行日志,所以将日志管理封装成一个模块,用于被其他模块调用。
在utils目录中新建logger.py模块,代码如下:

"""
@File    : logger.py
@Author  : Sarah
@Date    : 2023/7/10
@Desc    :日志管理模块
"""

import logging
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
from config.config import cm


class Log:
    def __init__(self, seg):

        self.seg = seg

        # 创建logger
        self.logger = logging.getLogger()
        if not self.logger.handlers:
            self.logger.setLevel(logging.DEBUG)

            # 定义日志文件大小为10M
            max_bytes = 10 * 1024 * 1024
            # 定义保留的文件数量为50个
            backup_count = 50

            # 创建日志文件处理器,备份文件命名规则默认为添加后缀数字,数字越大时间越早
            if self.seg == "time":
                fh = TimedRotatingFileHandler(cm.log_file, when='D', backupCount=backup_count, encoding='utf-8')
                """
                :param:when
                        'S':每秒切分日志文件
                        'M':每分钟切分日志文件
                        'H':每小时切分日志文件
                        'D':每天切分日志文件(默认值)
                        'W0' ~ 'W6':每星期切分日志文件(0表示星期一,1表示星期二,以此类推)
                        'midnight':每天午夜切分日志文件 与'D'相同
                :param:interval  默认1,1D就是一天
                """
                fh.setLevel(logging.INFO)
            else:
                fh = RotatingFileHandler(cm.log_file, maxBytes=max_bytes, backupCount=backup_count, encoding='utf-8')
                fh.setLevel(logging.INFO)

            # 创建一个handle输出到控制台
            ch = logging.StreamHandler()
            ch.setLevel(logging.INFO)

            # 定义输出的格式
            formatter = logging.Formatter(self.fmt)
            fh.setFormatter(formatter)
            ch.setFormatter(formatter)

            # 添加到handle
            self.logger.addHandler(fh)
            self.logger.addHandler(ch)

    @property
    def fmt(self):
        return '%(levelname)s\t%(asctime)s\t[%(filename)s:%(lineno)d]\t%(message)s'

# 使用时间分割log文件,可以修改when参数,建议在性能测试脚本中使用
# log = Log('time').logger
# 使用大小分割log文件
log = Log('size').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
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66

四、配置文件夹config

4.1 公共配置config.py

将所有的文件目录,文件位置,封装后被其他模块调用,如果新增文件或新增目录,在这里添加方法。
在config目录下新建模块config.py,代码如下:

"""
@File    : config.py
@Author  : Sarah
@Date    : 2023/7/10
@Desc    :工程的文件目录,文件位置
"""

import os
from utils.timer import dt_strftime


class ConfigManager:
    """
    管理项目目录,文件
    """

    DIR_CURR = os.path.abspath(__file__)

    DIR_CONF = os.path.dirname(os.path.abspath(__file__))

    # 项目路径
    DIR_BASE = os.path.dirname(DIR_CONF)

    DIR_COMM = os.path.join(DIR_BASE, "common")

    DIR_UTIL = os.path.join(DIR_BASE, "utils")

    # 页面元素和页面对象路径
    DIR_PAGE = os.path.join(DIR_BASE, 'page_object')

    # 页面元素
    DIR_ELEMENT = os.path.join(DIR_BASE, "page_element")

    DIR_DRIVER = os.path.join(DIR_BASE, "driver")

    @property
    def web_ini_file(self):
        """ web 配置文件"""
        ini_file = os.path.join(self.DIR_CONF, 'web_cfg.ini')
        if not os.path.exists(ini_file):
            raise FileNotFoundError("配置文件%s不存在!" % ini_file)
        return ini_file

    @property
    def dir_report_json(self):
        """allure报告目录 json文件"""
        report_dir = os.path.join(self.DIR_BASE, 'report')
        os.makedirs(report_dir, exist_ok=True)
        json_dir = os.path.join(report_dir, 'json')
        os.makedirs(json_dir, exist_ok=True)
        return json_dir

    @property
    def dir_report_html(self):
        """allure报告目录 html文件"""
        report_dir = os.path.join(self.DIR_BASE, 'report')
        os.makedirs(report_dir, exist_ok=True)
        html_dir = os.path.join(report_dir, 'html')
        os.makedirs(html_dir, exist_ok=True)
        return html_dir

    @property
    def dir_log(self):
        """日志目录"""
        log_dir = os.path.join(self.DIR_BASE, 'logs')
        os.makedirs(log_dir, exist_ok=True)
        return log_dir

    @property
    def log_file(self):
        """日志文件"""
        return os.path.join(self.dir_log, '{}.log'.format(dt_strftime()))

    @property
    def dir_img(self):
        """截图目录"""
        img_dir = os.path.join(self.dir_log, 'images')
        os.makedirs(img_dir, exist_ok=True)
        return img_dir

    @property
    def img_file(self):
        """截图文件"""
        return os.path.join(self.dir_img, '{}.png'.format(dt_strftime('%Y%m%d%H%M%S')))

    @property
    def dir_testdata(self):
        """测试数据目录"""
        test_data_dir = os.path.join(self.DIR_BASE, 'testdata')
        os.makedirs(test_data_dir, exist_ok=True)
        return test_data_dir

    @property
    def dir_web_testdata(self):
        """测试数据目录-web"""
        test_data_dir = os.path.join(self.dir_testdata, 'web')
        os.makedirs(test_data_dir, exist_ok=True)
        return test_data_dir

    @property
    def test_web_yaml_file(self):
        """ 测试数据文件.yaml """
        yaml_file = os.path.join(self.dir_web_testdata, 'test_data.yaml')
        if not os.path.exists(yaml_file):
            raise FileNotFoundError("测试数据文件%s不存在!" % yaml_file)
        return yaml_file

    @property
    def test_web_json_file(self):
        """ 测试数据文件.json """
        json_file = os.path.join(self.dir_web_testdata, 'test_data.json')
        if not os.path.exists(json_file):
            raise FileNotFoundError("测试数据文件%s不存在!" % json_file)
        return json_file

    @property
    def web_driver(self):
        """浏览器驱动"""
        os.makedirs(self.DIR_DRIVER, exist_ok=True)
        driver = os.path.join(self.DIR_DRIVER, 'chromedriver.exe')
        if not os.path.exists(driver):
            raise FileNotFoundError("浏览器驱动%s不存在!" % driver)
        return driver


cm = ConfigManager()
  • 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

4.2 web配置文件web_cfg.ini

在config目录下新建web_cfg.ini(文件名与config.py中封装的要一致), 用于存放web测试的参数,举例如下:

# web_cfg.ini
[host]
HOST = http://IP/index.action 
[driver] 
# 驱动进程名称
DRIVER_PROCESS = chromedriver.exe
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

五、测试公共库common

5.1 公共方法common.py

pytest框架的前后置,运行时自动调用。
在common目录下新建模块common.py,这里的前后置仅为log打印,查阅log比较方便,代码如下:

"""
@File    :common.py
@Author  : Sarah
@Date    : 2023/7/10
@Desc    :用例执行前后置,pytest框架
"""
from utils.logger import log


class Common:

    @staticmethod
    def setup_class():
        log.info('-------module start')

    @staticmethod
    def teardown_class():
        log.info('-------module end')

    @staticmethod
    def teardown_method():
        log.info('-------case end')
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

5.2. 读取配置文件read_cfg.py

配置文件使用ini格式,封装读取ini的方法。
在common目录下新建文件read_cfg.py,代码如下:

"""
@File    :read_cfg.py
@Author  : Sarah
@Date    : 2023/7/10
@Desc    :封装读取ini配置文件
"""

import configparser
from config.config import cm


# 根据实际定义
HOST = 'host'
DRIVER = 'driver'

class ReadConfig:
    """ini配置文件"""

    def __init__(self, ini_file):
        self.file = ini_file
        self.config = configparser.RawConfigParser()  # 当有%的符号时使用Raw读取 不对配置文件中的值进行解析
        self.config.read(self.file, encoding='utf-8')

    def _get(self, section, option):
        """获取"""
        return self.config.get(section, option)

    def _set(self, section, option, value):
        """更新"""
        self.config.set(section, option, value)
        with open(cm.ini_file, 'w') as f:
            self.config.write(f)

    @property
    def web_url(self):
        return self._get(HOST, 'HOST')

    @property
    def web_driver_process(self):
        return self._get(DRIVER, 'DRIVER_PROCESS')

web_cfg = ReadConfig(cm.web_ini_file)
  • 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

涉及配置和数据驱动的后续添加。

六、PO模式封装

按照POM模式设计,将自动化设计的页面或模块封装成对象,以便代码复用,便于维护(如果元素信息发生变化了,不用修改测试用例脚本)。

6.1.结构

  • base(基类):page页面的一些公共方法
  • page(页面):一个页面封装成一个对象
  • element(元素):页面元素,本项目使用.py来存放页面元素,写代码方便
  • testcase(测试用例):存放测试用例

6.2 基类page_base

在page_base目录下新建page_base.py文件,代码如下:

"""
@File    :page_base.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :Base类:存放所有Page页面公共操作方法
"""
from selenium.webdriver.support.select import Select
from selenium.webdriver.support.wait import WebDriverWait
from utils.logger import log
from utils.timer import sleep
from selenium.webdriver.common.action_chains import ActionChains


class Base:
    def __init__(self, driver):
        self.driver = driver

    def base_find(self, loc, timeout=30, poll=0.5):
        """
        查找元素
        timeout:超时的时长,一般30S,超时未找到报错
        poll:检测间隔时长,默认0.5s,如果有一闪而逝的提示信息框,修改为0.1s
        """
        return WebDriverWait(self.driver, timeout, poll).until(lambda x: x.find_element(*loc))

    def base_input(self, loc, text):
        """输入文本"""
        el = self.base_find(loc)
        el.clear()
        if text is not None:
            el.send_keys(text)
        log.info(f"{el}输入文本:{text}")

    def base_click(self, loc):
        """点击"""
        self.base_find(loc).click()
        sleep()
        log.info(f'点击按钮:{loc}')

    def base_get_text(self, loc):
        """获取当前元素文本"""
        _text = self.base_find(loc).text
        log.info(f"获取文本:{_text}")
        return _text

    def base_get_title(self):
        """获取当前页title"""
        title = self.driver.title
        log.info(f"当前页面:{title}")
        return title

    def base_alert_confirm(self):
        """自动确认弹框 以便继续进行后续的测试操作"""
        self.driver.switchTo().alert().accept()

    def base_is_dis(self, loc):
        """查看元素是否可见"""
        state = self.base_find(loc).is_displayed()
        log.info(f"获取元素可见状态:{state}")
        return state

    def base_keep_press(self, loc, time):
        """保持长按"""
        ActionChains(self.driver).click_and_hold(self.base_find(loc)).perform()
        log.info(f"长按:{loc}")
        sleep(time)
        ActionChains(self.driver).release(self.base_find(loc)).perform()
        log.info(f"释放:{loc}")

    def base_select(self, loc, text):
        """
        下拉框选择
        :param loc: select标签元素,父类, 不是option
        :param text: 通过显示文本选择
        """
        Select(self.base_find(loc)).select_by_visible_text(text)
        log.info(f"选择下拉框{loc}{text}")

    def base_tick_checkbox(self, loc, num):
        """勾选框"""
        checkbox_list = self.base_find(loc)
        action = ActionChains(self.driver)
        for i in range(0, num):
            action.move_to_element(checkbox_list[i])
            action.click(checkbox_list[i]).perform()
            sleep()

    def base_invisible_element(self, loc, num, stop):
        """对动态变化元素执行动作链"""
        msg = self.base_find(loc)
        for i in range(0, num):
            action = ActionChains(self.driver)
            action.move_to_element(msg[i])
            action.click(msg[i])
            action.perform()
            sleep(stop)

    def base_refresh(self):
        """刷新页面F5"""
        self.driver.refresh()

    def base_quit(self):
        """退出浏览器"""
        self.driver.quit()
  • 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

6.3. 页面元素page_element

在page_element目录下新建el_xxx.py文件,每个py文件存储一个页面的元素信息。
比如新建el_login.py,存放login页面的元素信息,代码如下:

"""
@File    :el_login.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :登录界面的元素信息
"""

from selenium.webdriver.common.by import By

byid = By.ID
byname = By.NAME
bycname = By.CLASS_NAME
bytname = By.TAG_NAME
bylink = By.LINK_TEXT
bycss = By.CSS_SELECTOR
byxpath = By.XPATH

# 登录界面元素配置信息
login_window = byxpath, '//*[@id="login"]/div/div[1]/div'
login_username = byxpath, '//*[@placeholder="用户名"]'
login_pwd = byid, 'password'
login_err_info = bycss, '.el-message__content'
login_btn = byxpath, '//*[text()="登录"]'
logout_btn = byxpath, '//*[@id="login"]/div/div[2]/div/form/div[4]/div/span[3]'
logout_cancel = byxpath, '/html/body/div[2]/div/div[3]/button[1]'
logout_confirm = byxpath, '/html/body/div[2]/div/div[3]/button[2]'

  • 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
'
运行

6.4 页面对象

在page_object目录下新建page_xxx.py文件,每个py文件存储一个页面的信息。
比如新建page_login.py,存放login页面的封装信息,代码如下:

"""
@File    :page_login.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :登录界面封装
"""

import allure

from utils.timer import sleep
from page_base.page_base import Base
from page_element import el_login


class PageLogin(Base):
    """登录页面"""

    def page_input_username(self, username):
        """输入登录用户名"""
        self.base_input(el_login.login_username, username)
        sleep()

    def page_input_passwd(self, password):
        """输入密码"""
        self.base_input(el_login.login_pwd, password)
        sleep()

    def page_input_verify_code(self, verify_code):
        """输入验证码"""
        pass

    def page_click_login_btn(self):
        """点击登录按钮"""
        self.base_click(el_login.login_btn)
        sleep()

    def page_get_error_info(self):
        """获取异常提示信息"""
        return self.base_get_text(el_login.login_err_info)

    def page_click_err_btn_ok(self):
        pass

    def page_click_quit_btn(self):
        """点击退出按钮"""
        self.base_click(el_login.login_btn)
        sleep()

    def page_click_quit_cancel(self):
        """取消退出"""
        self.base_click(el_login.logout_cancel)
        sleep()

    def page_click_quit_confirm(self):
        """确定退出"""
        self.base_click(el_login.logout_confirm)
        sleep()

    def page_login(self, username, password):
        sleep(2)
        with allure.step(f"输入用户名: {username}"):
            self.page_input_username(username)
        with allure.step(f"输入密码: {password}"):
            self.page_input_passwd(password)
        sleep(5)
        with allure.step("点击登录按钮"):
            self.page_click_login_btn()

  • 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

6.5 测试用例testcases目录结构

testcases目录下新建python包用户存储不同模块的测试用例,比如建立web文件夹和app文件夹,分别用户存储web测试用例和app测试用例。
web目录下新建test_login,test_group_call ……python包。
testcases目录结构如果不需要也可以不划分testcases下的目录结构,划分目录结构除了查阅清晰之外,还为了方便conftest.py文件的自动运行。

6.6. conftest.py

在目录testcases下新建conftest.py文件,当中编写所有测试用例的前后置。
conftest.py的说明参考《pytest框架介绍》。https://blog.csdn.net/weixin_59868574/article/details/135500904。
conftest.py中包含了web驱动,allure报告获取失败用例截图,打印用例名称,代码如下:

"""
@File    :testcases\conftest.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :web测试用例的前置
"""


import pytest
import psutil
import allure
from selenium import webdriver
from utils.timer import dt_strftime
from utils.logger import log
from common.read_cfg import web_cfg
from config.config import cm


web_driver = None


@allure.title("浏览器驱动")
@pytest.fixture(scope='session', autouse=True)
def drivers(request):

    global web_driver

    if web_driver is None:
        option = webdriver.ChromeOptions()
        option.binary_location = r'D:\HBFEC\AcroDCYJ\现场应急调度平台.exe'  # 根据实际目录修改
        web_driver = webdriver.Chrome(executable_path=cm.web_driver, options=option)

    @allure.step("退出浏览器驱动")
    def fn():
        web_driver.quit()

    @pytest.fixture(scope='function', autouse=True)
    def close_quit(self):
        process_name = web_cfg.web_driver_process
        # 查找所有的Chrome驱动进程
        process_list = [process for process in psutil.process_iter() if process.name() == process_name]
        if len(process_list) > 0:
            # 如果有多个Chrome驱动程序正在运行,则杀死所有的Chrome驱动程序
            for process in process_list:
                process.kill()
            log.info('存在Chrome驱动程序,并且已杀死所有Chrome驱动程序')
        else:
            log.info('没有Chrome驱动程序正在运行')

    @pytest.fixture(scope='function', autouse=True)
    def reconnect(self):
        process_name = web_cfg.web_driver_process
        # 查找所有的Chrome驱动进程
        process_list = [process for process in psutil.process_iter() if process.name() == process_name]
        if len(process_list) > 0:
            # 如果有多个Chrome驱动程序正在运行,则杀死所有的Chrome驱动程序
            if process_list[0] != process_list[1]:
                fn()
        else:
            log.info('没有Chrome驱动发生重启')

    request.addfinalizer(fn)
    with allure.step("返回浏览器驱动"):
        return web_driver


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    # 后置,获取测试结果
    outcome = yield
    reps = outcome.get_result()

    if reps.when == 'call' and reps.failed:
        # 在测试失败时进行截图, 添加到allure报告中
        img = web_driver.get_screenshot_as_png()
        # 为截图文件命名
        name = '_'.join([reps.nodeid.replace('testcases/', '').replace('::', '_'), dt_strftime('%Y%m%d %H%M%S')])
        allure.attach(img, name=name, attachment_type=allure.attachment_type.PNG)


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_call(item):
    # 记录正在运行的用例
    called = item.nodeid.replace('testcases/', '').replace('::', '_')
    log.info('case:%s', called)
    yield

  • 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

6.7 测试用例test_xxx.py

在testcases或testcases的子目录下新建test_xxx.py文件,编写测试用例,调用封装的页面的方法。
举例:
在testcases下新建test_login.py代码如下:

"""
@File    :test_login.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :登录模块测试用例
"""

import allure
import pytest
from common.read_yaml import login_para
from utils.logger import log
from common.common import Common
from page_object.login import PageLogin


@allure.feature("登录功能")
class TestLogin(Common):
    """登录测试 """

    @allure.story("登录失败")
    @allure.title("登录检测-{login_case}")
    @allure.description("测试登录时的各种异常情况")
    @allure.severity("minor")  
    @pytest.mark.parametrize("login_case", login_para)
    def test_login(self, drivers, login_case):
        username = login_case['username']
        password = login_case['password']
        expect_text = login_case['expect']
        log.info(f"username:{username}")
        log.info(f"password: {password}")
        log.info(f"expect text: {expect_text}")

        PageLogin(drivers).page_login(username, password)
        with allure.step("捕获提示信息"):
            error_info = PageLogin(drivers).page_get_error_info()
        with allure.step("开始断言"):
            assert expect_text in error_info

    @allure.story("登录成功")
    @allure.title("登录成功")
    @allure.description("输入正确合法的用户名和密码登录成功")
    @allure.severity("critical")  
    def test_login_suc(self, drivers):
        """需要增加测试步骤"""
        log.info(drivers.title)
        log.info("输入正确的用户名密码登录成功")

  • 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

当中涉及数据驱动parametrize和allure报告,参考第七第八章节。

七(1)、数据驱动json

测试数据格式没有统一的要求,可以使用json格式。
json格式返回值可以是单参数或多参数,本章节介绍多参数的参数化。

7.1. 测试数据test_data.json

在testdata目录下新建测试数据文件test_data.json,举例如下:

# test_data.json
{
  "login": [
    {
      "desc": "登录失败(用户不存在)",
      "username": "700001",
      "password": "700001@mcx",
      "expect": "登录注册失败"
    },
    {
      "desc": "登录失败(密码为空)",
      "username": "700002",
      "password": "",
      "expect": "失败"
    },
    {
      "desc": "登录失败",
      "username": "700003",
      "password": "700003",
      "expect": "失败"
    }
  ],
    "group_call": [
    {
      "desc": "组呼成功",
      "group_id": "80001",
      "time": "10",
      "expect": "呼叫成功"
    },
    {
      "desc": "组呼成功",
      "group_id": "80002",
      "time": "10",
      "expect": "呼叫成功"
    },
    {
      "desc": "组呼成功",
      "group_id": "80003",
      "time": "10",
      "expect": "呼叫成功"
    }
  ]
}

  • 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
'
运行

7.2 读取json文件read_json.py(元祖列表)

在common目录下新建文件read_json.py,读取测试数据,供测试用例使用,代码如下:

"""
@File    :read_json.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :读取json格式的测试数据
"""

import json

from config.config import cm


class ReadJson:

    def __init__(self, json_file):
        self.json_file = json_file

    def read_json(self, key):
        """ 返回元祖列表 """
        arr = []  
        with open(self.json_file, "r", encoding="utf-8") as f:
            datas = json.load(f,)
            value = datas.get(key)
            for data in value:
                arr.append(tuple(data.values())[1:])
            return arr


json_para = ReadJson(cm.test_json_file)
# 读取json文件中的对应模块,每个模块一个实例
login_para = json_para.read_json('login')
group_call_para = json_para.read_json('group_call')

# 得到元祖列表格式如下:
# [('700001', '700001', '登录成功'), ('700002', '', '登录失败,密码不能为空'), ('700003', '700003', '登录成功')]
  • 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

7.3. 测试用例test_xxx.py(多参数的参数化)

上述读取json数据封装方法返回值为元祖列表,使用多参数驱动的方式。
举例:
在testcases目录下新建test_login.py文件,代码如下:

"""
@File    :test_login.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :登录模块测试用例
"""

import allure
import pytest
from common.read_json import login_para
from utils.logger import log
from common.common import Common
from utils.timer import sleep
from page_object.page_login import PageLogin


@allure.feature("登录功能")
class TestLogin(Common):
    """登录测试 """

    @allure.story("登录失败")
    @allure.title("登录异常检测-{username}-{password}-{expect_text}") 
    @allure.description("测试登录时的各种异常情况")
    @allure.severity("minor")
    @pytest.mark.parametrize("username, password, expect_text", login_para)
    # 多参数的参数化,这样写的话参数可以直接使用,但在parametrize与测试函数的形参中需要列出所有的参数,并且参数的顺序必须一致
    def test_login(self, drivers, username, password, expect_text):
        PageLogin(drivers).page_login(username, password)
        with allure.step("捕获提示信息"):
            error_info = PageLogin(drivers).page_get_error_info()
        with allure.step("开始断言"):
            assert expect_text in error_inf
  • 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

七(2)、数据驱动yaml

测试数据格式没有统一要求,可以使用yaml格式。
本章节介绍单参数的参数化和多参数的参数化。

7.1 测试数据test_data.yaml

在testdata目录下新建测试数据文件test_data.yaml,举例如下:

login:
  - case1: 登录成功1
    username: 70001
    password: 7001@mcx
    expect: 失败
  - case2: 登录成功2
    username: 70002
    password:
    expect: 密码
  - case3: 登录成功3
    username: 70003
    password: 70003
    expect: 密码

login2:
- case1: 登录成功1
  expect: 失败
  password: 7001@mcx
  username: 70001
- case2: 登录成功2
  expect: 密码
  password: null
  username: 70002


  • 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

7.2. 读取yaml文件read_yaml.py 单参数-字典列表

在common目录下新建文件read_yaml.py,读取测试数据,供测试用例使用,代码如下:

"""
@File    :read_yaml.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :读取yaml格式的测试数据,返回的是字典列表
"""

import yaml

from config.config import cm


class ReadYaml:

    def __init__(self, yaml_file):
        self.yaml_file = yaml_file

    def read_yaml(self, key):
        with open(self.yaml_file, 'r', encoding='utf-8') as f:
            datas = yaml.load(f, Loader=yaml.FullLoader)  # 避免报警告,需要加入Loader=yaml.FullLoader
            value = datas[key]  
            return value

yaml_para = ReadYaml(cm.test_yaml_file)
login_para = yaml_para.read_yaml('login')
"""
返回字典列表 
[{'case1': '登录成功1', 'username': 70001, 'password': '7001@mcx', 'expect': '失败'}, 
{'case2': '登录成功2', 'username': 70002, 'password': None, 'expect': '密码'}, 
{'case3': '登录成功3', 'username': 70003, 'password': 70003, 'expect': '密码', 'desc': '登录失败(用户不存在)'}]
"""
  • 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

7.3 测试用例test_xxx.py-单参数参数化

上述读取yaml数据封装方法返回值为字典列表,使用单参数驱动的方式。
举例:
在testcases目录下新建test_login.py文件,代码如下:

"""
@File    :test_login.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :登录模块测试用例
"""

import allure
import pytest
from common.read_yaml import login_para
from utils.logger import log
from common.common import Common
from utils.timer import sleep
from page_object.page_login import PageLogin


@allure.feature("登录功能")
class TestLogin(Common):
    """登录测试 """

    @allure.story("登录失败")
    @allure.title("登录检测-{login_case}")
    @allure.description("测试登录时的各种异常情况")
    @allure.severity("minor")
    @pytest.mark.parametrize("login_case", login_para)
    def test_login(self, drivers, login_case):
        username = login_case['username']
        password = login_case['password']
        expect_text = login_case['expect']
        log.info(f"username:{username}")
        log.info(f"password: {password}")
        log.info(f"expect text: {expect_text}")

        PageLogin(drivers).page_login(username, password)
        with allure.step("捕获提示信息"):
            error_info = PageLogin(drivers).page_get_error_info()
        with allure.step("开始断言"):
            assert expect_text in error_info
  • 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

7.4 读取yaml文件read_yaml.py 多参数-元祖列表

在common目录下新建read_yaml.py,返回值为元祖列表,与read_json返回值相同,代码如下:

"""
@File    :read_yaml.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :读取yaml格式的测试数据
"""

import yaml

from config.config import cm


class ReadYaml:

    def __init__(self, yaml_file):
        self.yaml_file = yaml_file

    def read_yaml(self, key):
        arr = [] 
        with open(self.yaml_file, 'r', encoding='utf-8') as f:
            datas = yaml.load(f, Loader=yaml.FullLoader)  # 避免报警告,需要加入Loader=yaml.FullLoader
            value = datas[key]
            for data in value:
                arr.append(tuple(data.values())[1:])
            return arr

yaml_para = ReadYaml(cm.test_yaml_file)
login_para = yaml_para.read_yaml('login')
  • 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

7.5 测试用例test_xxx.py-多参数参数化

与7.3章节一致,只需要修改
from common.read_json import login_para

from common.read_yaml import login_para

7.6 对比单参数与多参数化

1. 装饰器@pytest.mark.parametrize

单参数,使用一个参数名,传入变量

@pytest.mark.parametrize("login_case", login_para)
  • 1

多参数,使用多个参数名,并且需要保证参数的顺序与变量log_para中值的顺序一致。

@pytest.mark.parametrize("username, password, expect_text",  login_para)
  • 1

2. 函数def test_login参数
单参数,使用参数化parametrize中对应的一个参数名

def test_login(self, drivers, login_case):
  • 1

多参数,使用参数化parametrize中对应的多个参数名

def test_login(self, drivers, username, password, expect_text):
  • 1

3. allure装饰器@allure.title
想要在allure中展示参数化的信息,需要将参数化信息放到title中。
单参数 ,使用参数化parametrize中的一个参数名

@allure.title("登录检测-{login_case}")
  • 1

多参数,使用参数化parametrize中对应的多个参数名

@allure.title("登录异常检测-{username}-{password}-{expect_text}")
  • 1

4. 参数的使用
单参数,需要在函数中读取每个参数的value,并且由于log不展示参数,需要打印参数到log。

        username = login_case['username']
        password = login_case['password']
        expect_text = login_case['expect']
        log.info(f"username:{username}")
        log.info(f"password: {password}")
        log.info(f"expect text: {expect_text}")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

多参数,直接使用形参,不需要读取或打印。

5. log打印
两种方式在打印测试用例名称上有所区别。
单参数,截取相关打印信息如下:

case:test_login.py_TestLogin_test_login[login_case0]
username:70001
password: 7001@mcx
expect text: 失败
case:test_login.py_TestLogin_test_login[login_case1]
username:70002
password: None
expect text: 密码

log中的测试用例名称为:函数名+参数名+编号,编号从0开始。
所以建议使用这种方式的话用log把对应的参数打印出来,方便调试。

多参数,截取相关打印信息如下:

case:test_login.py_TestLogin_test_login[700001-700001@mcx-登录注册失败]
case:test_login.py_TestLogin_test_login[700002–失败]
case:test_login.py_TestLogin_test_login[700003-700003-失败]

log中用例名称显示为函数名+参数值,可以不用将参数打印到log。

6. allure展示
单参数展示,测试用例标题不规则,参数不易读,如下图:
单参数allure报告失败用例截图的名称不显示参数详情,如下图:
单参数失败用例截图

多参数展示,测试用例标题相对规则,参数详情清晰易读,如下图所示:
多参数allure报告多参数失败用例截图展示测试参数,如下图所示:
多参数失败截图

根据上述对比选择合适的测试数据格式和参数化方式实现数据驱动。

八、allure报告

8.1. 报告内容 allure_des

在allure_des目录下新建allure_des.py文件,用于创建报告的环境信息,生成器信息和统计信息,代码如下:

"""
@File    :allure_des.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :allure报告的描述信息,每次运行重新创建
"""

import json
import os
import platform
import pytest
from config.config import cm


def set_report_env_on_results():
    """
    在json报告的目录下生成一个写入环境信息的文件:environment.properties(注意:不能放置中文,否则会出现乱码)
    """
    # 需要写入的环境信息,根据实际工程填写
    allure_env = {
        'OperatingEnvironment': 'mcx DC',
        'PythonVersion': platform.python_version(),
        'PytestVersion': pytest.__version__,
        'Platform': platform.platform(),
        'selenium': '3.141.0',
        'Browser': 'Chrome',
        'BrowserVersion': "59.0.3071.115",
        'DiverVersion': "2.32 MCX-driver"
    }

    allure_env_file = os.path.join(cm.dir_report_json, 'environment.properties')
    with open(allure_env_file, 'w', encoding='utf-8') as f:
        for _k, _v in allure_env.items():
            f.write(f'{_k}={_v}\n')


def set_report_executer_on_results(): 
    """
    在json报告的目录下生成一个写入执行人的文件:executor.json
    """
    # 需要写入的运行信息
    allure_executor = {
        "name": "张三",
        "type": "jenkins",
        "url": "http://helloqa.com",  # allure报告的地址
        "buildOrder": 3,
        "buildName": "allure-report_deploy#1",
        "buildUrl": "http://helloqa.com#1",
        "reportUrl": "http://helloqa.com#1/AllureReport",
        "reportName": "张三 Allure Report"
    }
    allure_env_file = os.path.join(cm.dir_report_json, 'executor.json')
    with open(allure_env_file, 'w', encoding='utf-8') as f:
        f.write(str(json.dumps(allure_executor, ensure_ascii=False, indent=4)))


def set_report_categories_on_results():
    """
    在json报告的目录下生成一个写入统计类型的文件:categories.json
    """
    # 需要写入的运行信息
    allure_categories = [
          {
            "name": "Ignored tests",
            "matchedStatuses": ["skipped"]
          },
          {
            "name": "Infrastructure problems",
            "matchedStatuses": ["broken", "failed"],
            "messageRegex": ".*bye-bye.*"
          },
          {
            "name": "Outdated tests",
            "matchedStatuses": ["broken"],
            "traceRegex": ".*FileNotFoundException.*"
          },
          {
            "name": "Product defects",
            "matchedStatuses": ["failed"]
          },
          {
            "name": "Test defects",
            "matchedStatuses": ["broken"]
          }
        ]

    allure_cat_file = os.path.join(cm.dir_report_json, 'categories.json')
    with open(allure_cat_file, 'w', encoding='utf-8') as f:
        f.write(str(json.dumps(allure_categories, ensure_ascii=False, indent=4)))
  • 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

8.2. 测试报告report

在report目录下分别建立json目录和html目录,allure报告首先生成json文件,包括allure_des生成的json文件,之后再生成html报告,命令在runtest.py的run()函数中。
生成的报告显示如下:
9

九、pytest运行

9.1. pytest.ini
在工程目录下新建pytest.ini文件,用于保存用例执行参数,内容如下:

# pytest.ini
[pytest]
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True
addopts = -vs --clean-alluredir
testpaths = ./testcases
python_files = test*.py
python_classes = Test*
python_functions = test_*
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

9.2. runtest.py

在工程目录下新建runtest.py文件,作为测试用例执行的main,代码如下:

"""
@File    :runtest.py
@Author  :sarah
@Date    :2023-07-21
@Desc    :测试用例执行的入口
"""

import os
import pytest

from allure_des.allure_des import set_report_env_on_results, \
    set_report_executer_on_results, \
    set_report_categories_on_results
from config.config import cm
from utils.timer import sleep


def run():
    pytest.main(['--allure-stories=登录成功,登录失败', '--alluredir=%s' % cm.dir_report_json])
    # 在json目录下创建categories.json文件
    set_report_categories_on_results()
    # 在json目录下创建environment.properties文件
    set_report_env_on_results()
    # 在json目录下创建executor.json文件
    set_report_executer_on_results()
    sleep(3)
    os.system("allure generate %s -o %s --clean" % (cm.dir_report_json, cm.dir_report_html))


if __name__ == '__main__':
    run()
  • 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

……结束。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/知新_RL/article/detail/799394
推荐阅读
相关标签
  

闽ICP备14008679号