Skip to content

07你的第一个Web测试框架(一)

通过模块一的学习,我们已经对自动化测试框架和 Python 编程有了基本的认识。从本节课开始,我们便正式进入实战部分,我将带你一步步搭建测试框架,"Web 测试框架"我将分为两个课时讲解,本课时我将先详细介绍 Python 自带的标准框架 unittest, 等完全熟悉 unittest 的各种用法后,下一课时我将把 unittest 作为测试框架的核心模块,并在此基础上带你快速搭建出第一个 Web 测试框架。

根据被测试对象的不同,当前流行的测试框架可以分为 Web 端测试框架、接口测试框架和移动端测试框架。在我看来,这些框架看似跨越了不同终端,属于不同类型的测试框架,但底层核心却可以是同一个,那就是以 unittest,pytest 为代表的一类基础框架。

就以 Python 应用中使用最广泛的pytest/unittest 框架 为例,在它的基础上集成 Selenium/WebDriver 就是一个 Web 端测试框架、集成 Requests 就是一个接口测试框架、集成 Appium 就变成了移动端测试框架。事实上市面上大多数开源框架,大部分底层核心部分用的正是pytest 框架。

如果你想要对测试框架有更多了解,可以至我的公众号 iTesting,搜索关键词"测试框架"查阅相关文章,如《测试框架之我见《Web自动化测试框架实践指南》等。

从我的角度看,学习框架最好遵循如下步骤:

  • 照猫画虎,根据官方文档搭建初始版框架;

  • 知其所以然,在使用中深入了解框架的经典实现;

  • 推陈出新,结合公司业务持续创新,最终形成通用的框架。

今天我就按照以上步骤带领你搭建你的第一个 Web 测试框架。 考虑到学习的难度,在本节 Web 端框架的讲解中,我将使用 Python 标准库自带的 unittest 来作为 Web 端框架的核心;而在之后的 API 测试框架中,我将会使用 pytest 作为 API 测试框架的核心。

什么是 unittest?

unittest 是 Python 自带的类 Junit 单元测试框架。

像 Junit 之于 Java 一样,unittest 可用于单元测试,也可用于 Web 自动化测试甚至接口测试。unittest 支持测试用例/测试用例集的查找、组装,还可以在测试用例/测试用例集内共享数据,也支持根据条件筛选测试用例执行,以及自动化生成测试报告。

使用 unittest 可以快速搭建自动化测试框架进行测试。

unittest 核心组成

unittest 由以下核心组成部分。

1.Test Fixture

Test Fixture 通常用来做测试用例的准备或者清理工作。比如测试开始前的数据准备或者测试结束后的数据清理等。Python 通过 setUp()、tearDown()、setUpClass()、tearDownClass() 这 4 个钩子函数(Hook)来实现测试的准备和清理工作。

2.Test Case

Test Case 是 unittest 的最小单元,一个 Test Case 就是一个测试用例,通常 Test Case 会继承 TestCase 这个基类。

3.Test Suite

Test Suite 是测试套件,就是我们常说的测试用例集,它可以包含一个或多个测试用例。

4.Test Loader

Test Loader 用来从提供的类(classes)和模块(modules)中生成测试用例集,默认情况下unittest 会提供一个 default test loader。

5.Test Runner

Test Runner 是测试执行器,用来进行测试用例的执行和测试结果的输出。

unittest 运行原理

知道了 unittest 的 5 大核心类,我们看下 unittest 的运行原理,如图所示:

Test Cases 包括一个或者多个 TestCase 类,其中保存了具体的测试过程,你可以在测试类里使用 Test Fixture,例如setUp()、tearDown() 进行测试开始前的准备和结束后的清理工作。

TestSuite 包括一个或者多个 TestSuite 类,其中 TestSuite 包括了一个或多个 TestCase,也可以包括其他 TestSuite。TestSuite 通过 addTest() 或者 addTests() 方法把一个个的测试用例或者测试用例集(TestSuite)组装起来成为一个新的测试用例集。

TestLoader 类加载本地或从外部文件中定义好的 TestCase 或者 TestSuites。

TestRunner 包括TextTestRunner类, 它提供了运行测试的标准平台。测试运行可以通过 unittest.main() 或者 python -m unittest xxx.py 来运行。

Test Results Collector 包括 TestResults 类,它为测试结果提供了一个标准容器,它存储运行的测试用例状态,例如 errors、failures、skipped,测试的结果可以直接在 Console 输出,也可以为通过其他形式输出,例如 Text、result、output。

融会贯通 unittest 使用

1.unittest 极简用法

好,原理我们也了解了,下面看下如何使用 unittest 来进行测试,以及 unittest 常用的语法语句。

python
# coding=utf-8
import unittest
#测试类必须要继承TestCase类
class TestSample(unittest.TestCase):

    #测试用例默认以test开头
    def test_equal(self):
        self.assertEqual(1, 1)
    def test_not_equal(self):
        self.assertNotEqual(1, 0)

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

上面是 unittest 的最简单使用方法。可以看到我定义了一个测试类 TestSample,它继承自 unittest.TestCse 类,如果使用 unittest 框架,你的测试类必须要继承unittest.TestCse 类,且你的测试用例默认以 test 开头(实际上这个可以更改)。

这里我的测试用例有 2 个,分别为 test_equal 和 test_not_equal。注意测试用例在 unittest 里的表现形式是一个类方法。

我们在 Pycharm 里或者命令行里运行上述文件,得到如下结果:

python
Ran 2 tests in 0.002s
OK

2.TestFixture 的使用

如果你想在测试用例或者测试用例集开始前,执行某些操作, 在测试用例或者测试用例集结束后再执行另外一些操作,那么你应该使用 Test Fixture。

python
# coding=utf-8
import unittest

# 测试类必须要继承TestCase类
class TestSample(unittest.TestCase):
    #类共享的fixture,在整个测试类执行过程中仅仅执行一次,需加装饰器@classmethod
    @classmethod
    def setUpClass(cls):
        print('整个测试类只执行一次 -- Start')

    #测试用例fixture
    def setUp(self):
        print('每个测试开始前执行一次')
    # 测试用例默认以test开头
    def test_equal(self):
        self.assertEqual(1, 1)
    def test_not_equal(self):
        self.assertNotEqual(1, 0)

    #测试用例fixture
    def tearDown(self):
        print('每个测试结束后执行一次')

    #类共享的fixture,在整个测试类执行过程中仅仅执行一次,需加装饰器@classmethod
    @classmethod
    def tearDownClass(cls):
        print('整个测试类只执行一次 -- End')

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

需要注意的是,TestFixture 包括如下 4 个方法:

  • setUp()

setUp()方法在每一个测试用例执行测试前都会执行。

  • setUpClass()

setUpClass()方法仅在整个测试类开始执行前执行.setUpClass()方法必须使用 @classmethod 来装饰。

setUp() 和 setUpClass() 通常用来进行测试前的准备工作。例如,访问数据库获得测试用例需要的数据等。

  • tearDown()

tearDown()方法在每一个测试用例执行后都会执行。

  • tearDownClass()

tearDownClass()方法仅在整个测试类结束执行后执行.tearDownClass()方法必须使用 @classmethod 来装饰。

tearDown() 和 tearDownClass() 通常用来进行测试后的清理工作。例如,测试结束后删除测试产生的数据,将被测试系统恢复至之前的状态等。

我们在 Pycharm 里或者命令行里运行上述文件,得到结果如下:

powershell
整个测试类只执行一次 -- Start
每个测试开始前执行一次
每个测试结束后执行一次
每个测试开始前执行一次
每个测试结束后执行一次
整个测试类只执行一次 -- End

由此可见,test fixture 被正确执行了。

3.运行指定文件夹下的测试用例

在真实工作中,我们常常需要仅运行某一个测试类,或者某一个文件夹下的测试用例。此时,可以利用 unittest 的 main 函数来指定 module 运行,我们在《04 必知必会,打好 Python 基本功》里讲过 module 是什么

模块(module)是为了编写可维护的代码,而把函数分组放到不同文件里的行为。在 Python 中,一个 .py文件 就是一个模块,一个模块可以包括一个或多个功能,模块有可以被一个或多个其他模块引用。

先来看下 unittest.main 的语法。

python
unittest.main(module='__main__', defaultTest=None, argv=None, testRunner=None, testLoader=unittest.defaultTestLoader, exit=True, verbosity=1, failfast=None, catchbreak=None, buffer=None, warnings=None

其各个参数的含义如下:

  • module:指定待运行的 module,默认是"main";

  • defaultTest:单个测试的名字或者多个测试名字的组合(必须要 iterable);

  • argv:传递给程序的一组变量,如果没有指定,那么系统默认使用 sys.argv;

  • testRunner:指定 unittest 的 test runner,可以是 test runner 类本身或者 test runner 类实例。默认情况下,main 函数会调用 sys.exit(),并且会在屏幕上显示测试运行错误或者成功的提示;

  • testLoader:必须是 TestLoader 类实例,默认是 defaultTestLoader

  • exit:默认是 True,即测试运行完调用 sys.exit(),在交互模式下使用时可指定为 False;

  • verbosity:用于控制显示在 console 里的 log 等级,有 0、1、 2 三种,一般默认为等级 1,其中等级 2 显示的 log 最详细。

下面来看一个 discover 的例子, 假设我们的项目结构如下:

java
|--lagouTest
    |--tests
        |--test_to_run.py
        |--itesting_test.py
        |--__init__.py
    |--main.py
    |--__init__.py

其中,test_to_run.py 文件里的内容如下:

# test_to_run.py
# coding=utf-8

import unittest

class TestToRun(unittest.TestCase):
    def setUp(self):
        pass
        # 这里写setUp的方法,通常是打开浏览器


    def testAssertNotEqual(self):
        self.assertEqual(1, 2)
        # 这里写具体的search方法


    def testAssertEqual(self):
        print(1)
        self.assertEqual(1, 1)
        # 这里写具体的search方法


    def tearDown(self):
        pass
        # tearDown方法,测试后的清理工具,比如对测试产生的数据进

itesting_test.py 文件里的内容如下:

#itesting_test.py
# coding=utf-8


import unittest


# 测试类必须要继承TestCase类
class ITestingTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        print('整个测试类只执行一次 -- Start')


    def setUp(self):
        print('每个测试开始前执行一次')


    # 测试用例默认以test开头
    def equal_test(self):
        self.assertEqual(1, 1)


    def test_not_equal(self):
        self.assertNotEqual(1, 0)


    def tearDown(self):
        print('每个测试结束后执行一次')


    @classmethod
    def tearDownClass(cls):
        print('整个测试类只执行一次 --')

main.py 的内容如下:

# coding=utf-8

import importlib.util
import os
import unittest


# 解析tests文件夹,并且返回module的字符串列表
def get_module_name_string(file_dir):
    return_list = []
    for root, dirs, file in os.walk(file_dir):
        for i in file:
            if not (i.endswith('__init__.py') or i.endswith('.pyc')) and i.startswith('test'):
                f = os.path.join(root, i)
                // 以下为Windows用法,如Mac系统,需要改成:
                //mod = 'tests.' + f.split('tests')[1].replace('.py', // '').replace('/', '')
                mod = 'tests.' + f.split('\\tests\\')[1].replace('.py', '').replace('\\', '.')
                return_list.append(mod)
    return return_list


if __name__ == "__main__":
    # 定义suites

    suites = unittest.TestSuite()

    # 获取所有的module的string,类似`
package.mod的方式
    mod_string_list = (get_module_name_string(os.path.join(os.path.dirname(__file__), 'tests')))
    # 遍历每个mod string,import并且把它加入test case中来
    for mod_string in mod_string_list:
        m = importlib.import_module(mod_string)
        test_case = unittest.TestLoader().loadTestsFromModule(m)
        suites.addTests(test_case)
    # 指定runner为TextTestRunner
    runner = unittest.TextTestRunner(verbosity=2)
    # 运行suites
    runner.run(suites)

在 Pycharm 或者命令行里运行 main.py,看下运行结果:

java
testAssertEqual (tests.tests_to_run.TestToRun) ... ok
testAssertNotEqual (tests.tests_to_run.TestToRun) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK

可以看到,os.path.join(os.path.dirname(-file-), 'tests') 这个命令获取了 tests 这个文件夹的路径,然后我通过 get_module_name_string 这个方法,把 tests 文件夹下的所有 module 的string 获取出来(放到 mod_string_list 中去),接着我遍历每一个获取的 module string,把它导入并加入到 unittest 的 suites 中去,最后我指定了 runner 并且运行。

如果你仔细观察测试结果,你会发现仅仅 test_to_run 这个文件夹下面的测试用例被执行了,而 itesting_tests.py下面的测试用例都没有被运行。

这是为什么呢?注意函数 get_module_name_string()中,我定义了仅会查找所有以"test"开头的 .py 文件。因为 itesting_tests.py 是以"itesting"开头的并不是以"test"开头的,所以它被排除在外了。

4.动态查找测试用例运行

除去直接使用 unittest.main 方式加载 module 运行外,unittest 还支持通过 TestLoader 下的 discover 方法去查找测试用例。

语法如下:

python
unittest.TestLoader.discover(start_dir, pattern='test*.py', top_level_dir=None)
unittest 允许你从某个文件夹开始,递归查找所有符合筛选条件的测试用例,并且返回一个包含这些测试用例的 TestSuite 对象,unittest.TestLoader.discover 支持的参数如下:
  • start_dir:起始文件夹的路径;

  • pattern(匹配模式):默认搜索所有以"test"开头的测试文件,并把这些文件里的以"test"开头的测试用例挑选出来;

  • top_level_dir(根目录):测试模块必须从根目录导入,如果 start_dir 的位置不是根目录,那么必须显式指定 top_level_dir。

仍然以上面的测试项目为例:

java
|--lagouTest
    |--tests
        |--test_to_run.py
        |--itesting_test.py
        |--__init__.py
    |--main.py
    |--__init__.py

其他文件内容不变,把 main.py 文件用 discover 的方式改写如下:

python
# coding=utf-8
import os
import unittest
if __name__ == "__main__":
    loader = unittest.defaultTestLoader
    #生成测试用suite
    suite = loader.discover(os.path.join(os.path.dirname(__file__), 'tests'), top_level_dir=os.path.dirname(__file__))
    #指定runner为TextTestRunner
    runner = unittest.TextTestRunner(verbosity=2)
    #运行suite
    runner.run(suite)

运行后发现结果跟用 unittest.main 的方式一致。

5.按需组装测试用例

从以上的例子里,你发现没有? 如果测试运行,那么一个测试类下面的所有以 test 开头的测试方法都会被执行,那有没有让我只执行指定的测试用例的方法呢?

在 unittest 中,testSuite 的组装,可以用上述的方式直接 discover,也可以用 unittest.TestSuite.addTest() 方式来添加测试用例到 TestSuite。

仍然以上述的项目为例:

html
|--lagouTest
    |--tests
        |--test_to_run.py
        |--itesting_test.py
        |--__init__.py
    |--main.py
    |--__init__.py

其他文件不变,我们把 main.py 更改成如下:

python
# coding=utf-8
import unittest
# 这里导入TestToRun这个测试类
from tests.tests_to_run import TestToRun
from tests.itesting_test import ITestingTest
if __name__ == "__main__":
    #定义一个测试用例集
    suite = unittest.TestSuite(v)
    #把导入进来的TestToRun这个测试类下面的测试方法加入测试用例
    suite.addTest(TestToRun('testAssertNotEqual'))
    suite.addTest(ITestingTest('test_not_equal'))

    # 指定runner为TextTestRunner
    runner = unittest.TextTestRunner(verbosity=2)
    # 运行测试
    runner.run(suite)

在 Pycharm 或者命令行里运行 main.py,结果如下:

java
整个测试类只执行一次 -- Start
每个测试开始前执行一次
每个测试结束后执行一次
整个测试类只执行一次 -- End
testAssertNotEqual (tests.tests_to_run.TestToRun) ... ok
test_not_equal (tests.itesting_test.ITestingTest) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK

你可以看到,在本次的测试中,我们分别挑选了 TestToRun 这个测试类下的"testAssertNotEqua"方法,和 ITestingTest 下面的"test_not_equal"方法,并且把它们组装到一个 TestSuite 里运行。

通过 suit.addTest() 的方式,就可以按照需要实现把不同文件下的测试用例组装到同一个 suite 执行的操作。

6.破除默认 pattern,随心所欲命名测试文件

在以上的举例中,除去按需组装测试用例,其他例子中,itesting_test.py 文件下的测试用例都没有被执行,其原因就是 unittest 有默认的查找 pattern 如下:

  • 查找测试文件,默认查找"test*.py";

  • 查找测试用例,默认查找"test*"。

我们可以通过更改查找 pattern 的方式来执行所有的测试用例,仍以上述项目为例:

html
|--lagouTest
    |--tests
        |--test_to_run.py
        |--itesting_test.py
        |--__init__.py
    |--main.py
    |--__init__.py

其他文件不变,更改 main.py 为:

python
# coding=utf-8
import os
import unittest
if __name__ == "__main__":
    suite = unittest.defaultTestLoader.discover(os.path.join(os.path.dirname(__file__), "tests"), \
                                                pattern='*.py', top_level_dir=os.path.dirname(__file__))
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(suite)

运行后查看结果,如下:

java
test_not_equal (tests.itesting_test.ITestingTest) ... ok
testAssertEqual (tests.tests_to_run.TestToRun) ... ok
testAssertNotEqual (tests.tests_to_run.TestToRun) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK
整个测试类只执行一次 -- Start
每个测试开始前执行一次
每个测试结束后执行一次
整个测试类只执行一次 -- End
1
Process finished with exit code 0

我们把默认的 pattern 更改为"*.py",这样任何在 tests 文件夹下的 py 文件都可以被查找到。可以看到 itesting_test.py 下的测试用例运行了一个方法,即"test_not_equal",但是"equal_test"这个方法没有运行,那是因为方法"testMethodPrefix"在起作用。

我们来更改下测试方法的默认查找方式, 更改 main.py 为如下:

python
# coding=utf-8
import os
import unittest
if __name__ == "__main__":
    loader = unittest.defaultTestLoader
    # 设置仅运行以equal开头的测试用例
    loader.testMethodPrefix = 'equal'
    suite = loader.discover(start_dir=os.path.join(os.path.dirname(__file__), "tests"), pattern='*.py', top_level_dir=os.path.dirname(__file__))
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(suite)

运行结果如下:

java
整个测试类只执行一次 -- Start
每个测试开始前执行一次
每个测试结束后执行一次
整个测试类只执行一次 -- End
equal_test (tests.itesting_test.ITestingTest) ... ok
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK

可以发现,"testMethodPrefix"改变了 python 查找测试用例的默认方式。

在这里给你留一个课后作业:Python 3.7 及以后的版本,TestLoader 多了一个属性 testNamePatterns,可以用于设置测试用例的 pattern,你可以课后尝试一下,看看结果有什么不同?

7.忽略测试用例执行

unittest 还支持忽略执行某些测试用例,只要在要忽略的测试用例上加上如下装饰器即可:

@unittest.skip() 执行时直接忽略掉被装饰的测试用例;

@unittest.skipIf() 如果 skipIf 里的条件成立,执行时直接忽略掉被装饰的测试用例;

@unittest.skipUnless() 永久在执行时忽略被装饰的测试用例,除非 skipUnless 里的条件成立;

@unittest.expectedFailure期望被装饰的测试用例是失败的,如果是失败的,则此条测试用例将被标记为测试通过。

下面来通过一组测试来显示如何忽略测试用例执行:

python
# coding=utf-8

import unittest
flag = False

# 测试类必须要继承TestCase类
class ITestingTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        print('整个测试类只执行一次 -- Start')
    def setUp(self):
        print('每个测试开始前执行一次')
    @unittest.skip('没有任何原因,忽略运行')
    def equal_test(self):
        self.assertEqual(1, 1)
    @unittest.skipIf(flag == True, "flag为True则skip")
    def test_not_equal(self):
        self.assertNotEqual(1, 0)
    @unittest.skipUnless(flag == True, "flag为False则skip")
    def test_not_equal1(self):
        self.assertNotEqual(1, 0)
    @unittest.expectedFailure
    def test_not_equal2(self):
        self.assertNotEqual(1, 0)
    def tearDown(self):
        print('每个测试结束后执行一次')
    @classmethod
    def tearDownClass(cls):
        print('整个测试类只执行一次 -- End')

if __name__ == '__main__':
    flag = False
    unittest.main(verbosity=2)

运行后结果如下:

java
test_not_equal (__main__.ITestingTest) ... ok
test_not_equal1 (__main__.ITestingTest) ... skipped 'flag为False则skip'
test_not_equal2 (__main__.ITestingTest) ... unexpected success
----------------------------------------------------------------------
Ran 3 tests in 0.000s
FAILED (skipped=1, unexpected successes=1)
整个测试类只执行一次 -- Start
每个测试开始前执行一次
每个测试结束后执行一次
每个测试开始前执行一次
每个测试结束后执行一次
整个测试类只执行一次 -- End
Process finished with exit code 1

使用 unittest 框架创建测试的步骤

本课时我详细讲解了unittest 的各种用法,了解 unitest 的各种用法后,你就可以搭建出以unittest 为核心的测试框架了。

使用 unittest 框架创建测试的步骤如下:

  • 编写一个测试类,这个测试类必须继承 TestCase 这个基类, 测试类所对应的 .py 文件默认要以 test 开头;

  • 在这个测试类下面写你的测试方法,每个测试方法应该包括一个测试的完整步骤,测试方法要默认以 test 开头;

  • 通过 unittest.main()、runner.run() 或者 python -m 的方式来调用这些测试用例。

熟悉了 unittest 框架后,下一课时我将带你正式搭建 Web 测试框架。


unittest.main 源码图

该图用以回答留言区:[老师,这几种加载方式一般都是为runner.run方法去执行?然而unittest.main方法是不是只能用于单个module场景?多个module就无法应用?] 这一提问。