Metadata-Version: 2.1
Name: stest
Version: 1.0.8
Summary: 更友好、更灵活的编写、管理与运行测试，生成更加美观的独立单文件HTML报告。内置参数化测试数据存取方案，省去设计的烦恼，节省更多的时间，从而更快的投入到编写用例阶段
Home-page: https://github.com/hotswwkyo/stest
Author: 思文伟
Author-email: hotswwkyo@qq.com
License: MIT
Platform: UNKNOWN
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Natural Language :: Chinese (Simplified)
Classifier: Operating System :: OS Independent
Requires-Python: >=3
Description-Content-Type: text/markdown
Requires-Dist: xlrd (==1.2.0)

# stest

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

![](https://github.com/hotswwkyo/stest/blob/main/img/htmlreport.png)

### 功能特性

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


## 安装

```bash
# pip 方式安装
pip install stest

# 源码方式安装（需以管理员方式执行）
python setup.py install
```

## 执行测试

```bash
# 执行指定测试文件，生成 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()` 执行：

```python
#!/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. 导入 `AbstractTestCase` 和 `Test` 装饰器
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` 排序后的用例 |

### 简单示例

```python
#!/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()
```

### 综合示例

```python
#!/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 转义） |

### 配置文件示例

```python
# 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`

![](https://github.com/hotswwkyo/stest/blob/main/img/test_param_format_01.png)

![](https://github.com/hotswwkyo/stest/blob/main/img/test_param_format_02.png)

### 项目目录结构示例

![](https://github.com/hotswwkyo/stest/blob/main/img/project_dirs.png)

## 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']` |

### 示例

```python
#!/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 文件中以"数据块"方式存储测试数据：

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

![](https://github.com/hotswwkyo/stest/blob/main/img/testcase_data_excel_file.png)

#### 数据文件查找规则

| 配置方式 | 说明 |
| :---- | :---- |
| `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 |

#### 示例

```python
#!/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` 的数据提供者：**

```python
#!/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)
```

**普通类的数据提供者：**

```python
#!/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)
```

**函数数据提供者：**

```python
#!/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` 装饰器定义钩子：

```python
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` 装饰器标记宿主函数，框架会在宿主函数执行前后自动触发对应阶段的钩子：

```python
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()` 扩展自定义运行阶段：

```python
from stest import hook

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

### 示例

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

```python
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()
```

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

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

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

```python
@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

### 快速使用

```python
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：

```python
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` 简化为 `./tbody`
- **`allow_missing_position`**：当 `auto_set_position=False` 时列索引为 `None`，生成的 XPath 可能模糊。建议调用 `sync_from_dom()` 自动填充索引，而非设置 `allow_missing_position = True`
- **`cxpaths_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` |

### 设计模式

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

1. **页面类**继承对应的抽象基类，可覆写 `init()` 方法执行自定义初始化逻辑
2. **内部类 `Elements`**（继承基类的 `Elements`）：封装页面元素定位，通过 `@property` 暴露元素访问接口
3. **内部类 `Actions`**（继承基类的 `Actions`）：封装页面操作动作，通过 `self.page.elements` 访问元素，所有动作方法返回 `self` 以支持链式调用
4. 页面实例化时自动构建 `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

**驱动创建方式：**

```python
# 方式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` 属性直接获取底层 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_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 进程

**驱动创建方式：**

```python
# 方式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()` 解决：

```python
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` 属性获取当前小程序页面实例

**初始化方式：**

```python
# 传入小程序页面路径和 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页面示例
```python
#!/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

```
```python
#!/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页面示例
```python
#!/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

```
```python
#!/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()

```
### 微信小程序页面示例
```python
# -*- 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

```
```python
#!/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 |
```python
#!/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

```
```python
#!/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页面示例
```python
#!/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

```
```python
#!/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()

```


