需求

假设领导让你开发一个接口测试框架。领导提出了一些新的需求,你如何实现?

  • 支持用例优先级、标签,支持通过优先级或标签筛选用例
  • 支持用例负责人、迭代,及通过负责人或迭代筛选用例
  • 支持多环境配置
  • 支持超时及重试机制,防止不稳定用例
  • 并发执行用例以提高用例回归效率

Unittest测试框架基础

Unittest测试框架介绍

Unittest是Python自带的测试框架,提供基础的用例管理和测试控制功能,使用灵活易于定制。Unittest中主要包含TestCase测试用例、TestSuite测试套件、测试准备及清理方法和TestRunner测试运行器等主要概念,另外还包含TestLoader用于批量加载用例生成测试套件,TestResult用于在TestRunner中记录测试结果。

Unittest测试框架使用

基础使用

编写用例
test_demo1.py
import requests

import unittest

class TestDemo(unittest.TestCase):
    def setUp(self):
        self.session = requests.session()

    def tearDown(self):
        self.session.close()

    def test_get(self):
        """测试Get接口"""
        url = 'https://postman-echo.com/get?a=1&b=2'
        res = self.session.get(url)
        self.assertDictEqual({'a': 1, 'b': 2}, res.json()['args'])

运行方法

$ python3 –m unittest test_demo1.py -vvv

自定义测试套件运行方法

loader = unittest.TestLoader()
suite = loader.discover('.testcases')
runner = unittest.TextTestRunner()
result = runner.run(suite)

测试用例的生命周期

  • setUp()方法在每个测试方法执行之前调用,用于准备测试环境。
  • tearDown()方法在每个测试方法执行之后调用,用于清理测试环境。
  • setUpClass()方法在测试类中的所有测试方法执行之前调用,用于准备测试环境。
  • tearDownClass()方法在测试类中的所有测试方法执行之后调用,用于清理测试环境。

测试用例的组织和管理

  • 可以使用TestSuite类来组织多个测试用例。
  • 可以使用TestLoader类来动态加载测试用例。
  • 可以使用TestResult类来收集测试结果和生成测试报告。

跳过用例及期望失败

待补充

ddt数据驱动

安装ddt

$ pip install ddt

使用方法

待补充

ddt与unittest subtests的区别

  • subtests: 一个测试用例,循环测试失败不中断测试
  • ddt: 生成多个测试用例

ddt数据驱动原理

  • @ddt.data(): 为测试函数添加数据属性
  • @ddt.ddt: 遍历测试类中所有带数据属性的测试函数,动态在类中添加添加多个测试函数,并赋予数据

Unittest测试框架原理

Unittest测试框架的的原理是将继承unittest.TestCase的测试类中,所有的test开头的测试函数,生成该测试类的一个对象,然后组装成测试套件,使用测试运行器(TestRunner)运行并使用测试结果(TestResult)对象纪录每个用例的运行状态。

Unittest测试框架基础及进阶_测试用例

import unittest

class TestDemo(unittest.TestCase):
    def test_1(): ...

    def test_2(): ...

    def test_3(): ...

运行方法

loader = unittest.TestLoader()
# 生成测试套件-将test_1,、test_2、 test_3生成TestDemo的对象,添加到测试套件中
suite = loader.loadTestsFromTestCase(TestDemo)
for test in suite:
    print(test.id())

runner = unittest.TextTestRunner()
result = runner.run(suite)

Unittest测试框架基本结构

  • TestCase :测试用例
  • TestSuite:测试套件
  • TestLoader:测试用例加载器
  • TestResult:测试结果记录器
  • TestRunner:测试运行器

Unittest测试框架基础及进阶_测试用例_02

TestCase类

常用属性及方法

  • _testMethodName:字符串类型,测试方法(函数)名
  • _testMethodDoc:字符串类型,测试方法(函数)docstring注释
  • _outcome: 用例结果,unittest._Outcome对象,包含测试结果result(TestResult对象)及用例过执行异常errors等
  • id():用例标识,为用例的导入路径,例如test_demo.TestDemo.test_a
  • shortDescription():用例描述,_testMethodDoc的第一行内容
  • defaultTestResult():默认使用的测试结果result对象(新建一个TestResult对象)
  • countTestCases():用例数量,固定为1
  • run(result=None):用例运行方法
  • debug():用例调试方法,不纪录测试结果
  • fail(msg=None):使用例失败
  • skipTest(reason):使用例跳过(抛出SkipTest异常)

示例-通过测试用例(对象),获取测试方法(函数)及类对象

import unittest

class TestDemo(unittest.TestCase):
    def test_a(self):
        """
        测试a
        这是一个测试用例示例
        """
        pass

suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDemo)
for test in suite:
    print('用例id:', test.id())
    print('用例描述:', test.shortDescription())
    print('测试方法(函数)名:', test._testMethodName)
    print('测试方法(函数)完整docstring:', test._testMethodDoc)
    print('所属测试类(名称):', test.__class__.__name__)

    # 可以通过 用例 测试类对象 + 对应的测试方法名 获取测试方法对象
    testMethod = getattr(test.__class__, test._testMethodName)
    print('测试方法(函数)对象:', testMethod)
    # 通过测试方法对象可以拿到很多相关信息
    print('测试方法(函数)名:', testMethod.__name__)  # 同test._testMethodName
    print('测试方法(函数)完整docstring:', testMethod.__doc__)  # 同test._testMethodDoc
    # 还可以获取函数位置、代码、参数等信息

运行输出

用例id: __main__.TestDemo.test_a
用例描述: 测试a
测试方法(函数)名: test_a
测试方法(函数)完整docstring: 
        测试a
        这是一个测试用例示例
        
所属测试类(名称): TestDemo
测试方法(函数)对象: <function TestDemo.test_a at 0x10dcecdc0>
测试方法(函数)名: test_a
测试方法(函数)完整docstring: 
        测试a
        这是一个测试用例示例

另外测试方法对象testMethod或所在测试类对象中可能包含 __unittest_skip____unittest_expecting_failure__属性,用例运行时根据该属性来判断该用例是否直接跳过或执行期望失败逻辑

示例-强制失败或跳过用例
在测试方法中可以使用self.fail或self.skipTest根据条件使用例失败或跳过。

import unittest

class TestDemo(unittest.TestCase):
    def test_a(self):
        if 1 + 1 < 2:
            self.fail("数学崩塌了")
            # self.skipTest("我没办法执行")

需用户实现的方法

  • setUp():需用户实现,单条测试用例准备方法
  • tearDown():需用户实现,单条测试用例清理方法
  • setUpClass():需用户实现,测试类准备方法
  • tearDownClass():需用户实现,测试类清理方法

常用断言方法

Unittest测试框架基础及进阶_测试方法_03

TestSuite类

常用方法

  • addTest(test):添加单个用例或测试套件对象到该测试套件
  • addTests(tests):添加多个测试用例或测试套件对象到该测试套件
  • run(result):测试套件运行
  • debug():测试套件调试,不生成测试结果
  • countTestCases():测试套件中测用例数量
  • __iter__():支持for ... in ...遍历其中所有的测试用例

如何判断 test 是 测试套件还是用例

import unittest
from unittest.suite import _isnotsuite

suite = ...
for test in suite
    if _isnotsuite(test):   # 或 if isinstance(test, unittest.TestCase)
        print('是测试用例过')
    else:
        print('是测试套件')

由于测试套件是支持嵌套的,如果想遍历获取其中的所有测试用例对象,可以参考如下方式:

def get_suite_tests(suite):
    tests = []
    for test in suite:
        if isinstance(test, unittest.TestCase):
            tests.append(test)
        else:
            tests.extend(get_suite_tests(test))
    return tests

TestLoader类

常用方法

  • loadTestsFromTestCase(testCaseClass):通过测试类对象加载其中的所有测试函数,生成测试套件
  • loadTestsFromModule(module, pattern=None):通过测试模块加载其中的所有测试用例,生成测试套件
  • loadTestsFromName(name, module=None):通过字符串格式的测试函数导入路径名,如test_demo.TestDemo.test_a来加载测试用例,生成测试套件
  • loadTestsFromNames(names, module=None):通过测试函数导入路径名,批量加载测试用例,生成测试套件
  • getTestCaseNames(testCaseClass):通过测试类获取其中所有测试函数的测试函数导入路径名,生成测试套件
  • discover(start_dir, pattern='test.py', top_level_dir=None):递归遍历目录,搜集所有test.py中的Test开头的测试类中的所有测试函数,生成测试套件

TestResult类

常用方法

  • wasSuccessful():是否全部成功
  • stop():停止当前测试运行
  • startTest(test):开始(纪录)测试某用例
  • stopTest(test):停止(纪录)测试某用例
  • startTestRun():开始(纪录)整体的测试运行
  • stopTestRun():停止(纪录)整体的测试运行
  • addError(test, err):纪录异常用例
  • addFailure(test, err):纪录失败的用例
  • addSuccess(test):纪录成功的用例(默认什么都不做)
  • addSkip(test, reason):纪录跳过的测试用例
  • addExpectedFailure(test, err):纪录期望失败的测试用例
  • addUnexpectedSuccess(test):纪录非预期成功的测试用例
  • addSubTest(test, subtest, outcome):纪录子测试

TextTestRunner类

  • _makeResult():创建TestResult对象
  • run(test):运行测试套件

Unittest测试框架进阶

为用例添加额外属性

需求:

  • 支持用例优先级、标签,支持通过优先级或标签筛选用例
  • 支持用例负责人、迭代,及通过负责人或迭代筛选用例

已知:

  • 测试用例的声明方式是编写测试函数。
  • 用例加载后(测试套件中),每个用例是该测试类的对象,可以获取到对应的测试函数及测试函数注释

因此为用例添加额外属性,实现的步骤为:

  1. 编写用例时,在测试函数上添加特殊标记
  2. 正常加载用例生成测试套件
  3. 遍历测试套件所有测试用例,根据条件(如优先级为P0),和用例特殊标记筛选生成新的测试套件

编写用例时,可添加特殊标记的地方有:

  • 测试函数名,如test_a_p0,多种标签可能导致用例名太长或标识度不高,不推荐
  • 测试函数注释,如在测试函数注释第一行以外添加特定格式字符串如priority:0等
  • 测试函数对象,为测试函数对象添加额外属性,可以通过装饰器实现

通过测试函数注释-添加用例优先级属性

Unittest测试框架基础及进阶_测试用例_04

通过用例注释筛选用例

# 原测试套件
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDemo)
new_suite = unittest.TestSuite()  # 新建测试套件(用于放筛选的用例)

# 筛选p0用例
for test in get_suite_tests(suite):
    # 通过正则匹配获取用例优先级
    matched = re.search(r'priority:(\d)', test._testMethodDoc)
    if matched:
        priority = matched.group(1)
    else:
        priority = None
    print(f"用例:{test._testMethodName} 优先级: {priority}")
    # 筛选p0用例-组成新测试套件
    if priority == 0:
        new_suite.addTest(test)

print('筛选得到的用例数量:', new_suite.countTestCases())

完整代码

import re
import unittest

def get_suite_tests(suite):
    tests = []
    for test in suite:
        if isinstance(test, unittest.TestCase):
            tests.append(test)
        else:
            tests.extend(get_suite_tests(test))
    return tests

class TestDemo(unittest.TestCase):
    def test_a(self):
        """测试a
        priority:0
        """
    def test_b(self):
        """测试b
        priority:1
        """
    def test_c(self):
        """测试c
        priority:1
        """

# 原测试套件
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDemo)
new_suite = unittest.TestSuite()  # 新建测试套件(用于放筛选的用例)

# 筛选p0用例
for test in get_suite_tests(suite):
    # 通过正则匹配获取用例优先级
    matched = re.search(r'priority:(\d)', test._testMethodDoc)
    if matched:
        priority = matched.group(1)
    else:
        priority = None
    print(f"用例:{test._testMethodName} 优先级: {priority}")
    # 筛选p0用例-组成新测试套件
    if priority == 0:
        new_suite.addTest(test)

print('筛选得到的用例数量:', new_suite.countTestCases())

运行后输出:

用例:test_a 优先级: 0
用例:test_b 优先级: 1
用例:test_c 优先级: 1
筛选得到的用例数量: 0

其他属性如owner、iteration等也可以如法炮制。

通过测试函数对象-添加额外属性

Unittest测试框架基础及进阶_测试方法_05

import unittest

class TestDemo(unittest.TestCase):
    def test_a(self):
        """测试a"""

    def test_b(self):
        """测试b"""

    def test_c(self):
        """测试c"""

TestDemo.test_a.priority = 0
TestDemo.test_b.priority = 1
TestDemo.test_c.priority = 2

筛选时可以通过getattr(test.__class__, test._testMethodName)得到测试函数对象,然后通过测试函数对象的属性来筛选。
我们可以编写一个装饰器来为函数添加属性,例如:

装饰器实现

def test(priority=None, tags=None, owner=None, iteration=None):
    """装饰器,为测试函数添加额外属性"""
    def decorator(func):
        # 根据参数为测试函数添加额外属性
        func.priority = priority
        func.tags = tags
        func.owner = owner
        func.iteration = iteration
        return func
    return decorator

使用方法

class TestDemo(unittest.TestCase):
    @test(priority=0, tags=['demo'], owner='superhin', iteration='v1.0.0')
    def test_a(self):
        """测试a"""

    @test(priority=1, tags=['demo'], owner='superhin', iteration='v1.0.0')
    def test_b(self):
        """测试b"""

    @test(priority=1, tags=['demo'], owner='superhin', iteration='v2.0.0')
    def test_c(self):
        """测试c"""

用例筛选方法

# 原测试套件
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDemo)
new_suite = unittest.TestSuite()  # 新建测试套件(用于放筛选的用例)

# 筛选p0用例
for test in suite:
    testMethod = getattr(test.__class__, test._testMethodName)
    if hasattr(testMethod, 'priority') and getattr(testMethod, 'priority') == 0:
        new_suite.addTest(test)
print('筛选得到的用例数量:', new_suite.countTestCases())

完整代码

import unittest

def get_suite_tests(suite):
    tests = []
    for test in suite:
        if isinstance(test, unittest.TestCase):
            tests.append(test)
        else:
            tests.extend(get_suite_tests(test))
    return tests

def test(priority=None, tags=None, owner=None, iteration=None):
    """装饰器,为测试函数添加额外属性"""
    def decorator(func):
        # 根据参数为测试函数添加额外属性
        func.priority = priority
        func.tags = tags
        func.owner = owner
        func.iteration = iteration
        return func
    return decorator

class TestDemo(unittest.TestCase):
    @test(priority=0, tags=['demo'], owner='superhin', iteration='v1.0.0')
    def test_a(self):
        """测试a"""

    @test(priority=1, tags=['demo'], owner='superhin', iteration='v1.0.0')
    def test_b(self):
        """测试b"""

    @test(priority=1, tags=['demo'], owner='superhin', iteration='v2.0.0')
    def test_c(self):
        """测试c"""

# 原测试套件
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDemo)
new_suite = unittest.TestSuite()  # 新建测试套件(用于放筛选的用例)

# 筛选p0用例
for test in get_suite_tests(suite):
    testMethod = getattr(test.__class__, test._testMethodName)
    if hasattr(testMethod, 'priority') and getattr(testMethod, 'priority') == 0:
        new_suite.addTest(test)
print('筛选得到的用例数量:', new_suite.countTestCases())

也可是通过owner、tags或iteration筛选用例。另外还可以一个safe_getattr函数,简化对象属性的判断及获取,示例如下:

def safe_getattr(obj, attr):
    """对象没有该属性时返回None"""
    if hasattr(obj, attr):
        return getattr(obj, attr)

通过测试类-类属性设置用例通用属性

同一个测试类(TestDemo)的用例可能有些属性是一样的,如ower、iteration或tags等,我们可以通过测试类的类属性来声明用例的一些默认属性,当然用例也可以覆盖莫属性添加, 例如

Unittest测试框架基础及进阶_用例_06

class TestDemo(TestCase):
    owner = 'superhin'
    iteration = 'v1.0.0'
    tags = ['demo']

    @test(priority=0)
    def test_a(self):
        """测试a"""

    @test(priority=1)
    def test_b(self):
        """测试b"""

    @test(priority=1, iteration='v2.0.0')
    def test_c(self):
        """测试c"""

问题是,由于装饰器是立即执行,在装饰器中,很难通过测试函数对象func,获取到所在的测试类对象,以获取其属性。
一种解决办法是自定义测试用例类,重写用例的初始化过程,将测试函数的额外属性拷贝到测试用例对象上,例如

# 自定义测试用例基础类
class TestCase(unittest.TestCase):
    def __init__(self, methodName='runTest'):
        super().__init__(methodName)
        testMethod = getattr(self, methodName)
        testClass = self.__class__
        # 拷贝测试方法(函数)属性到测试用例对象,测试方法无属性时尝试获取测试类对象指定属性
        self.priority = safe_getattr(testMethod, 'priority') or safe_getattr(testClass, 'priority')
        self.tags = safe_getattr(testMethod, 'tags') or safe_getattr(testClass, 'tags')
        self.owner = safe_getattr(testMethod, 'owner') or safe_getattr(testClass, 'owner')
        self.iteration = safe_getattr(testMethod, 'iteration') or safe_getattr(testClass, 'iteration')

由于已将优先级等属性拷贝到测试用例对象上,因此在筛选用例时无需再获取testMethod进行判断,例如:

# 原测试套件
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDemo)
new_suite = unittest.TestSuite()  # 新建测试套件(用于放筛选的用例)

# 筛选iteration='v1.0.0'用例
for test in suite:
    print("用例适用版本:", safe_getattr(test, 'iteration'))
    if safe_getattr(test, 'iteration') == 'v1.0.0':
        new_suite.addTest(test)
print('筛选得到的用例数量:', new_suite.countTestCases())

完整代码

import unittest

def get_suite_tests(suite):
    tests = []
    for test in suite:
        if isinstance(test, unittest.TestCase):
            tests.append(test)
        else:
            tests.extend(get_suite_tests(test))
    return tests

def safe_getattr(obj, attr):
    """对象没有该属性时返回None"""
    if hasattr(obj, attr):
        return getattr(obj, attr)

def test(priority=None, tags=None, owner=None, iteration=None):
    """装饰器,为测试函数添加额外属性"""
    def decorator(func):
        # 根据参数为测试函数添加额外属性
        func.priority = priority
        func.tags = tags
        func.owner = owner
        func.iteration = iteration
        return func
    return decorator

class TestCase(unittest.TestCase):
    def __init__(self, methodName='runTest'):
        super().__init__(methodName)
        testMethod = getattr(self, methodName)
        testClass = self.__class__
        # 拷贝测试方法(函数)属性到测试用例对象,测试方法无属性时尝试获取测试类对象指定属性
        self.priority = safe_getattr(testMethod, 'priority') or safe_getattr(testClass, 'priority')
        self.tags = safe_getattr(testMethod, 'tags') or safe_getattr(testClass, 'tags')
        self.owner = safe_getattr(testMethod, 'owner') or safe_getattr(testClass, 'owner')
        self.iteration = safe_getattr(testMethod, 'iteration') or safe_getattr(testClass, 'iteration')

class TestDemo(TestCase):
    owner = 'superhin'
    iteration = 'v1.0.0'
    tags = ['demo']

    @test(priority=0)
    def test_a(self):
        """测试a"""

    @test(priority=1)
    def test_b(self):
        """测试b"""

    @test(priority=1, iteration='v2.0.0')
    def test_c(self):
        """测试c"""

# 原测试套件
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDemo)
new_suite = unittest.TestSuite()  # 新建测试套件(用于放筛选的用例)

# 筛选iteration='v1.0.0'用例
for test in get_suite_tests(suite):
    print("用例适用版本:", safe_getattr(test, 'iteration'))
    if safe_getattr(test, 'iteration') == 'v1.0.0':
        new_suite.addTest(test)
print('筛选得到的用例数量:', new_suite.countTestCases())

测试计划-通过属性筛选测试用例

由于测试套件筛选方式过程稍显复杂,我们可以增加一个自定义的TestPlan对象,通过类属性描述

class TestPlanDemo1(TestPlan):
    # 测试目录(该目录所有的测试用例)
    test_dir = '../testcases'
    # 可选, 支持priorities, owners, iterations, tags, exclude_tags等
    filter = {
        "priorities": [0, 1],
        "tags": ["demo"],
    }

if __name__ == '__main__':
    TestPlanDemo1().run(verbosity=2)

class TestPlanDemo2(TestPlan):
    # 指定包含的测试用例(也可以结合filter过滤)
    tests = [
        'testcases.test_demo.TestDemo.test_a',
        'testcases.test_demo.TestDemo.test_b',
        'testcases.test_demo.TestDemo.test_c',
    ]

if __name__ == '__main__':
    TestPlanDemo2().run(verbosity=2)

用例过滤函数实现

def filter_by_priorities(tests, priorities):
    """通过优先级列表筛选用例,返回筛选后用例对象列表"""
    new_tests = []
    for test in tests:
        if safe_getattr(test, 'priority') in priorities:
            new_tests.append(test)
    return new_tests

def filter_by_tags(tests, tags):
    """通过tags筛选用例,返回筛选后用例对象列表"""
    new_tests = []
    for test in tests:
        test_tags = safe_getattr(test, 'tags') or []
        print(tags, test_tags)
        # 测试用例tags集合不完全包含指定tags集合
        # 通过差集:部分(指定tags) - 整体(用例tags), 为空集时, 则整体完全包含部分
        if set(tags) - set(test_tags) == set():
            new_tests.append(test)
    return new_tests

# ... 其他筛选方法

测试计划类实现

class TestPlan:
    test_dir: str = None
    tests = []
    filter: dict = None

    def __init__(self):
        # 组装测试套件
        loader = unittest.defaultTestLoader
        if self.tests:   # 通过用例名称-生成测试套件
            suite = loader.loadTestsFromNames(self.tests)
        elif self.test_dir:  # 通过测试目录-遍历生成测试套件
            print('self.test_dir', self.test_dir)
            suite = loader.discover(start_dir=self.test_dir)
        else:
            raise ValueError("测试计划必须包含start_dir或tests属性")

        # 过滤用例
        if self.filter:
            tests = get_suite_tests(suite)
            if 'priorities' in self.filter:
                tests = filter_by_priorities(tests, self.filter['priorities'])

            if 'tags' in self.filter:
                tests = filter_by_tags(tests, self.filter['tags'])
            # ... 其他筛选条件

            # 根据筛选的用例列表生成新的测试套件
            suite = unittest.TestSuite()
            suite.addTests(tests)

        self.suite = suite

    def run(self, verbosity=1):
        runner = unittest.TextTestRunner(verbosity=verbosity)
        runner.run(self.suite)

整理下项目结构,如下图

Unittest测试框架基础及进阶_测试方法_07

libs/utils.py代码

import unittest


def safe_getattr(obj, attr):
    """对象没有该属性时返回None"""
    if hasattr(obj, attr):
        return getattr(obj, attr)


def get_suite_tests(suite):
    tests = []
    for test in suite:
        if isinstance(test, unittest.TestCase):
            tests.append(test)
        else:
            tests.extend(get_suite_tests(test))
    return tests

libs/filters.py

from libs.utils import safe_getattr


def filter_by_priorities(tests, priorities):
    """通过优先级列表筛选用例,返回筛选后用例对象列表"""
    new_tests = []
    for test in tests:
        if safe_getattr(test, 'priority') in priorities:
            new_tests.append(test)
    return new_tests


def filter_by_tags(tests, tags):
    """通过tags筛选用例,返回筛选后用例对象列表"""
    new_tests = []
    for test in tests:
        test_tags = safe_getattr(test, 'tags') or []
        # 测试用例tags集合不完全包含指定tags集合
        # 通过差集:部分(指定tags) - 整体(用例tags), 为空集时, 则整体完全包含部分
        if set(tags) - set(test_tags) == set():
            new_tests.append(test)
    return new_tests


def filter_by_iterations(tests, iterations):
    """通过迭代版本列表选用例,返回筛选后用例对象列表"""
    new_tests = []
    for test in tests:
        if safe_getattr(test, 'iteration') in iterations:
            new_tests.append(test)
    return new_tests


def filter_by_owners(tests, owners):
    """通过归属人列表筛选用例,返回筛选后用例对象列表"""
    new_tests = []
    for test in tests:
        if safe_getattr(test, 'owner') in owners:
            new_tests.append(test)
    return new_tests


def filter_by_exclude_tags(tests, exclude_tags):
    """通过排除tags筛选用例,返回筛选后用例对象列表"""
    new_tests = []
    for test in tests:
        test_tags = safe_getattr(test, 'tags') or []
        # 测试用例tags集合不包含指定exclude_tags中任一个
        # 通过差集:部分(指定tags) - 整体(用例tags), 为空集时, 则整体完全包含部分
        if set(test_tags) - set(exclude_tags) == set():
            new_tests.append(test)
    return new_tests

libs/case.py

import unittest

from libs.utils import safe_getattr


def test(priority=None, tags=None, owner=None, iteration=None):
    """装饰器,为测试函数添加额外属性"""

    def decorator(func):
        # 根据参数为测试函数添加额外属性
        func.priority = priority
        func.tags = tags
        func.owner = owner
        func.iteration = iteration
        return func

    return decorator


class TestCase(unittest.TestCase):
    def __init__(self, methodName='runTest'):
        super().__init__(methodName)
        testMethod = getattr(self, methodName)
        testClass = self.__class__
        # 拷贝测试方法(函数)属性到测试用例对象,测试方法无属性时尝试获取测试类对象指定属性
        self.priority = safe_getattr(testMethod, 'priority') or safe_getattr(testClass, 'priority')
        self.tags = safe_getattr(testMethod, 'tags') or safe_getattr(testClass, 'tags')
        self.owner = safe_getattr(testMethod, 'owner') or safe_getattr(testClass, 'owner')
        self.iteration = safe_getattr(testMethod, 'iteration') or safe_getattr(testClass, 'iteration')

libs/plan.py

import importlib
import unittest

from libs.filters import (filter_by_priorities, filter_by_tags, filter_by_iterations, filter_by_owners, filter_by_exclude_tags)
from libs.utils import get_suite_tests


class TestPlan:
    test_dir: str = None
    tests = []
    filter: dict = None

    def __init__(self):
        # 组装测试套件
        loader = unittest.defaultTestLoader
        if self.tests:   # 通过用例名称-生成测试套件
            suite = loader.loadTestsFromNames(self.tests)
        elif self.test_dir:  # 通过测试目录-遍历生成测试套件
            suite = loader.discover(start_dir=self.test_dir)
        else:
            raise ValueError("测试计划必须包含start_dir或tests属性")

        # 过滤用例
        if self.filter:
            tests = get_suite_tests(suite)
            if 'priorities' in self.filter:
                tests = filter_by_priorities(tests, self.filter['priorities'])

            if 'tags' in self.filter:
                tests = filter_by_tags(tests, self.filter['tags'])

            if 'iterations' in self.filter:
                tests = filter_by_iterations(tests, self.filter['iterations'])

            if 'owners' in self.filter:
                tests = filter_by_owners(tests, self.filter['owners'])

            if 'exclude_tags' in self.filter:
                tests = filter_by_exclude_tags(tests, self.filter['exclude_tags'])

            # 根据筛选的用例列表生成新的测试套件
            suite = unittest.TestSuite()
            suite.addTests(tests)

        self.suite = suite

    def run(self, verbosity=1):
        runner = unittest.TextTestRunner(verbosity=verbosity)
        runner.run(self.suite)

testcases/test_demo.py

import unittest

from libs.case import TestCase, test
from libs.utils import get_suite_tests, safe_getattr


class TestDemo(TestCase):
    owner = 'superhin'
    iteration = 'v1.0.0'
    tags = ['demo']

    @test(priority=0)
    def test_a(self):
        """测试a"""

    @test(priority=1)
    def test_b(self):
        """测试b"""

    @test(priority=1, iteration='v2.0.0')
    def test_c(self):
        """测试c"""


if __name__ == '__main__':
    suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDemo)
    new_suite = unittest.TestSuite()  # 新建测试套件(用于放筛选的用例)

    # 筛选iteration='v1.0.0'用例
    for test in get_suite_tests(suite):
        print("用例适用版本:", safe_getattr(test, 'iteration'))
        if safe_getattr(test, 'iteration') == 'v1.0.0':
            new_suite.addTest(test)
    print('筛选得到的用例数量:', new_suite.countTestCases())

testplans/testplan_demo1.py

from libs.plan import TestPlan


class TestPlanDemo1(TestPlan):
    # 测试目录(该目录所有的测试用例)
    test_path = '../testcases'
    # 可选, 支持priorities, owners, iterations, tags, exclude_tags等
    filter = {
        "priorities": [0, 1],
        "tags": ["demo"],
    }


if __name__ == '__main__':
    TestPlanDemo1().run(verbosity=2)

testplans/testplan_demo1.py

from libs.plan import TestPlan


class TestPlanDemo2(TestPlan):
    # 指定包含的测试用例(也可以结合filter过滤)
    tests = [
        'testcases.test_demo.TestDemo.test_a',
        'testcases.test_demo.TestDemo.test_b',
        'testcases.test_demo.TestDemo.test_c',
    ]


if __name__ == '__main__':
    TestPlanDemo2().run(verbosity=2)

运行控制-超时及失败重试控制

  • 支持超时及重试机制,防止不稳定用例

自定义测试套件-实现用例并发

自定义HTML测试报告及JSON测试报告数据实现