Skip to content

20环境变化,测试框架如何动态秒切换?

上一章节,我们讲了如何使用 pytest 创建自定义命令行参数,并利用 argparse 自主实现了命令行参数的定义,最后创建并上传了自己的命令行程序。

本节课我们继续探讨命令行参数的奥秘,我会先讲解如何在 pytest 测试框架下实现环境变量切换,以及测试环境切换原理。然后,我会带你实现在自研框架下,根据环境变化切换测试环境。

多套测试环境带来的问题

大到一个软件产品的上线,小到一个页面功能改进,没有一个功能是开发改完就能直接上线的。正常情况下都需要经历至少 3~4 个环境的测试,包括开发环境、集成测试环境、预生产环境、生产环境等环节。

那么自动化测试也是一样,同样一个功能,它在所有的测试环境都运行正确,才能放心上线。这也带来如下显而易见的问题:

  • 自动化测试脚本只有一套,如何让一套脚本应用在不同的测试环境?

  • 每套测试环境,其测试数据可以不一样,如何确保测试数据正确绑定不同测试环境?

  • 在持续发布平台/DevOps 流水线里,如何指定测试环境运行测试脚本?

这些问题,都需要我们进一步研究命令行参数。

pytest 实现环境变量切换

首先,我们来看下如何使用 pytest 实现测试环境的切换。

假设我的项目目录结构如下:

python
|--lagouAPITest
    |--tests 
        |--test_demo.py
        |--__init__.py
    |--conftest.py

其中 conftest.py 的内容如下:

python
# conftest.py
import pytest

def pytest_addoption(parser):
    parser.addoption(
        "--env", action="store", default='dev', help="set env"
    )

@pytest.fixture(scope='session')
def get_env(request):
    return request.config.getoption('--env')

在上面这段代码里,我定义了一个命令行参数"env",它是一个可选参数,它的默认值是"dev";然后我定义了一个用于整个 session 的 fixture,名为 "get_env",用来获取环境变量的值。

然后 test_demo.py 的代码如下:

python
# test_demo.py
import pytest

class TestDemo:
    def test_env_config(self, get_env):
        print("\nNow the environment are --: {}".format(get_env))
        assert True

这段代码接收我们在 conftest.py 里定义的 get_env 这个 fixture,然后把它应用到 test_env_config 这个函数中去,这样我们就实现了环境变量的传入。

在命令行中分别以如下命令运行:

python
# 不加参数,使用env的默认值-dev
python -m pytest -v -s

命令执行的结果如下:

python
collected 1 item 
tests/test_demo.py::TestDemo::test_env_config 
my auth are --: dev
PASSED

可以看到,环境变量 env 被设置为默认值"dev"。

如果定义了可选参数并配置了默认值,那么在使用命令行执行时,可以不传入这个参数而直接使用。

我们再来看下指定环境变量的情况,命令行中执行以下语句:

python
# 给定环境变量参数
python -m pytest -v -s --env qa

命令执行的结果如下所示:

python
collected 1 item 
tests/test_demo.py::TestDemo::test_env_config 
Now the environment are: --qa
PASSED

可以看到,当你指定了环境变量参数 env 后,传入的值会覆盖掉默认值。

真实场景下的测试环境切换

现在虽然环境变量切换的功能实现了,但是如何把环境变量切换应用到我们的测试用例上呢

正常情况下,我们的测试数据是根据测试环境分开存放的。现在来更新下我们的文件结构,使它更能反映我们的真实工作情况:

python
|--lagouAPITest
    |--tests 
        |--test_demo.py
        |--__init__.py
    |--test_data
        |--test_demo
            |--test_demo.dev.yaml
            |--test_demo.qa.yaml
    |--conftest.py

观察下,这个项目结构与以往我讲的有什么不同?

在这个项目结构里,所有的数据都放在 test_data 这个目录中,test_data 这个目录下的子目录名(test_demo ),和 tests 这个目录下的 module 名(.py 文件的名称,test_demo.py 的名称,即 test_demo)是一一对应的。

然后在 test_demo 这个子目录下,存储着不同环境的数据文件 test_demo.dev.yaml 和 test_demo.qa.yaml,其中 dev 和 qa 是两个测试环境的名称。

采取这种方式,我们的测试文件,以及测试文件对应的数据文件,在文件组织结构上就变得相对有序,方便我们代码处理。

下面来看一下, 在这个目录结构中,每个文件中的内容分别是什么。

其中conftest.py的内容如下:

python
import codecs
import json
import os
import pytest
import yaml

def pytest_addoption(parser):
    parser.addoption(
        "--env", action="store", default='dev', help="set env"
    )

@pytest.fixture(scope='session')
def get_env(request):
    return request.config.getoption('--env')

@pytest.fixture(scope='session')
def load_data_from_json_yaml(request):
    # 获取解析到的test_data所在的目录,以及调用test_data的文件
    data_folder, function_file = request.param
    # 根据传入的环境变量参数,计算出应该用那个环境下的数据文件
    data_file_name = function_file.replace('.py', '.%s.yaml' % request.getfixturevalue('get_env'))
    data_file = os.path.join(data_folder, data_file_name)
    _is_yaml_file = data_file.endswith((".yml", ".yaml"))
    with codecs.open(data_file, 'r', 'utf-8') as f:
        # Load the data from YAML or JSON
        if _is_yaml_file:
            data = yaml.safe_load(f)
        else:
            data = json.load(f)
    # 这里你可能需要根据业务需要,把数据解析以下再返回
    return data

在 conftest.py 中,我增加了一个 fixture 方法,用来根据指定的测试文件,查找并返回不同测试环境下,该测试文件对应的测试数据。这个 fixture 的方法名为 load_data_from_json_yaml, 它将接收我从测试用例传递过来的所有参数,并对其进行解析。

这个 fixture 方法是用来处理 yaml 或者 json 文件,在实际工作中,你可能需要根据数据格式的不同,编写不同的数据处理函数。

关于不同数据格式的数据处理,请回看前面的章节《DDT:博采众长,数据驱动的秘诀》和《Pandas:拒绝低效,数据驱动新手段》。

有了数据解析的方法,我们再来看下我们的数据文件。

test_demo.dev.yaml的内容如下:

python
username: "iTesting"
password: "isAwesome"

test_demo.qa.yaml的内容如下:

python
username: "iTesting"
password: "isGood"

一般情况下,不同测试环境下的数据文件的层次结构应该保持一致,以方便代码统一解析。但是数据变量的数量可以不一致,例如 qa 环境下可以有 dev 环境下没有的变量,反之亦然。

最后,编写测试文件test_demo.py的内容如下:

python
import os
import pytest
# 获取当前文件夹路径
# 获取当前文件的名称
current_folder, current_file = os.path.split(os.path.realpath(__file__))
#计算出当前文件对应的数据文件的目录名称
data_folder = os.path.dirname(current_folder) + os.sep + 'test_data' + os.sep + current_file.strip('.py') + os.sep

class Demo:
    @staticmethod
    def login(username, password):
        # 这里写你的业务逻辑,简单起见,我返回True
        print('\n%s' % username)
        print('\n%s' % password)
        return True

class TestDemo:
    @pytest.mark.parametrize("load_data_from_json_yaml, expected", [((data_folder, current_file), True)], indirect=['load_data_from_json_yaml'])
    def test_login(self, load_data_from_json_yaml, expected):
        assert Demo.login(load_data_from_json_yaml["username"], load_data_from_json_yaml["password"]) == expected

在这个测试文件中,我模拟了一个如何测试登录功能的例子:

  • 首先,获取当前文件对应的文件夹路径,以及当前文件名称,用于后续传递给 fixture 函数load_data_from_json_yaml 解析。

  • 接着,根据项目文件结构,计算出存储我的数据文件的文件夹路径。

  • 然后我定义了我的 Page 类 Demo,以及定义了一个静态方法 login 用于处理登录。

正常情况下,我们应该按照 PageObject 模型,再创建一个 page 目录 pages,来存储所有的 Page类,这里为了方便,我直接放在测试文件中了。

  • 接着定义测试类并且测试我在 Page 类中定义的方法 login。

我们来运行一下这个测试,看看切换环境对测试结果的影响。

在命令行中,输入以下命令执行:

python
# 根目录下执行
D:\_Automation\lagouAPITest>pytest tests/test_demo.py -v -s

执行成功后,你将看到如下结果:

因为我没有传入环境变量参数,默认使用 dev 环境,所以我们的值打印出来为"iTesting isAwesome"。

现在切换下环境运行:

python
# 根目录下执行
D:\_Automation\lagouAPITest>pytest tests/test_demo.py -v -s --env qa

执行成功后,你将看到如下结果:

现在环境被切换成 qa 环境,所以测试数据也被加载为 qa 环境的数据了。

由此,我们就实现了测试环境秒切换。

在此留个课后作业给你:根据上面所讲内容,结合 PageObject 模型,重新组织项目文件结构,并实现你自己业务的登录测试。

测试环境切换原理

上面是我们运用 pytest 实现了当测试环境变化时,测试框架秒切换测试环境。下面来看下测试环境切换的原理,其实特别简单,即:

  • 测试环境变量由用户输入提供;

  • 测试框架定义测试数据解析函数,并根据用户输入的测试变量,解析并返回测试环境对应的数据文件内容。

自研框架实现测试环境自由切换

假设我们使用自研框架而非 pytest 测试框架,我们该如何根据环境变化来切换测试环境呢?

还记得我们上一讲发布的 iTestingDemoFramework 吗?我们来更改下它,使它能够根据需要切换测试环境。

更改项目文件结构,使得它的文件结构如下:

其中 test_data 目录下有两个子目录,分别为 dev 和 qa,它们分别代表 dev 环境和 qa 环境。

在 dev 和 qa 目录下,有两个具备相同名称的数据文件 test_iTesting.yaml,它们是 tests 这个目录下的测试文件 test_iTesting.py 在不同测试环境下所对应的测试数据。

test_iTesting.py 是我们的测试文件,它的内容如下:

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

class Demo:
    @staticmethod
    def login(username, password):
        # 这里写你的业务逻辑,简单起见,我返回True
        print('\n%s' % username)
        print('\n%s' % password)
        return True

class TestDemo:
    def test_login(self, username, password, expected):
        assert Demo.login(username, password) == expect

在这个文件中,我定义了两个类,分别是 Page 类 Demo 和它对应的测试类 TestDemo。

注意: 在实际工作中,请按照 PageObject 模型将 Page 类定义在 pages 目录中,这里为了演示方便,我将它们定义在测试文件中了。

其中,Page 类 Demo 里有个静态方法,它实现了登录;测试类 TestDemo 有一条测试用例 test_login,它用来测试登录是否成功。

接着,我需要更改下 main.py 文件,使得它能够满足我们环境切换的要求:

python
# main.py
import argparse
import codecs
import inspect
import json
import os
import shlex
import sys
import glob
import importlib.util
import yaml
# 解析命令行参数
def parse_options(user_options=None):
    parser = argparse.ArgumentParser(prog='iTesting',
                                     usage='Demo Automation Framework, Search wechat account iTesting for more information')
    parser.add_argument("-env", default='dev', type=str, choices=['dev', 'qa', 'staging', 'prod'], help="Env parameter")
    if not user_options:
        args = sys.argv[1:]
    else:
        args = shlex.split(user_options)
    options, un_known = parser.parse_known_args(args)
    if options.env:
        print("\n想了解更多测试框架内容吗?请关注公众号iTesting")
        print('Currently the env are set to: %s' % options.env)
    return options
# 从指定文件夹下获取模块及其所在的路径
def find_modules_from_folder(folder):
    absolute_f = os.path.abspath(folder)
    md = glob.glob(os.path.join(absolute_f, "*.py"))
    return [(os.path.basename(f)[:-3], f) for f in md if os.path.isfile(f) and not f.endswith('__init__.py')]
# 动态导入模块
def import_modules_dynamically(mod, file_path):
    spec = importlib.util.spec_from_file_location(mod, file_path)
    md = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(md)
    return md
# 获取测试类所在的文件夹和测试类对应的数据类所在的文件夹
def get_tests_and_data_folder_via_env(env):
    # 注意,下面的test_root和test_data_root这两个方法。从main函数执行,和从命令行输入iTesting运行,获取到的值不同
    # 当前此代码获取方式是通过命令行运行,即使用pip install iTesting后,在命令行中使用iTesting来执行
    test_root = os.path.join(os.getcwd(), 'iTesting' + os.sep + 'tests')
    test_data_root = os.path.join(os.getcwd(), 'iTesting' + os.sep + 'test_data' + os.sep + env)
    # current_folder, current_file = os.path.split(os.path.realpath(__file__))
    # test_data_root = os.path.join(current_folder, 'test_data' + os.sep + env)
    # test_root = os.path.join(current_folder, 'tests')
    return test_root, test_data_root
# 解析数据文件的方法
def load_data_from_json_yaml(yaml_file):
    _is_yaml_file = yaml_file.endswith((".yml", ".yaml"))
    with codecs.open(yaml_file, 'r', 'utf-8') as f:
        # Load the data from YAML or JSON
        if _is_yaml_file:
            data = yaml.safe_load(f)
        else:
            data = json.load(f)
    return data
# 测试框架的执行函数
def run(test_folder, test_data_folder):
    module_pair_list = find_modules_from_folder(test_folder)
    for m in module_pair_list:
        mod = import_modules_dynamically(m[0], m[1])
        test_data_file = os.path.join(test_data_folder, mod.__name__ + '.yaml')
        for cls_name, cls in inspect.getmembers(mod, inspect.isclass):
            if cls_name.startswith('Test'):
                for item in inspect.getmembers(cls, lambda fc: inspect.isfunction(fc)):
                    func_name, func = item
                    if func_name.startswith('test'):
                        test_data = load_data_from_json_yaml(test_data_file)
                        print("\n想了解更多测试框架内容吗?请关注公众号iTesting")
                        print(test_data["username"])
                        print(test_data["password"])
                        func(cls_name, test_data["username"], test_data["password"], True)
# main函数,也是测试框架入口
def main(user_options=None):
    args = parse_options(user_options)
    test_root, test_data_root = get_tests_and_data_folder_via_env(args.env)
    run(test_root, test_data_root)

if __name__ == "__main__":
    main('-env dev')

这段代码功能比较多,简单来说我实现了如下功能:

  • 解析用户输入的命令行参数;

  • 自动查找测试目录 tests 下的所有的测试类及测试方法;

  • 自动加载并解析每一个测试类对应的测试数据文件;

  • 动态导入所有的测试类,测试方法;

  • 针对每一个测试用例,将测试数据喂给它,并自动执行。

通过以上代码,我基本实现了最简单的测试环境切换、测试用例查找、测试模块自动导入、测试数据加载,以及测试用例执行,在此我先略过不讲每一个方法的具体含义。我将配合下一讲《21 |需求多变,测试框架如何动态挑选用例?》的内容,在下一节课中详细解释其实现原理及我的思考过程。

现在我们先按照上一节课中学习的方法,将此"测试框架"发布到 PyPI 中去:

注意,在更新包之前,要更改 setup.py 中的版本号 version 的值,不能跟之前的版本一样。

python
# 1. 打包程序
# 切换到项目根目录, 此处是iTestingDemoFramework
D:\_Automation\iTestingDemoFramework>python setup.py sdist build
# 2. 上传程序
# 在项目根目录下执行,本例为 iTestingDemoFramework
# 根据提示填写用户名和密码即可上传成功
D:\_Automation\iTestingDemoFramework>twine upload dist/*

发布成功后,通过PyPI链接访问安装或者直接命令行安装:

html
pip install iTesting

安装成功后,在命令行中分别执行以下两步:

html
iTesting
iTesting --env qa

你会发现, 环境切换后,测试框架会自动定位到当前环境下的测试数据。

如果你输入错误的测试环境变量,例如:

html
iTesting --env superEnv

你会发现测试框架会报错并提醒你只允许输入指定的测试环境,它们分别为 dev、qa、staging 和 prod。

采用这种方式,我们不仅可以达成测试环境秒切换,还能在测试开始前,就避免由于传入错误的测试环境变量,而导致的测试数据加载错误,从而引发测试运行错误。

总结

我来总结下本章节所讲的内容。本章节我们讨论了测试环境切换的实现原理,并分别在pytest 测试框架自研测试框架中根据原理实现了测试环境的切换。特别是在自研的测试框架中,我们达成测试框架切换的同时,还完成了测试用例查找、测试模块自动导入、测试数据加载,以及测试用例执行等动作。

而测试用例查找、测试模块自动导入、测试数据加载,以及测试用例执行这些动作是一切测试框架的必要组成部分。这些功能的实现所用到的基础和高级知识,我都在"模块一 打牢基础,从框架概念到代码实践"中讲到过,请你回顾下这部分内容,并尝试理解下本章节中的各个方式的实现原理。

在下节课中,我将为你讲解如何在自研框架中实现测试用例动态挑选。

如果你还想进一步了解创建自研测试框架的思考过程,请关注我的公众号 iTesting 并回复"测试框架"查看。


课程评价入口,挑选 5 名小伙伴赠送小礼品~