Skip to main content

更友好、更灵活的编写、管理与运行测试,生成更加美观的独立单文件HTML报告。内置参数化测试数据存取方案,省去设计的烦恼,节省更多的时间,从而更快的投入到编写用例阶段

Project description

stest

更友好、更灵活的编写、管理与运行测试,生成更加美观的独立单文件 HTML 报告。内置参数化测试数据存取方案,省去设计的烦恼,节省更多的时间,从而更快的投入到编写用例阶段。

功能特性

类别 特性
用例管理 命名测试方法(与 docstring 不冲突)、设置执行顺序、用例依赖
参数化 参数化测试、数据驱动测试、内置 Excel 数据存取方案(SevenDataProvider)
测试报告 简洁美观的独立单文件 HTML 报告、Jenkins JUnit XML 格式报告
截图 测试失败自动截图、截图附加到报告、快速添加截图到报告
配置 自动查找并载入项目 settings.py 配置文件、灵活控制截图与报告行为
Page Object 内置 Selenium/Appium/Playwright/Minium/WinAppDriver 的 Page Object 实现方案
驱动管理 DRIVER_MANAGER 统一管理驱动会话,支持多实例切换

安装

# pip 方式安装
pip install stest

# 源码方式安装(需以管理员方式执行)
python setup.py install

执行测试

# 执行指定测试文件,生成 HTML 报告
python -m stest -v -html D:\temp\tms_apitest.html calculation_test.py

# 发现并执行指定目录下的测试
python -m stest discover -s erp_autotest\testcases -p *.py

# 查看命令行帮助
python -m stest -h

代码中调用 stest.main() 执行:

#!/usr/bin/env python
# -*- encoding: utf-8 -*-

import stest
from stest import AbstractTestCase
from stest import Test as testcase


def get_testdatas(test_class_name, test_method_name, *args, **kwargs):
    return [[1, 2, 3], [3, 4, 7]]


class Demo1Test(AbstractTestCase):

    @testcase(priority=1, enabled=True, data_provider=get_testdatas, author='思文伟', name='两数加法测试01')
    def integer_addition_02(self, number_1, number_2, expected):
        result = number_1 + number_2
        self.assertEqual(result, expected)


if __name__ == '__main__':
    stest.main()

快速开始

  1. 导入 AbstractTestCaseTest 装饰器
  2. 编写继承自 AbstractTestCase 的测试类,使用 @Test 装饰器标记测试方法
  3. 调用 stest.main()TestClassName.run_test() 执行测试

AbstractTestCase 提供以下实用方法:

方法 说明
collect_testcases() 获取类下所有 @Test 装饰且 enabled=True,按 priority 排序后的用例列表
build_self_suite() 构建该类测试用例构成的测试套件
run_test() 执行该类所有 @Test 装饰且 enabled=True,按 priority 排序后的用例

简单示例

#!/usr/bin/env python
# -*- encoding: utf-8 -*-

from stest import AbstractTestCase
from stest import Test as testcase


def get_testdatas(test_class_name, test_method_name, *args, **kwargs):
    return [[1, 2, 3], [3, 4, 7]]


class Demo1Test(AbstractTestCase):

    @testcase(priority=1, enabled=True, data_provider=get_testdatas, author='思文伟', name='两数加法测试01')
    def integer_addition_02(self, number_1, number_2, expected):
        result = number_1 + number_2
        self.assertEqual(result, expected)


if __name__ == '__main__':
    Demo1Test.run_test()

综合示例

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
@Date: 2021/03/30 15:49:32
'''
import os
from stest import settings
from stest import AbstractTestCase
from stest import Test as testcase


class DataProvider01(object):
    def get_testdatas(self, testclass, testmethod, *args, **kwargs):
        return [
            {'加数1': 1, '加数2': 2, '预期': 3},
            {'加数1': 4, '加数2': 5, '预期': 9}
        ]


class DataProvider02(object):
    def get_testdatas(self, testclass, testmethod, *args, **kwargs):
        return [
            [{'加数1': 7}, {'加数2': 5}, {'预期': 12}],
            [{'加数1': 10}, {'加数2': 5}, {'预期': 15}]
        ]


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


class CalculationTest(AbstractTestCase):
    """数学运算测试"""

    @classmethod
    def setUpClass(cls):
        pass

    def setUp(self):
        pass

    @testcase(priority=1, enabled=True, data_provider=DataProvider01().get_testdatas, author='思文伟', name='整数加法测试01')
    def integer_addition_01(self, testdata):
        """自定义数据提供者 - 单参数化"""
        number_1 = testdata.get("加数1")
        number_2 = testdata.get("加数2")
        expected = testdata.get("预期")
        result = number_1 + number_2
        self.assertEqual(result, expected)

    @testcase(priority=2, enabled=True, data_provider=DataProvider02().get_testdatas, author='思文伟', name='整数加法测试02')
    def integer_addition_02(self, testdata_01, testdata_02, testdata_03):
        """自定义数据提供者 - 多参数化"""
        number_1 = testdata_01.get("加数1")
        number_2 = testdata_02.get("加数2")
        expected = testdata_03.get("预期")
        result = number_1 + number_2
        self.assertEqual(result, expected)

    @testcase(priority=3, enabled=True, author='思文伟', name='整数减法测试01')
    def integer_subtraction_01(self):
        """不参数化"""
        self.assertEqual(21 - 10, 11)

    @testcase(priority=4, enabled=True, author='思文伟', data_provider_kwargs={'data_file_dir_path': TEST_DATA_FILE_DIRPATH}, name='整数减法测试02')
    def integer_subtraction_02(self, testdata):
        """内置数据提供者 - 指定数据文件目录"""
        result = int(testdata.get("减数1")) - int(testdata.get("减数2"))
        self.assertEqual(result, int(testdata.get("预期")))

    @testcase(priority=5, enabled=True, author='思文伟', name='整数减法测试03')
    def integer_subtraction_03(self, testdata):
        """内置数据提供者 - 自动查找数据文件目录"""
        result = int(testdata.get("减数1")) - int(testdata.get("减数2"))
        self.assertEqual(result, int(testdata.get("预期")))

    def tearDown(self):
        pass

    @classmethod
    def tearDownClass(cls):
        pass


if __name__ == '__main__':
    CalculationTest.run_test()

settings.py 配置文件

框架会自动查找并载入项目下的 settings.py 配置文件,通过 from stest import settings 导入配置对象,访问配置字段(字段必须大写,如 settings.SCREENSHOT)。

查找规则:

  1. 通过命令行参数 -sfile 指定配置文件路径或查找起始目录
  2. 若未指定,框架自动递归遍历项目目录(从用例所在目录往外推,第一个非 Python 包的目录即被认定为项目目录)及其子孙目录,查找 settings.py

框架配置字段

字段 描述
SCREENSHOT 控制测试失败后是否自动截图
ATTACH_SCREENSHOT_TO_REPORT 控制截图后是否附加到测试报告中(附加则转为 base64 嵌入报告)
SCREENSHOT_SAVE_DIR 截图存放目录(预留字段)
SEVEN_DATA_PROVIDER_DATA_FILE_DIR 内置数据提供者(SevenDataProvider)读取的测试数据文件目录。未设置则自动取测试用例所在模块目录
TEST_REPORT_DIR 测试报告存放目录。优先级:命令行参数 > 配置文件 > 模块目录
TEST_REPORT_NAME 测试报告名称。优先级:命令行参数 > 配置文件 > 模块名 > 任务名 > 测试开始时间
EXECUTOR 任务执行人,命令行未传入则取该设置
PROJECT_NAME 项目名称,命令行未传入则取该设置
DESCRIPTION 测试报告概要描述,命令行未传入则取该设置
DRIVER_MANAGER 驱动管理器,框架自动赋值,勿修改
TEST_PARAM_ALIAS @Test 参数别名字典,用于测试报告中参数显示名称映射
TEST_PARAM_NAME_FORMAT_STRING @Test 参数格式化字符串或函数。字符串可用变量:{param}(参数字段)、{alias}(参数别名);函数接收字段名和别名两个参数,返回字符串
TEST_PARAM_VALUE_FORMATTER @Test 参数值格式化。字典(键为参数名,值为格式化函数)或统一格式化函数,函数接收参数名和参数值两个参数
HTML_REPORT_RAW_HTML_PREFIX HTML 报告原始 HTML 输出前缀。在 print() 输出中,以该前缀开头的行将不做 HTML 转义,直接作为原始 HTML 输出到报告中(可用于输出带 HTML 标签的富文本内容)。默认为 None(不启用)
HTML_REPORT_MESSAGE_RENDERER HTML 报告消息自定义渲染函数。接收三个参数:message_list(消息列表)、settings(全局配置)、test_case(用例信息字典),返回渲染后的字符串。未设置或返回非字符串时使用默认处理( HTML 转义)

配置文件示例

# settings.py
import os
import html
from stest.report.html import elements

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

# 测试用例数据目录
SEVEN_DATA_PROVIDER_DATA_FILE_DIR = os.path.join(PROJECT_DIR, "testdata")

# 测试报告存放目录
TEST_REPORT_DIR = os.path.join(PROJECT_DIR, "report")

# 截图存放目录
SCREENSHOT_SAVE_DIR = os.path.join(PROJECT_DIR, "screenshot")

# 测试环境地址
WEB_URL = "https://tv.cctv.com/live/cctv13"

# 账号&密码
USER_NAME = "siwenwei"
USER_PWD = "123456"

# 参数名称格式化
TEST_PARAM_NAME_FORMAT_STRING = "{param} - {alias}"

# 参数别名映射
TEST_PARAM_ALIAS = {
    "priority": "优先级",
    "author": "编写者",
    "editors": "修改者",
    "last_modifyied_by": "最后修改者",
    "last_modified_time": "最近修改时间",
    "groups": "所属组",
    "related_testcases": "相关用例",
}


# 参数值格式化函数
def test_param_value_formatter(param, value):
    if isinstance(value, (list, tuple)):
        html_texts = []
        for one in value:
            text = one if isinstance(one, str) else str(one)
            html_texts.append(elements.P(html.escape(text, False)).to_html())
        return "".join(html_texts)
    else:
        return None


TEST_PARAM_VALUE_FORMATTER = dict(related_testcases=test_param_value_formatter)

# HTML 报告原始 HTML 输出前缀,print 输出中以该前缀开头的行不做 HTML 转义,直接作为原始 HTML 输出到报告
HTML_REPORT_RAW_HTML_PREFIX = "[RAW]"

# HTML 报告消息自定义渲染函数
def message_renderer(message_list, settings, test_case=None):
    rows = []
    for msg in message_list:
        lines = msg.split("\n")
        for line in lines:
            prefix = getattr(settings, "HTML_REPORT_RAW_HTML_PREFIX", None)
            if isinstance(prefix, str) and line.startswith(prefix):
                rows.append(line[len(prefix):])
            else:
                rows.append(html.escape(line, False))
    return "\n".join(rows)

HTML_REPORT_MESSAGE_RENDERER = message_renderer

使用 -b 参数运行测试,print() 输出才会被捕获并显示到 HTML 报告中: python -m stest -v -b -html report.html test_module.py

项目目录结构示例

Test 装饰器参数说明

参数 类型 描述
name str 测试用例名称。未传或为空则取方法 docstring 首行
dname str 或 list 用于给用例起一个依赖名称,配合 depends 使用
depends list 用例依赖列表。被依赖的用例不成功则当前用例不执行(除非 alway_run=True
groups list 方法所属的组列表
enabled bool 是否启用执行该测试方法
priority int 执行优先级,数值越小越先执行
alway_run bool True 时无论依赖用例结果如何都始终运行,默认 False
description str 已弃用,原用于设置测试用例名称
data_provider object 参数化数据提供者。AbsractDataProvider 子类或可调用对象,返回数据集列表(单参数化返回一维列表,多参数化返回二维列表)
data_provider_args tuple 数据提供者的变长位置参数
data_provider_kwargs dict 数据提供者的变长关键字参数
screenshot bool 控制该用例测试失败是否截图,优先级高于配置文件
attach_screenshot_to_report bool 控制该用例失败截图是否附加到报告,优先级高于配置文件
enable_default_data_provider bool 是否使用内置数据提供者(SevenDataProvider),默认 True。仅当 data_provider=None 且此值为 True 时生效

用例依赖设置

用例可依赖于其它用例成功后执行。若被依赖的用例不成功或未执行,则该用例会被标记为失败(除非设置了 alway_run=True)。

典型场景:添加和删除设备共用测试数据,删除用例依赖于添加用例成功后执行。

依赖引用格式

格式 说明 示例
模块名.py 依赖于指定模块中的所有用例 depends=['vnctest.py']
模块名.py.类名 依赖于指定模块中某个类的所有用例 depends=['vnctest.py.LoginTest']
模块名.py.类名.方法名 依赖于指定模块中某个类的某个用例 depends=['vnctest.py.LoginTest.login']
方法名 依赖于当前类的指定用例 depends=['dtest6']
dname值 依赖于当前类中通过 dname 命名的用例 depends=['four']

示例

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
@Date: 2021/09/29
'''
import stest
from stest import AbstractTestCase
from stest import Test as testcase


class DependTest(AbstractTestCase):
    """依赖设置测试"""

    @classmethod
    def setUpClass(cls):
        pass

    def setUp(self):
        pass

    @testcase(priority=1, enabled=True, author='思文伟', name='dtest1', depends=['vnctest.py'])
    def dtest1(self):
        """依赖于 vnctest.py 模块中的所有用例"""
        pass

    @testcase(priority=2, enabled=True, author='思文伟', name='dtest2', depends=['vnctest.py.LoginTest'])
    def dtest2(self):
        """依赖于 vnctest.py 模块中 LoginTest 类的所有用例"""
        pass

    @testcase(priority=2, enabled=True, author='思文伟', name='dtest3', depends=['vnctest.py.LoginTest.login'])
    def dtest3(self):
        """依赖于 vnctest.py 模块中 LoginTest 类的 login 用例"""
        pass

    @testcase(priority=2, enabled=True, author='思文伟', name='dtest4', dname='four')
    def dtest4(self):
        """命名用例为 four,供其他用例通过 dname 引用"""
        pass

    @testcase(priority=2, enabled=True, author='思文伟', name='dtest5', depends=['dtest6'])
    def dtest5(self):
        """依赖于当前类的 dtest6 用例"""
        pass

    @testcase(priority=2, enabled=True, author='思文伟', name='dtest6', depends=['four'])
    def dtest6(self):
        """依赖于当前类中 dname='four' 的 dtest4 用例"""
        pass

    def tearDown(self):
        pass

    @classmethod
    def tearDownClass(cls):
        pass


if __name__ == '__main__':
    stest.main()

参数化数据提供者(Data Provider)

@Test 装饰器会调用数据提供者,传入测试类名称和方法名称作为前两个固定参数,data_provider_argsdata_provider_kwargs 分别传给数据提供者的变长位置参数和关键字参数。

返回值规则

测试方法参数个数 数据提供者返回值 示例
单参数(如 def test(self, testdata) 一维字典列表 [{'name':'zhangsan','age':17}, {'name':'xiaoming','age':18}]
多参数(如 def test(self, name, age) 二维列表 [['zhangsan', 17], ['xiaoming', 18]]

内置数据提供者 - SevenDataProvider

使用 Excel(xlsx 或 xls 格式)存取和管理参数化测试数据,简洁直观,易于修改维护。

启用条件: data_provider=None(默认)且 enable_default_data_provider=True(默认)

数据块格式

Excel 文件中以"数据块"方式存储测试数据:

  • 所有行的第一列为标记列,第一行第一列为数据块开始标记(默认为"用例名称")
  • 第一行:用例名称信息(标记列的下一列为方法名称列,之后为用例名称列)
  • 第二行:用例数据标题
  • 第三行起:每一行为一组完整测试数据,直至空行或下一个数据块

数据文件查找规则

配置方式 说明
data_provider_kwargs={'data_file_dir_path':'路径'} 通过 @Test 装饰器指定数据文件目录
settings.SEVEN_DATA_PROVIDER_DATA_FILE_DIR settings.py 中配置全局数据文件目录
自动查找 以上均未设置,则取测试方法所在模块的目录

数据文件名规则

配置方式 说明
data_provider_kwargs={'data_file_name':'mytest'} 通过 @Test 装饰器指定数据文件名
自动取类名 以上未设置,则取测试类名作为数据文件名

其他参数

参数 说明
data_provider_kwargs={'sheet_name_or_index': 0} 指定 Excel 工作表索引(从 0 开始)或名称,默认取索引 0

示例

#!/usr/bin/env python
# -*- encoding: utf-8 -*-

from stest import AbstractTestCase
from stest import Test as testcase


class CalculationTest(AbstractTestCase):

    @classmethod
    def setUpClass(cls):
        pass

    def setUp(self):
        pass

    @testcase(priority=4, enabled=True, author='思文伟',
              data_provider_kwargs={'data_file_dir_path': 'E:\\alltest'},
              name='整数减法测试02')
    def integer_subtraction_02(self, testdata):
        """内置数据提供者 - 指定数据文件目录"""
        number_1 = testdata.get("减数1")
        number_2 = testdata.get("减数2")
        expected = testdata.get("预期")
        result = int(number_1) - int(number_2)
        self.assertEqual(result, int(expected))

    @testcase(priority=5, enabled=True, author='思文伟', name='整数减法测试03')
    def integer_subtraction_03(self, testdata):
        """内置数据提供者 - 自动查找数据文件目录"""
        number_1 = testdata.get("减数1")
        number_2 = testdata.get("减数2")
        expected = testdata.get("预期")
        result = int(number_1) - int(number_2)
        self.assertEqual(result, int(expected))

自定义数据提供者

自定义数据提供者可以是 AbsractDataProvider 的子类或任何可调用对象,必须接收以下参数:

get_testdatas(test_class_name, test_method_name, *args, **kwargs)

实现方式

  1. 继承 AbsractDataProvider:实现 get_testdatas(self, test_class_name, test_method_name, *args, **kwargs) 方法
  2. 可调用对象:任何接收上述参数签名的函数或方法

示例

继承 AbsractDataProvider 的数据提供者:

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
'''
from stest import AbstractTestCase
from stest import Test as testcase
from stest.abstract_data_provider import AbsractDataProvider


class SevenDataProvider(AbsractDataProvider):
    """示例:内置 SevenDataProvider 的简化版"""

    def get_testdatas(self, test_class_name, test_method_name, *args, **kwargs):
        # 从 Excel 文件读取数据的逻辑
        return [{"减数1": "36", "减数2": "10", "预期": "26"}]


class CalculationTest(AbstractTestCase):

    @testcase(priority=1, enabled=True,
              data_provider=SevenDataProvider,
              data_provider_kwargs={'data_file_dir_path': 'E:\\mytestdatas'},
              author='思文伟', name='整数加法测试01')
    def integer_addition_01(self, testdata):
        number_1 = testdata.get("加数1")
        number_2 = testdata.get("加数2")
        expected = testdata.get("预期")
        result = number_1 + number_2
        self.assertEqual(result, expected)

普通类的数据提供者:

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
'''
from stest import AbstractTestCase
from stest import Test as testcase


class Demo1DataProvider(object):

    def get_testdatas(self, test_class_name, test_method_name, *args, **kwargs):
        return [[1, 2, 3], [3, 4, 7]]


class Demo1Test(AbstractTestCase):

    @testcase(priority=1, enabled=True,
              data_provider=Demo1DataProvider().get_testdatas,
              author='思文伟', name='两数加法测试01')
    def integer_addition_02(self, number_1, number_2, expected):
        result = number_1 + number_2
        self.assertEqual(result, expected)

函数数据提供者:

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
'''
from stest import AbstractTestCase
from stest import Test as testcase


def get_testdatas(test_class_name, test_method_name, *args, **kwargs):
    return [[1, 2, 3], [3, 4, 7]]


class Demo1Test(AbstractTestCase):

    @testcase(priority=1, enabled=True,
              data_provider=get_testdatas,
              author='思文伟', name='两数加法测试01')
    def integer_addition_02(self, number_1, number_2, expected):
        result = number_1 + number_2
        self.assertEqual(result, expected)

钩子(hook)

钩子机制允许在测试执行的关键节点(运行阶段)插入自定义逻辑,实现测试生命周期的扩展。

核心概念

  • 钩子(Hook):通过 hook.wrapper 装饰器定义的函数,在指定运行阶段自动执行
  • 宿主函数(Host Function):被钩子挂载的函数,通过 hook.host 装饰器标记。宿主函数执行时,框架会根据运行策略自动触发挂载的钩子
  • 运行阶段(RunStage):钩子触发的时机,对应 SevenTestResult 的各个生命周期方法
  • 运行策略(RunPolicy):钩子相对于宿主函数的执行时机

运行策略

策略 说明
RunPolicy.BEFORE 1 在宿主函数执行之前运行钩子
RunPolicy.AFTER 2 在宿主函数执行之后运行钩子

内置运行阶段

框架内置以下运行阶段,对应 SevenTestResult 的同名方法:

运行阶段 说明 钩子函数参数(第一个参数始终为 settings
RunStage.startTestRun 所有测试开始执行前 (settings, result)
RunStage.startTest 单个测试开始执行前 (settings, test)
RunStage.stopTest 单个测试执行完成后 (settings, test)
RunStage.stopTestRun 所有测试执行完成后 (settings, result)
RunStage.addSuccess 测试成功时 (settings, test)
RunStage.addError 测试发生错误时 (settings, test, err)
RunStage.addFailure 测试断言失败时 (settings, test, err)
RunStage.addSkip 测试被跳过时 (settings, test, reason)
RunStage.addExpectedFailure 预期失败发生时 (settings, test, err)
RunStage.addUnexpectedSuccess 预期失败但意外成功时 (settings, test)

定义钩子

使用 hook.wrapper 装饰器定义钩子:

hook.wrapper(runstage, *, runpolicy=RunPolicy.BEFORE, priority=49, name=None, options={})
参数 说明
runstage 钩子运行阶段,指定钩子在哪个生命周期节点触发
runpolicy 运行策略,RunPolicy.BEFORE(宿主函数之前)或 RunPolicy.AFTER(宿主函数之后),默认 BEFORE
priority 运行优先级,数值越小越先执行,默认 49
name 钩子名称,默认取函数名
options 钩子额外属性字典

钩子函数签名规则:

  1. 第一个参数必须为全局配置对象 settings
  2. 其余参数与对应运行阶段的宿主函数参数一一对应(见上方参数表)
  3. 框架会在注册时自动校验参数签名是否匹配,不匹配会抛出 TypeError

标记宿主函数

使用 hook.host 装饰器标记宿主函数,框架会在宿主函数执行前后自动触发对应阶段的钩子:

hook.host(runstage, **options)

options 支持以下参数,用于控制钩子的排序和过滤:

作用范围 说明
before RunPolicy.BEFORE 的钩子 字典,支持 sort_keysort_reversefilter_func
after RunPolicy.AFTER 的钩子 字典,支持 sort_keysort_reversefilter_func

注意:框架内置的 SevenTestResult 方法已通过 @host 装饰器标记,无需手动标记。用户只需通过 @hook.wrapper 定义钩子即可。

自定义运行阶段

可通过 RunStage.newstage() 扩展自定义运行阶段:

from stest import hook

hook.RunStage.newstage("myCustomStage", 100, mark="自定义阶段描述")

示例

在测试运行前后启动和停止 Playwright:

import stest
from stest import hook
from playwright.sync_api import sync_playwright

@hook.wrapper(hook.RunStage.startTestRun)
def startTestRun(conf: stest.settings, result):
    """所有测试开始前,启动 Playwright"""
    conf.playwright = sync_playwright().start()

@hook.wrapper(hook.RunStage.stopTestRun, runpolicy=hook.RunPolicy.AFTER)
def stopTestRun(conf: stest.settings, result):
    """所有测试结束后,停止 Playwright"""
    playwright = getattr(conf, 'playwright', None)
    if playwright is not None:
        playwright.stop()

在单个测试开始前打印日志:

@hook.wrapper(hook.RunStage.startTest, priority=10)
def before_each_test(conf: stest.settings, test):
    """每个测试开始前打印用例名称"""
    print(f"开始执行: {test}")

在测试失败时记录额外信息:

@hook.wrapper(hook.RunStage.addFailure)
def on_failure(conf: stest.settings, test, err):
    """测试断言失败时记录错误信息"""
    print(f"测试失败: {test}, 错误: {err}")

表格定位器(Table Locator)

基于 Playwright 的表格通用定位器,采用**"由列定表"**的设计理念:只需提供关心的列标题或列索引,即可自动生成高精度 XPath,精准定位目标表格,并提供行查找与数据提取能力。

核心能力

  • 结构化定位:按列标题、列索引或混合字典配置表格结构,自动生成高精度 XPath
  • 智能检测与修复:通过 sync_from_dom 方法根据 DOM 检测结果同步或重建内部列配置
  • 多级表头支持:自动解析 colspan/rowspan,将多行表头合并为扁平化的列标题
  • 便捷数据提取:提供 row()cells()cell() 等链式 API

快速使用

from stest.testobjs.useful.table_locator import Table

# 1. 创建 Table 实例 —— 传入列标题
table = Table(page, '序号', '专资编码', '影城名称', '营业状态', '操作')

# 2. 标记非数据列(提取数据时自动忽略)
table.mark_non_data_columns_by_titles('操作', '序号')

# 3. 按内容查找行
row_el = table.row({"专资编码": "80002048"}, el_type="row")

# 4. 提取行数据
data = table.cells(row_el)
# => {'专资编码': '80002048', '影城名称': '影院名称-80002048', '营业状态': '开业'}

# 5. 遍历所有行
for row in table.all_rows.all():
    row_data = table.cells(row)
    print(row_data)

初始化配置

Tableconfig 参数支持多种类型(同一批次不可混用):

配置类型 示例 说明
字符串(列标题) Table(page, '姓名', '年龄') 最常用,auto_set_position=True 时标题顺序须与 DOM 一致
整数(列索引) Table(page, 2, 5, auto_set_position=False) 适用于无标题或动态标题的表格
字典 Table(page, {"index":1,"title":"姓名"}, {"index":2,"title":"年龄"}) 精细控制,支持 xpath/value/tagname
列表/元组 Table(page, (1,'姓名'), (2,'年龄','div[@class="cell"]')) 按位置传参,2~5个元素

auto_set_position 参数说明:

适用场景
True(默认) 列顺序固定且已知,传入标题顺序须与 DOM 一致
False 只关心列是否存在,不关心顺序;后续需配合 sync_from_domallow_missing_position=True

关键方法

方法 说明 示例
row(cells, by, **settings) 按内容查找行或单元格 table.row({"专资编码": "80002048"}, el_type="row")
cells(row, return_locator, title_as_key) 提取行数据(自动排除非数据列) table.cells(row_el)
cell(row, title_or_position, by) 获取单个单元格 table.cell(row_el, "营业状态")
sibling_cell(cell, target, by) 获取同行兄弟单元格 table.sibling_cell(code_cell, "营业状态")
header_cells(title, position, row) 获取表头单元格 table.header_cells(title='姓名', row=1)
mark_non_data_columns_by_titles(*titles) 按标题标记非数据列 table.mark_non_data_columns_by_titles('操作')
mark_non_data_columns_by_position(*positions) 按索引标记非数据列 table.mark_non_data_columns_by_position(1, 5)
set_body_cell_xpath(cxpath, *titles) 设置指定主体列的内部元素路径 table.set_body_cell_xpath('div[@class="action-btns"]', '操作')
detect_header_titles() 自动检测表头信息(含多级表头) table.detect_header_titles()
sync_from_dom(mode, by, sep) 从 DOM 同步/重建列配置 table.sync_from_dom(mode="rebuild")

固定列表格示例(Element UI)

Element UI 固定列会将表格拆分为多个独立的 <table> DOM 节点,需修改默认 XPath:

from stest.testobjs.useful.table_locator import Table

titles = ['序号', '专资编码', '影城名称', '院线', '影投', '营业状态',
          '终端绑定状态', '终端安装状态', '操作']

right_table = Table(page, *titles)

# 覆盖默认 XPath,指向固定列所在的 DOM 节点
right_table.default_head_xpath = (
    '//div[@class="el-table__fixed-right"]'
    '/div[contains(@class,"el-table__fixed-header-wrapper")]'
    '/table/thead'
)
right_table.default_body_xpath = (
    './ancestor::table/parent::div'
    '/following-sibling::div[contains(@class,"el-table__fixed-body-wrapper")]'
    '/table/tbody'
)

right_table.mark_non_data_columns_by_titles('操作')

# 正常使用
row_el = right_table.row({"专资编码": "80002048"}, el_type="row")
data = right_table.cells(row_el)

关键点

  • default_head_xpathdefault_body_xpath:默认针对 Element UI 标准表格设计。固定列场景必须修改;标准 HTML 表格(thead 与 tbody 在同一 <table> 内)可将 default_body_xpath 简化为 ./tbody
  • allow_missing_position:当 auto_set_position=False 时列索引为 None,生成的 XPath 可能模糊。建议调用 sync_from_dom() 自动填充索引,而非设置 allow_missing_position = True
  • cxpaths_for_rebuildsync_from_dom 重建时会丢失 cxpath,此属性充当"记忆层"确保重建后路径不丢失
  • 重复标题by="title" 仅匹配第一个出现的列,建议改用 by="position"
  • XPath 注入row()cells 参数值会直接嵌入 XPath,注意特殊字符转义

详细教程请参考源码包下的 stest/testobjs/useful/table_locator_tutorial.md

Page object 实现方案

框架内置三种抽象页面基类,分别对应不同的自动化测试工具,均遵循**"元素与动作分离"**的设计模式:

抽象基类 适用场景 驱动技术 类属性
AbstractPage Web页面、APP页面、Windows桌面应用 Selenium / Appium / WinAppDriver DRIVER_MANAGERWIN_APP_DRIVER_HELPER
AbstractPlaywrightPage Web页面 Playwright DRIVER_MANAGER
AbstractMiniumPage 微信小程序 minium WECHAT_MANAGER

设计模式

所有抽象基类遵循统一的页面封装规范:

  1. 页面类继承对应的抽象基类,可覆写 init() 方法执行自定义初始化逻辑
  2. 内部类 Elements(继承基类的 Elements):封装页面元素定位,通过 @property 暴露元素访问接口
  3. 内部类 Actions(继承基类的 Actions):封装页面操作动作,通过 self.page.elements 访问元素,所有动作方法返回 self 以支持链式调用
  4. 页面实例化时自动构建 ElementsActions 实例,分别赋给 self.elementsself.actions

AbstractPage(Selenium / Appium / WinAppDriver)

核心能力:

  • 多驱动支持:通过 DRIVER_MANAGER 统一管理 Selenium、Appium、WinAppDriver 驱动会话,支持多驱动实例切换
  • 丰富的元素查找:提供 find_element_by_idfind_element_by_xpathfind_element_by_android_uiautomatorfind_element_by_accessibility_id 等 20+ 种定位方法,支持超时等待
  • 浏览器管理chrome()firefox()ie() 快捷打开浏览器;maximize_window()set_window_size() 管理窗口
  • APP 会话open_app() 创建 Appium 会话;hide_keyboard()keyevent() 处理移动端特有操作
  • Windows 应用open_window_app() 创建 WinAppDriver 会话;switch_window_app_by_name() 切换应用窗口;WIN_APP_DRIVER_HELPER 管理驱动启停
  • 窗口与 Frameswitch_window_by_title()switch_window_by_url() 切换窗口;select_frame()default_frame()parent_frame() 切换 Frame
  • 滚动操作scroll_to()scroll_to_top()scroll_to_bottom()scroll_to_center()scroll_into_view()
  • 截图与报告screenshot() 保存截图;show2html() 截图并附加到 HTML 测试报告
  • XPath 拼接join_xpaths() 静态方法,便捷拼接多段 XPath

驱动创建方式:

# 方式1:实例化后调用浏览器方法(推荐)
page = LoginPage().chrome(url="https://example.com")

# 方式2:实例化时传入驱动名称
page = LoginPage("chrome", url="https://example.com")

# 方式3:APP 会话
page = LoginPage().open_app(remote_url, desired_capabilities=caps)

# 方式4:Windows 应用
page = LoginPage().open_window_app(remote_url, desired_capabilities=caps)

AbstractPlaywrightPage(Playwright)

核心能力:

  • 多浏览器支持chrome()chromium()firfox()msedge()webkit()(别名 safari())快捷打开浏览器,支持 browser_launch_argsbrowser_context_args 自定义启动参数
  • Playwright 原生 Page 访问pwpage 属性直接获取底层 Playwright Page 实例,可调用 Playwright 原生 API(如 pwpage.get_by_role()pwpage.wait_for_selector() 等),适用于框架未封装的 Playwright 能力
  • Playwright 原生定位get_by_xpath()get_by_id()get_by_text()get_by_alt_text()get_by_title()get_by_placeholder()locator()frame_locator() 等,完整封装 Playwright 的定位能力
  • 页面导航open_url()goto()reload()content()new_page()
  • 多页面管理switch2page() 切换到指定 Page;switch_to_default_page() 回到默认页面;get_playwright_pages_by_title()get_playwright_pages_by_url() 按标题/URL 查找页面
  • Frame 操作frame()main_frameframes
  • 滚动操作scroll_to()scroll_to_top()scroll_to_bottom()scroll_to_center()scroll_into_view()
  • 视口管理set_viewport_size()viewport_size()
  • 截图与报告screenshot() 保存截图;show2html() 截图并附加到 HTML 测试报告
  • XPath 拼接join_xpaths() 类方法,便捷拼接多段 XPath
  • 自动清理:支持配置 PlaywrightDriver.AUTO_STOP_PLAYWRIGHT,测试运行结束后自动停止 Playwright 进程

驱动创建方式:

# 方式1:实例化后调用浏览器方法(推荐)
page = LoginPage().chrome()

# 方式2:实例化时传入浏览器类型
page = LoginPage("chromium", browser_launch_args=dict(channel="chrome"))

# 方式3:指定启动参数
page = LoginPage().chrome(browser_launch_args=dict(channel="chrome"))

IDE 类型提示:

由于 ElementsActions 通过反射自动构建,IDE 无法自动推断类型。可通过覆写 _build_elements()_build_actions() 方法,使用 typing.cast() 解决:

import typing

class LoginPage(AbstractPlaywrightPage):

    def _build_elements(self):
        rv = super()._build_elements()
        self.elements = typing.cast(LoginPage.Elements, self.elements)
        return rv

    def _build_actions(self):
        rv = super()._build_actions()
        self.actions = typing.cast(LoginPage.Actions, self.actions)
        return rv

AbstractMiniumPage(微信小程序)

核心能力:

  • minium 集成:自动初始化 minium 实例,通过 self.miniself.nativeself.app 访问 minium 原生能力
  • 页面导航:实例化时传入 url 参数自动跳转到指定小程序页面
  • 元素查找get_element()get_elements() 封装 minium 的元素查找,支持 inner_texttext_containsvalue 等筛选条件
  • 当前页面current_page 属性获取当前小程序页面实例

初始化方式:

# 传入小程序页面路径和 minium 配置
page = IndexPage('/pages/index/index', minium_config={
    "platform": "ide",
    "debug_mode": "info",
    "close_ide": False,
    ...
})

公共约定

  • 链式调用Actions 中的方法返回 self,支持链式操作:page.actions.username(name).password(pwd).login()
  • 延时等待:页面和 Elements/Actions 均提供 sleep(seconds) 方法
  • 翻页接口Actions.turn_to_page(page_number) 由具体页面实现
  • 驱动管理:所有页面通过 DRIVER_MANAGER(或 WECHAT_MANAGER)统一管理驱动生命周期,支持多实例、别名切换

Web页面示例

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
'''

from stest.testobjs.abstract_page import AbstractPage


class LoginPage(AbstractPage):
    """登录页面"""
    def init(self):
        """其实不需要这个,页面会自省的去自动创建元素和动作,这样做只是为了开发工具可以使用.引出相关的元素和动作方法"""
        cls = self.__class__
        self.elements = cls.Elements(self)
        self.actions = cls.Actions(self)

    class Elements(AbstractPage.Elements):
        @property
        def username(self):

            name = "用户名"
            xpath = '//div[@id="app"]//div[@class="loginBox"]//form//label[normalize-space()="{}"]/following-sibling::div//input'.format(name)
            return self.page.find_element_by_xpath(xpath)

        @property
        def password(self):

            name = "密码"
            xpath = '//div[@id="app"]//div[@class="loginBox"]//form//label[normalize-space()="{}"]/following-sibling::div//input'.format(name)
            return self.page.find_element_by_xpath(xpath)

        @property
        def login(self):

            name = "登录"
            xpath = '//div[@id="app"]//div[@class="loginBox"]//form//button//span[normalize-space()="{}"]'.format(name)
            return self.page.find_element_by_xpath(xpath)

    class Actions(AbstractPage.Actions):
        def username(self, name):

            self.page.elements.username.clear()
            self.page.elements.username.send_keys(name)
            return self

        def password(self, pwd):

            self.page.elements.password.clear()
            self.page.elements.password.send_keys(pwd)
            return self

        def login(self):

            self.page.elements.login.click()
            return self
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
'''

import stest
from stest import settings
from stest import AbstractTestCase
from stest import Test as testcase

# 驱动管理器
from stest.dm import DRIVER_MANAGER

from ..pages.web.login_page import LoginPage


class WebLoginPageTest(AbstractTestCase):
    """ 登录页面测试 """
    @classmethod
    def setUpClass(cls):
        pass

    def setUp(self):
        pass

    @testcase(priority=1, enabled=True, screenshot=True, author='思文伟', name='用正确账号密码登录测试')
    def login_with_right_user_and_password(self, testdata):

        user = testdata.get("用户名")
        pwd = testdata.get("用户密码")
        url = settings.URLS.get('登录页面url')
        LoginPage().chrome(url, executable_path=settings.CHROME_DRIVER_PATH).maximize_window().actions.username(user).sleep(2).password(pwd).login().sleep(7)

    def tearDown(self):
        pass

    @classmethod
    def tearDownClass(cls):

        DRIVER_MANAGER.close_all_drivers()


if __name__ == '__main__':
    # WebLoginPageTest.run_test()
    stest.main()

APP页面示例

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
'''

from stest.testobjs.abstract_page import AbstractPage


class LoginPage(AbstractPage):
    """ APP登录页面 """
    class Elements(AbstractPage.Elements):
        @property
        def continue_btn(self):
            """授权页->继续按钮"""

            xpath = 'UiSelector().resourceId("com.android.permissioncontroller:id/continue_button")'
            return self.page.find_element_by_android_uiautomator(xpath)

        @property
        def confirm_btn(self):
            """更新提示->确定按钮"""

            xpath = 'UiSelector().resourceId("android:id/button1")'
            return self.page.find_element_by_android_uiautomator(xpath)

        @property
        def username(self):
            """用户名输入框"""

            xpath = 'UiSelector().resourceId("userName")'
            return self.page.find_element_by_android_uiautomator(xpath)

        @property
        def password(self):
            """密码输入框"""

            xpath = 'UiSelector().resourceId("password")'
            return self.page.find_element_by_android_uiautomator(xpath)

        @property
        def login(self):
            """登录按钮"""

            xpath = 'UiSelector().resourceId("submit")'
            return self.page.find_element_by_android_uiautomator(xpath)

        @property
        def reminder(self):
            """下次提醒"""

            xpath = 'UiSelector().resourceId("android:id/button1")'
            return self.page.find_element_by_android_uiautomator(xpath)

    class Actions(AbstractPage.Actions):
        def click_continue_btn(self):
            self.page.elements.continue_btn.click()
            return self

        def click_confirm_btn(self):
            self.page.elements.confirm_btn.click()
            return self

        def username(self, name):
            """输入用户名"""

            self.page.elements.username.clear()
            self.page.elements.username.send_keys(name)
            return self

        def password(self, pwd):
            """输入密码"""

            self.page.elements.password.clear()
            self.page.elements.password.send_keys(pwd)
            return self

        def login(self):
            """点击登录按钮"""

            self.page.elements.login.click()
            return self

        def reminder(self):
            """下次提醒"""

            self.page.elements.reminder.click()
            return self
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
'''

import stest
from stest import AbstractTestCase
from stest import Test as testcase
from stest.dm import DRIVER_MANAGER

from ..pages.app.login_page import LoginPage
from ..pages.app.home_page import HomePage
from ..pages.app.main_page import SettlementMainPage


class AppLoginPageTest(AbstractTestCase):
    """APP登录页面测试"""
    @classmethod
    def setUpClass(cls):

        cls.desired_caps = {
            'platformName': 'Android',  # 平台名称
            'platformVersion': '10.0',  # 系统版本号
            'deviceName': 'P10 Plus',  # 设备名称。如果是真机,在'设置->关于手机->设备名称'里查看
            'appPackage': 'com.ddnapalon.calculator.gp',  # apk的包名
            'appActivity': 'com.ddnapalon.calculator.gp.ScienceFragment',  # activity 名称
            # 'automationName': "uiautomator2"
        }
        cls.desired_caps["appPackage"] = "com.zgdygf.zygfpfapp"
        cls.desired_caps["appActivity"] = "io.dcloud.PandoraEntry"
        cls.server_url = "http://127.0.0.1:4723/wd/hub"
        # adb shell am start -W -n com.zgdygf.zygfpfapp/io.dcloud.PandoraEntry

    def setUp(self):
        pass

    @testcase(priority=1, enabled=True, screenshot=True, author='思文伟', name='成功登录测试')
    def test_successfully_login(self, testdata):

        name = testdata.get("用户名")
        pwd = testdata.get("密码")

        page = LoginPage()
        page.open_app(self.server_url, desired_capabilities=self.desired_caps, implicit_wait_timeout=10)
        page.actions.click_continue_btn().sleep(2).click_confirm_btn().sleep(2).username(name).password(pwd).login().sleep(2).reminder().sleep(21)
        # HomePage().elements.settlement_tab
        HomePage().actions.sleep(2).click_settlement_tab()
        sp = SettlementMainPage()
        sp.actions.sleep(7).swipe_to_select_year("2019年").sleep(7).input_film_name("单行道").click_search().sleep(3)
        page.hide_keyboard()
        sp.actions.click_film_item("单行道")

    def tearDown(self):
        pass

    @classmethod
    def tearDownClass(cls):

        DRIVER_MANAGER.close_all_drivers()


if __name__ == "__main__":
    # AppLoginPageTest.run_test()
    stest.main()

微信小程序页面示例

# -*- coding:utf-8 -*-

from stest.testobjs.abstract_minium_page import AbstractMiniumPage


class ADBasketPage(AbstractMiniumPage):
    """ 广告篮页面 """
    class Elements(AbstractMiniumPage.Elements):
        @property
        def do_ad_btn(self):
            """去投放广告"""

            selector = '#cart'
            inner_text = '去投放广告'
            return self.page.get_element(selector).get_element('view').get_element('view').get_element('button', inner_text=inner_text)

        @property
        def tabbar(self):
            """首页下方tab工具栏"""

            selector = '.mp-tabbar'
            return self.page.get_element(selector)

        @property
        def home_tab(self):
            """首页 标签"""

            selector = '.weui-tabbar__label'
            inner_text = "首页"
            return self.tabbar.get_element(selector, inner_text=inner_text)

        @property
        def ad_tab(self):
            """广告篮 标签"""

            selector = '.weui-tabbar__label'
            inner_text = "广告篮"
            return self.tabbar.get_element(selector, inner_text=inner_text)

        @property
        def order_tab(self):
            """订单 标签"""

            selector = '.weui-tabbar__label'
            inner_text = "订单"
            return self.tabbar.get_element(selector, inner_text=inner_text)

        @property
        def my_tab(self):
            """我的 标签"""

            selector = '.weui-tabbar__label'
            inner_text = "我的"
            return self.tabbar.get_element(selector, inner_text=inner_text)

        @property
        def _ad_cart(self):
            """广告购物车"""

            s = 'view>cart#cart'
            el_cart = self.page.get_element(s)
            el_cart.click()
            self.page.sleep(1)
            return el_cart

        def cinema_checkbox(self, cinema):
            """影院复选框

            Args:
                cinema: 影院
            """

            s1 = 'view.container.car>view.cinema-list>view.backgroud-float>view.flex-row>view.cinema-title'
            # 影院名
            s2 = 'text'
            # 复选框
            s8 = 'view>image.cart-icon'
            el_cts = self._ad_cart.get_elements(s1)
            el_cb = None
            for el_ct in el_cts:
                el_cinema = el_ct.get_element(s2)
                if el_cinema and el_cinema.inner_text == cinema:
                    el_cb = el_ct.get_element(s8)
                    if el_cb:
                        break
            return el_cb

        @property
        def all_schedules(self):
            """所有影院排期, 未调试,误用

            Args:
                cinema: 影院
            """

            s1 = 'view.container.car>view.cinema-list>view.backgroud-float'
            # 影院名
            s2 = 'view.flex-row>view.cinema-title>text.cinema-Name'
            # 放映日期
            s3 = 'view.cart--cinema-time'
            # 排期列表
            s4 = 'view.cart--cart-goods'
            # 影片名称
            s5 = 'view.cart-img>view.cart-message>view.name>text.filmName'
            # 放映时间
            s6 = 'view.cart-img>view.cart-message>view.common-flex>text.playTime'
            # 影厅
            s7 = 'view.cart-img>view.cart-message>view.common-flex>text.filmType'

            el_cinemaboxs = self._ad_cart.get_elements(s1)
            schedules = {}
            # {
            # 'el_cinema': {
            # 'el_showdate': [
            # (el_film, el_showtime, el_hall),...
            # ]
            # }
            # }
            for el_cinemabox in el_cinemaboxs:
                el_cinema = el_cinemabox.get_element(s2)
                if el_cinema:
                    cinema_schedules = {}  # 影院排期
                    el_cart_boxes = el_cinemabox.get_elements('view>view.cart--cart-box')
                    for el_cart_box in el_cart_boxes:
                        el_showdate = el_cinemabox.get_element(s3)
                        if not el_showdate:
                            continue
                        el_cart_goods = el_cart_box.get_element(s4)
                        one_day_schedules = []
                        for el_cart_good in el_cart_goods:
                            el_film = el_cart_good.get_element(s5)
                            el_showtime = el_cart_good.get_element(s6)
                            el_hall = el_cart_good.get_element(s7)
                            if el_film and el_showtime and el_hall:
                                one_day_schedules.append((el_film, el_showtime, el_hall))
                        cinema_schedules[el_showdate] = one_day_schedules
                    schedules[el_cinema] = cinema_schedules
            return schedules

        def schedule_checkbox(self, cinema, film, hall, showdate, showtime):
            """排期复选框

            Args:
                film: 影片
                cinema: 影院
                hall: 影厅
                showdate: 放映日期
                showtime: 放映时间
            """

            s1 = 'view.container.car>view.cinema-list>view.backgroud-float'
            # 影院名
            s2 = 'view.flex-row>view.cinema-title>text'
            # 放映日期
            s3 = 'view>view.cart--cart-box>view.cart--cinema-time'
            # 排期列表
            s4 = 'view>view.cart--cart-box>view.cart--cart-goods'
            # 影片名称
            s5 = 'view.cart-img>view.cart-message>view.name>text'
            # 放映时间
            s6 = 'view.cart-img>view.cart-message>view.common-flex>text'
            # 影厅
            s7 = 'view.cart-img>view.cart-message>view.common-flex>text'
            # 复选框
            s8 = 'view>image'
            el_cinemaboxs = self._ad_cart.get_elements(s1)
            el_cb = None
            for el_cinemabox in el_cinemaboxs:
                el_cinema = el_cinemabox.get_element(s2, inner_text=cinema)
                if el_cinema:
                    el_showdate = el_cinemabox.get_element(s3, inner_text=showdate)
                    if el_cinema and el_showdate:
                        el_goods = el_cinemabox.get_elements(s4)
                        for el_good in el_goods:

                            el_film = el_good.get_element(s5, inner_text=film)
                            el_showtime = el_good.get_element(s6, inner_text=showtime)
                            el_halls = el_good.get_elements(s7)
                            el_rhall = None
                            for el_hall in el_halls:
                                if el_hall.inner_text.strip().startswith(hall):
                                    el_rhall = el_hall
                                    break

                            if el_film and el_showtime and el_rhall:
                                el_cb = el_good.get_element(s8)
                                if el_cb:
                                    break
                if el_cb:
                    break
            return el_cb

        @property
        def select_all_btn(self):
            """全选按钮"""

            inner_text = '全选'
            s = 'view.container.car>view.cart-bottom>view.car-pay>view.cart-bottom-select>text'
            return self._ad_cart.get_element(s, inner_text=inner_text)

        @property
        def org_price(self):
            """原价结算金额"""

            inner_text = '原价结算'
            s1 = 'view.container.car>view.cart-bottom>view.car-pay>view.cart-bottom-pay>view.cart-btn'
            s2 = 'view'

            el_p_btn = None
            el_btns = self._ad_cart.get_elements(s1)
            for el_btn in el_btns:
                el_yj = el_btn.get_element(s2, inner_text=inner_text)
                if el_yj:
                    el_views = el_btn.get_elements(s2)
                    el_p_btn = el_views[0]
            return el_p_btn

        @property
        def org_price_btn(self):
            """原价结算按钮"""

            inner_text = '原价结算'
            s = 'view.container.car>view.cart-bottom>view.car-pay>view.cart-bottom-pay>view.cart-btn>view'
            return self._ad_cart.get_element(s, inner_text=inner_text)

        @property
        def pt_price(self):
            """拼团结算金额"""

            inner_text = '拼团结算'
            s1 = 'view.container.car>view.cart-bottom>view.car-pay>view.cart-bottom-pay>view.cart-btn'
            s2 = 'view'

            el_p_btn = None
            el_btns = self._ad_cart.get_elements(s1)
            for el_btn in el_btns:
                el_yj = el_btn.get_element(s2, inner_text=inner_text)
                if el_yj:
                    el_views = el_btn.get_elements(s2)
                    el_p_btn = el_views[0]
            return el_p_btn

        @property
        def pt_price_btn(self):
            """拼团结算按钮"""

            inner_text = '拼团结算'
            s = 'view.container.car>view.cart-bottom>view.car-pay>view.cart-bottom-pay>view.cart-btn>view'
            return self._ad_cart.get_element(s, inner_text=inner_text)

    class Actions(AbstractMiniumPage.Actions):
        def click_do_ad_btn(self):
            """点击去投放广告按钮"""

            self.page.elements.do_ad_btn.click()
            return self

        def click_tabbar(self):
            """点击下方标签工具栏"""

            self.page.elements.tabbar.click()
            return self

        def click_home_tab(self):
            """点击下方首页标签"""

            self.page.elements.home_tab.click()
            return self

        def click_ad_tab(self):
            """点击下方广告篮标签"""

            self.page.elements.ad_tab.click()
            return self

        def click_order_tab(self):
            """点击下方订单标签"""

            self.page.elements.order_tab.click()
            return self

        def click_my_tab(self):
            """点击下方我的标签"""

            self.page.elements.my_tab.click()
            return self

        def click_cinema_checkbox(self, cinema):
            """点击 影院复选框"""

            self.page.elements.cinema_checkbox(cinema).click()
            return self

        def click_schedule_checkbox(self, cinema, film, hall, showdate, showtime):
            """点击 排期复选框"""

            self.page.elements.schedule_checkbox(cinema, film, hall, showdate, showtime).click()
            return self

        def select_all(self):
            """点击全选按钮"""

            self.page.elements.select_all_btn.click()
            return self

        def org_price_equals(self, price, prefix='¥'):
            """检查原价结算金额是否正确"""

            ptext = self.page.elements.org_price.inner_text
            a_price = ptext.strip().lstrip(prefix)
            if a_price != price:
                self.page.fail('原价结算金额实际({})显示与预期({})不等'.format(a_price, price))
            return self

        def click_org_price(self):
            """点击原价结算按钮"""

            self.page.elements.org_price_btn.click()
            return self

        def pt_price_equals(self, price, prefix='¥'):
            """检查拼团结算金额是否正确"""

            ptext = self.page.elements.pt_price.inner_text
            a_price = ptext.strip().lstrip(prefix)
            if a_price != price:
                self.page.fail('拼团结算金额实际({})显示与预期({})不等'.format(a_price, price))
            return self

        def click_pt_price(self):
            """点击拼团结算按钮"""

            self.page.elements.pt_price_btn.click()
            return self
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
'''

import datetime

import stest
from stest import AbstractTestCase
from stest import Test as testcase
from stest.dm import DRIVER_MANAGER

from ..pages.wechat_mini.ad_basket_page import ADBasketPage
from ..pages.wechat_mini.index_page import IndexPage
from ..pages.wechat_mini.my_adlist_page import MyAdListPage
from ..pages.wechat_mini.cinema_list_page import CinemaListPage
from ..pages.wechat_mini.cinema_detail_page import CinemaDetailPage


class WechatMiniPageTest(AbstractTestCase):
    """微信小程序页面示例"""
    @classmethod
    def setUpClass(cls):

        cls.minium_config = {
            "platform": "ide",
            "debug_mode": "info",
            "close_ide": False,
            "no_assert_capture": False,
            "auto_relaunch": False,
            "device_desire": {},
            "report_usage": True,
            "remote_connect_timeout": 180,
            "use_push": True
        }

    def setUp(self):
        pass

    @testcase(priority=1, enabled=True, author='思文伟', name='广告投放界面->广告视频显示的正确性 - 影院列表>加入广告栏')
    def test_add_ad_to_ad_basket_in_cinemalist(self, testdata):

        ad_name = testdata.get('广告名')
        cinema = testdata.get('影院名称')
        film = testdata.get('影片名称')
        hall = testdata.get('影厅名称')
        showdate = testdata.get('放映日期')
        showtime = testdata.get('放映时间')
        showdate_fmt = testdata.get('放映日期格式', '%Y-%m-%d')

        month_day = datetime.datetime.strptime(showdate, showdate_fmt).strftime('%m-%d')
        ipage = IndexPage('/pages/index/index', minium_config=self.minium_config)
        ipage.actions.click_tabbar().sleep(1).click_home_tab().sleep(1)
        ipage.actions.click_cinema_ad_btn()

        clpage = CinemaListPage()
        clpage.actions.sleep(1).is_page_self('/pages/cinema/cinema')
        clpage.actions.upload_ad().sleep(2)

        p = MyAdListPage()
        p.actions.is_page_self().click_ad_checkbox(ad_name).sleep(1).to_launch().sleep(2)
        clpage.actions.click_cinema_item(cinema).sleep(1)

        cdp = CinemaDetailPage()
        cdp.actions.click_film(film).select_day(month_day).sleep(1).click_schedule(film, hall, showtime).sleep(1).confirm().sleep(2)
        clpage.actions.join_to_ad_basket().sleep(1).shopping_basket().sleep(1)

        bp = ADBasketPage()
        bp.actions.click_schedule_checkbox(cinema, film, hall, showdate, showtime)

    def tearDown(self):
        pass

    @classmethod
    def tearDownClass(cls):

        DRIVER_MANAGER.close_all_drivers()


if __name__ == "__main__":
    # WechatMiniPageTest.run_test()
    stest.main()

window应用程序页面示例

name version
selenium 3.141.0
Appium-Python-Client 0.48
WinAppDriver 1.2.200902003-release
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
'''

from stest.testobjs.abstract_page import AbstractPage


class VNCViewerPage(AbstractPage):
    """VNCViewer页面"""
    def init(self):
        """其实不需要这个,页面会自省的去自动创建元素和动作,这样做只是为了开发工具可以使用.引出相关的元素和动作方法"""
        cls = self.__class__
        self.elements = cls.Elements(self)
        self.actions = cls.Actions(self)

    class Elements(AbstractPage.Elements):
        @property
        def server_ip(self):
            """ip地址输入框"""

            return self.page.find_element_by_accessibility_id('1001')

        @property
        def ok(self):
            """ok按钮"""

            return self.page.find_element_by_name("OK")

        @property
        def pwd(self):
            """密码输入框"""

            locator = "./*"
            childrens = self.page.find_elements_by_xpath(locator)  # 获取当前窗口下的所有子元素
            element = None
            for c in childrens:
                # print("c.get_attribute("IsEnabled")=", c.get_attribute("IsEnabled"))
                if c.get_attribute("IsEnabled") == "true":  # 通过界面我们知道 只有输入密码框是可编辑的,所以使用该条件来判断是否密码输入框元素
                    element = c
                    break
            if element is None:
                message = "{} with locator '{}' not found.".format("xpath", locator)
                self.page.raise_no_such_element_exc(message)
            return element

    class Actions(AbstractPage.Actions):
        def server_ip(self, ip):
            """输入ip"""

            element = self.page.elements.server_ip
            element.clear()
            element.send_keys(ip)
            return self

        def ok(self):
            """点击ok按钮"""

            self.page.elements.ok.click()
            return self

        def pwd(self, password):

            element = self.page.elements.pwd
            element.clear()
            element.send_keys(password)
            return self
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
'''
@Author: 思文伟
'''

import stest
# from stest import settings
from stest import AbstractTestCase
from stest import Test as testcase
from stest.dm import DRIVER_MANAGER
from stest.dm import WIN_APP_DRIVER_HELPER

from ..pages.winapp.vncviewer_page import VNCViewerPage


class VNCViewerPageTest(AbstractTestCase):
    """ 使用WinAppDriver.exe测试Window应用程序VNCViewer示例 """
    @classmethod
    def setUpClass(cls):

        WIN_APP_DRIVER_HELPER.startup_winappdriver(r"E:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe")

    def setUp(self):
        pass

    @testcase(priority=1, enabled=True, screenshot=True, author='思文伟', name='用正确账号密码登录测试')
    def connect_remote_pc_desktop(self, testdata):

        ip = testdata.get("远程桌面登录账户")
        pwd = testdata.get("远程桌面登录密码")
        vnc_title = "VNC Viewer : Authentication [No Encryption]"
        desired_capabilities = {}
        desired_capabilities["app"] = r"C:\Users\siwenwei\Desktop\vnc-4_1_2-x86_win32_viewer.exe"  # vnc viewer 的执行路径
        server_url = "http://127.0.0.1:4723"
        page = VNCViewerPage()
        page.open_window_app(server_url, desired_capabilities)

        page.actions.sleep(5).server_ip(ip).sleep(1).ok()
        # 上面点击ok后,到下一个界面显示出来需要时间,所以这里设置延时等待
        page.switch_window_by_title(vnc_title, timeout=20).actions.pwd(pwd).sleep(2).ok()

    def tearDown(self):
        pass

    @classmethod
    def tearDownClass(cls):

        DRIVER_MANAGER.close_all_drivers()
        WIN_APP_DRIVER_HELPER.shutdown_winappdriver()


if __name__ == '__main__':
    # VNCViewerPageTest.run_test()
    stest.main()

playwrigh自动化测试工具 - Web页面示例

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
import typing
from stest.testobjs.abstract_playwright_page import AbstractPlaywrightPage


class LoginPage(AbstractPlaywrightPage):

    # 因是利用反射自动构建的实例对象,ide无法通过点(dot)带出实例下的属性以及方法,
    # 通过self.elements = typing.cast(LoginPage.Elements, self.elements)这个方式就可以解决该问题
    def _build_elements(self):
        rv = super()._build_elements()
        self.elements = typing.cast(LoginPage.Elements, self.elements)
        return rv

    # 因是利用反射自动构建的实例对象,ide无法通过点(dot)带出实例下的属性以及方法,
    # 通过self.actions = typing.cast(LoginPage.Actions, self.actions)这个方式就可以解决该问题
    def _build_actions(self):
        rv = super()._build_actions()
        self.actions = typing.cast(LoginPage.Actions, self.actions)
        return rv

    class Elements(AbstractPlaywrightPage.Elements):

        def __init__(self, page):
            super().__init__(page)
            self.page = typing.cast(LoginPage, self.page)

        @property
        def login_link(self):
            """登录弹窗按钮"""

            selector = '//*[@id="bid"]//div[contains(@class,"user_icon")]/a'
            return self.page.get_by_xpath(selector)

        @property
        def login_title(self):

            return self.page.get_by_text("账号登录", exact=True)

        @property
        def username(self):

            self.page.get_by_text("手机号/邮箱").click()
            self.sleep(1)
            return self.page.get_by_id("Id")

        @property
        def password(self):

            self.page.get_by_xpath('//*[@id="showpsd"]').click()
            self.sleep(1)
            return self.page.get_by_id("passwordFU")

        @property
        def login(self):
            """登录按钮"""

            return self.page.pwpage.get_by_role("link", name="登 录")

    class Actions(AbstractPlaywrightPage.Actions):

        # 因是利用反射自动构建的实例对象,ide无法通过点(dot)带出实例下的属性以及方法,
        # 通过这个方式就可以解决该问题:self.page = typing.cast(LoginPage, self.page)
        def __init__(self, page):
            super().__init__(page)
            self.page = typing.cast(LoginPage, self.page)

        def login(self, name, pwd):
            """登录

            Args
            -------
            name : 手机号/邮箱
            pwd : 密码
            """

            self.page.elements.login_link.click()
            self.sleep(1)
            self.page.elements.login_title.click()
            self.sleep(1)
            self.page.elements.username.fill(name)
            self.page.elements.password.fill(pwd)
            self.page.elements.login.click()
            return self
#!/usr/bin/env python
# -*- encoding: utf-8 -*-

import stest
from ..pages.login_page import LoginPage


class LoginTest(stest.AbstractTestCase):

    @classmethod
    def setUpClass(cls):

        pass

    def setUp(self):

        pass

    @stest.Test(enabled=True, name="用正确账号密码登录测试", screenshot=True, groups=["all", "cctv"])
    def login_with_right_user_and_password(self):

        LoginPage().chrome().open_url("https://tv.cctv.com/").actions.login("test@qq.com", '123456')

    def tearDown(self):

        pass

    @classmethod
    def tearDownClass(cls):

        pass

    @classmethod
    def tearDownClass(cls):

        pass


if __name__ == '__main__':
    LoginTest.run_test()

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

stest-1.0.8.tar.gz (338.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

stest-1.0.8-py3-none-any.whl (324.1 kB view details)

Uploaded Python 3

File details

Details for the file stest-1.0.8.tar.gz.

File metadata

  • Download URL: stest-1.0.8.tar.gz
  • Upload date:
  • Size: 338.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/3.4.2 importlib_metadata/4.8.1 pkginfo/1.7.1 requests/2.25.0 requests-toolbelt/0.9.1 tqdm/4.62.2 CPython/3.9.1

File hashes

Hashes for stest-1.0.8.tar.gz
Algorithm Hash digest
SHA256 a70fc999c58dda8e077361dbbf3c599e0e3ccf27d654c605c7b4e737f06095d0
MD5 c7f2b0b9082e08c8e7526ce0b27cd02b
BLAKE2b-256 710b88a510288ac5f7b5f87206e4b74d7bedee8890507bf882050136d3965dbf

See more details on using hashes here.

File details

Details for the file stest-1.0.8-py3-none-any.whl.

File metadata

  • Download URL: stest-1.0.8-py3-none-any.whl
  • Upload date:
  • Size: 324.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/3.4.2 importlib_metadata/4.8.1 pkginfo/1.7.1 requests/2.25.0 requests-toolbelt/0.9.1 tqdm/4.62.2 CPython/3.9.1

File hashes

Hashes for stest-1.0.8-py3-none-any.whl
Algorithm Hash digest
SHA256 e0f35d0f20212df1063d4f38f577d479ce2f304e29b18d509871bde99c607dae
MD5 e61e0cbf1f703037c834d365a017965e
BLAKE2b-256 6e26caf75dc30806d7bb6df3e4d45c5d39d931c630f352623d404bdaccacda03

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page