更友好、更灵活的编写、管理与运行测试,生成更加美观的独立单文件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()
快速开始
- 导入
AbstractTestCase和Test装饰器 - 编写继承自
AbstractTestCase的测试类,使用@Test装饰器标记测试方法 - 调用
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)。
查找规则:
- 通过命令行参数
-sfile指定配置文件路径或查找起始目录 - 若未指定,框架自动递归遍历项目目录(从用例所在目录往外推,第一个非 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_args 和 data_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)
实现方式
- 继承
AbsractDataProvider:实现get_testdatas(self, test_class_name, test_method_name, *args, **kwargs)方法 - 可调用对象:任何接收上述参数签名的函数或方法
示例
继承 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 |
钩子额外属性字典 |
钩子函数签名规则:
- 第一个参数必须为全局配置对象
settings - 其余参数与对应运行阶段的宿主函数参数一一对应(见上方参数表)
- 框架会在注册时自动校验参数签名是否匹配,不匹配会抛出
TypeError
标记宿主函数
使用 hook.host 装饰器标记宿主函数,框架会在宿主函数执行前后自动触发对应阶段的钩子:
hook.host(runstage, **options)
options 支持以下参数,用于控制钩子的排序和过滤:
| 键 | 作用范围 | 说明 |
|---|---|---|
before |
RunPolicy.BEFORE 的钩子 |
字典,支持 sort_key、sort_reverse、filter_func |
after |
RunPolicy.AFTER 的钩子 |
字典,支持 sort_key、sort_reverse、filter_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)
初始化配置
Table 的 config 参数支持多种类型(同一批次不可混用):
| 配置类型 | 示例 | 说明 |
|---|---|---|
| 字符串(列标题) | 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_dom 或 allow_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_xpath与default_body_xpath:默认针对 Element UI 标准表格设计。固定列场景必须修改;标准 HTML 表格(thead 与 tbody 在同一<table>内)可将default_body_xpath简化为./tbodyallow_missing_position:当auto_set_position=False时列索引为None,生成的 XPath 可能模糊。建议调用sync_from_dom()自动填充索引,而非设置allow_missing_position = Truecxpaths_for_rebuild:sync_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_MANAGER、WIN_APP_DRIVER_HELPER |
AbstractPlaywrightPage |
Web页面 | Playwright | DRIVER_MANAGER |
AbstractMiniumPage |
微信小程序 | minium | WECHAT_MANAGER |
设计模式
所有抽象基类遵循统一的页面封装规范:
- 页面类继承对应的抽象基类,可覆写
init()方法执行自定义初始化逻辑 - 内部类
Elements(继承基类的Elements):封装页面元素定位,通过@property暴露元素访问接口 - 内部类
Actions(继承基类的Actions):封装页面操作动作,通过self.page.elements访问元素,所有动作方法返回self以支持链式调用 - 页面实例化时自动构建
Elements和Actions实例,分别赋给self.elements和self.actions
AbstractPage(Selenium / Appium / WinAppDriver)
核心能力:
- 多驱动支持:通过
DRIVER_MANAGER统一管理 Selenium、Appium、WinAppDriver 驱动会话,支持多驱动实例切换 - 丰富的元素查找:提供
find_element_by_id、find_element_by_xpath、find_element_by_android_uiautomator、find_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管理驱动启停 - 窗口与 Frame:
switch_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_args和browser_context_args自定义启动参数 - Playwright 原生 Page 访问:
pwpage属性直接获取底层 PlaywrightPage实例,可调用 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_frame、frames - 滚动操作:
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 类型提示:
由于 Elements 和 Actions 通过反射自动构建,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.mini、self.native、self.app访问 minium 原生能力 - 页面导航:实例化时传入
url参数自动跳转到指定小程序页面 - 元素查找:
get_element()、get_elements()封装 minium 的元素查找,支持inner_text、text_contains、value等筛选条件 - 当前页面:
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a70fc999c58dda8e077361dbbf3c599e0e3ccf27d654c605c7b4e737f06095d0
|
|
| MD5 |
c7f2b0b9082e08c8e7526ce0b27cd02b
|
|
| BLAKE2b-256 |
710b88a510288ac5f7b5f87206e4b74d7bedee8890507bf882050136d3965dbf
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e0f35d0f20212df1063d4f38f577d479ce2f304e29b18d509871bde99c607dae
|
|
| MD5 |
e61e0cbf1f703037c834d365a017965e
|
|
| BLAKE2b-256 |
6e26caf75dc30806d7bb6df3e4d45c5d39d931c630f352623d404bdaccacda03
|