Desarrollo de pruebas de software Combate real | Automatización de interfaces Desarrollo del marco de pruebas (pytest + allure + aiohttp + generación automática de casos de uso)

En un futuro cercano, planeo dar prioridad a la cobertura de las pruebas de interfaz. Para esto, necesitamos desarrollar un marco de prueba. Después de pensar, todavía quiero hacer algo diferente esta vez.

  • Las pruebas de interfaz son más eficientes. Los probadores esperan recibir retroalimentación sobre los resultados pronto. Sin embargo, el número de interfaces es generalmente grande y aumentará. Por lo tanto, es necesario mejorar la eficiencia de ejecución.
  • De hecho, los casos de uso de las pruebas de interfaz también se pueden usar para pruebas de estrés simples, y las pruebas de estrés requieren concurrencia
  • Hay muchas cosas repetitivas en los casos de uso de las pruebas de interfaz. Los probadores solo deben prestar atención al diseño de las pruebas de interfaz. Es mejor automatizar estas
    tareas repetitivas . Pytest y allure son tan fáciles de usar que el nuevo marco debería integrarlos.
  • Los casos de uso para las pruebas de interfaz deben ser lo más concisos posible, preferiblemente usando yaml, de modo que los datos puedan mapearse directamente para solicitar datos. Escribir un caso de uso es lo mismo que completar los espacios en blanco. Es fácil promocionar a miembros que no tienen experiencia en automatización y estoy muy impresionado con la corrutina de Python. Interés, he estudiado durante un período de tiempo y siempre espero aplicar lo que he aprendido, así que decidí usar aiohttp para implementar solicitudes http. Pero pytest no admite bucles de eventos, y requiere un poco de esfuerzo si desea combinarlos. Así que seguí pensando, y el resultado de pensar es que puedo dividir todo el asunto en dos partes. La primera parte es leer casos de prueba de yaml, interfaces de prueba de solicitud http y recopilar datos de prueba. La segunda parte es generar dinámicamente casos de prueba aprobados por pytest en función de los datos de prueba y luego ejecutarlos para generar informes de prueba. De esta manera, los dos se pueden combinar perfectamente y se adapta perfectamente a mi visión. La idea se establece y luego se realiza.

    La primera parte (se requiere que todo el proceso sea asíncrono y no bloqueante)

    Leer el caso de prueba de yaml

    Diseñé una plantilla de caso de uso simple como esta. La ventaja de esto es que el nombre del parámetro y aiohttp.ClientSession (). Request (method, url, ** kwargs) son directamente correspondientes, y puedo pasarlo directamente a El método de solicitud evita varias conversiones, es conciso, elegante y expresivo.

args:
  - post
  - /xxx/add
kwargs:
  -
    caseName: 新增xxx
    data:
      name: ${gen_uid(10)}
validator:
  -
    json:
      successed: True

Aiofiles, una biblioteca de terceros, se puede usar para leer archivos de forma asincrónica. Yaml_load es una corrutina que puede garantizar que el proceso principal no se bloquee al leer los casos de prueba de yaml. Los datos de los casos de prueba se pueden obtener a través de await yaml_load ()

async def yaml_load(dir='', file=''):
    """
    异步读取yaml文件,并转义其中的特殊值
    :param file:
    :return:
    """
    if dir:
        file = os.path.join(dir, file)
    async with aiofiles.open(file, 'r', encoding='utf-8', errors='ignore') as f:
        data = await f.read()

    data = yaml.load(data)

    # 匹配函数调用形式的语法
    pattern_function = re.compile(r'^\${([A-Za-z_]+\w*\(.*\))}$')
    pattern_function2 = re.compile(r'^\${(.*)}$')
    # 匹配取默认值的语法
    pattern_function3 = re.compile(r'^\$\((.*)\)$')

    def my_iter(data):
        """
        递归测试用例,根据不同数据类型做相应处理,将模板语法转化为正常值
        :param data:
        :return:
        """
        if isinstance(data, (list, tuple)):
            for index, _data in enumerate(data):
                data[index] = my_iter(_data) or _data
        elif isinstance(data, dict):
            for k, v in data.items():
                data[k] = my_iter(v) or v
        elif isinstance(data, (str, bytes)):
            m = pattern_function.match(data)
            if not m:
                m = pattern_function2.match(data)
            if m:
                return eval(m.group(1))
            if not m:
                m = pattern_function3.match(data)
            if m:
                K, k = m.group(1).split(':')
                return bxmat.default_values.get(K).get(k)

            return data

    my_iter(data)

    return BXMDict(data)

Como puede ver, los casos de prueba también admiten cierta sintaxis de plantilla, como $ {function}, $ (a: b), etc., lo que puede ampliar enormemente la capacidad del evaluador para escribir casos de uso.

http请求测试接口

Las solicitudes HTTP se pueden usar directamente aiohttp.ClientSession (). Request (método, url, ** kwargs), http también es una rutina, que puede garantizar que las solicitudes de red no se bloqueen y los datos de prueba de la interfaz se pueden obtener a través de await http ()

async def http(domain, *args, **kwargs):
    """
    http请求处理器
    :param domain: 服务地址
    :param args:
    :param kwargs:
    :return:
    """
    method, api = args
    arguments = kwargs.get('data') or kwargs.get('params') or kwargs.get('json') or {}

    # kwargs中加入token
    kwargs.setdefault('headers', {}).update({'token': bxmat.token})
    # 拼接服务地址和api
    url = ''.join([domain, api])

    async with ClientSession() as session:
        async with session.request(method, url, **kwargs) as response:
            res = await response_handler(response)
            return {
                'response': res,
                'url': url,
                'arguments': arguments
            }

Recopilar datos de prueba

La concurrencia de la corrutina es realmente rápida. Para evitar que la respuesta del servicio cause el fusible, puede introducir asyncio.Semaphore (num) para controlar la concurrencia

async def entrace(test_cases, loop, semaphore=None):
    """
    http执行入口
    :param test_cases:
    :param semaphore:
    :return:
    """
    res = BXMDict()
    # 在CookieJar的update_cookies方法中,如果unsafe=False并且访问的是IP地址,客户端是不会更新cookie信息
    # 这就导致session不能正确处理登录态的问题
    # 所以这里使用的cookie_jar参数使用手动生成的CookieJar对象,并将其unsafe设置为True
    async with ClientSession(loop=loop, cookie_jar=CookieJar(unsafe=True), headers={'token': bxmat.token}) as session:
        await advertise_cms_login(session)
        if semaphore:
            async with semaphore:
                for test_case in test_cases:
                    data = await one(session, case_name=test_case)
                    res.setdefault(data.pop('case_dir'), BXMList()).append(data)
        else:
            for test_case in test_cases:
                data = await one(session, case_name=test_case)
                res.setdefault(data.pop('case_dir'), BXMList()).append(data)

        return res

async def one(session, case_dir='', case_name=''):
    """
    一份测试用例执行的全过程,包括读取.yml测试用例,执行http请求,返回请求结果
    所有操作都是异步非阻塞的
    :param session: session会话
    :param case_dir: 用例目录
    :param case_name: 用例名称
    :return:
    """
    project_name = case_name.split(os.sep)[1]
    domain = bxmat.url.get(project_name)
    test_data = await yaml_load(dir=case_dir, file=case_name)
    result = BXMDict({
        'case_dir': os.path.dirname(case_name),
        'api': test_data.args[1].replace('/', '_'),
    })
    if isinstance(test_data.kwargs, list):
        for index, each_data in enumerate(test_data.kwargs):
            step_name = each_data.pop('caseName')
            r = await http(session, domain, *test_data.args, **each_data)
            r.update({'case_name': step_name})
            result.setdefault('responses', BXMList()).append({
                'response': r,
                'validator': test_data.validator[index]
            })
    else:
        step_name = test_data.kwargs.pop('caseName')
        r = await http(session, domain, *test_data.args, **test_data.kwargs)
        r.update({'case_name': step_name})
        result.setdefault('responses', BXMList()).append({
            'response': r,
            'validator': test_data.validator
        })

    return result

El bucle de eventos es responsable de ejecutar la corrutina y devolver los resultados. En la colección de resultados final, utilicé el directorio de casos de prueba para clasificar los resultados, lo que sentó una buena base para la posterior generación automática de casos de prueba reconocidos por pytest.

def main(test_cases):
    """
    事件循环主函数,负责所有接口请求的执行
    :param test_cases:
    :return:
    """
    loop = asyncio.get_event_loop()
    semaphore = asyncio.Semaphore(bxmat.semaphore)
    # 需要处理的任务
    # tasks = [asyncio.ensure_future(one(case_name=test_case, semaphore=semaphore)) for test_case in test_cases]
    task = loop.create_task(entrace(test_cases, loop, semaphore))
    # 将协程注册到事件循环,并启动事件循环
    try:
        # loop.run_until_complete(asyncio.gather(*tasks))
        loop.run_until_complete(task)
    finally:
        loop.close()

    return task.result()

la segunda parte

Genere dinámicamente casos de prueba aprobados por pytest

Primero explique el mecanismo operativo de pytest. Pytest primero encontrará el archivo conftest.py en el directorio actual. Si lo encuentra, ejecútelo primero y luego busque el archivo .py al principio o al final de la prueba en el directorio especificado de acuerdo con los parámetros de la línea de comandos. Si lo encuentra, si lo encuentra, analice el dispositivo, si hay sesión o tipo de módulo, y el parámetro autotest = True o marcado como pytest.mark.usefixtures (a ...), ejecútelos primero; luego vaya a buscar la clase y el método a su vez Las reglas son similares. Probablemente tal proceso.
Se puede ver que la clave para ejecutar la prueba pytest es que debe haber al menos un archivo testxx.py reconocido por el mecanismo de descubrimiento de pytest, el archivo contiene la clase TestxxClass y la clase tiene al menos un método def testxx (self).
No hay ningún archivo de prueba reconocido por pytest, por lo que mi idea es crear primero un archivo de prueba guiado, que es responsable de hacer que pytest se mueva. Puede usar pytest.skip () para omitir el método de prueba. Entonces, nuestro objetivo es cómo generar dinámicamente casos de uso después de que se activa pytest, y luego descubrir estos casos de uso, ejecutar estos casos de uso y generar informes de prueba de una sola vez.

# test_bootstrap.py
import pytest

class TestStarter(object):

    def test_start(self):
        pytest.skip('此为测试启动方法, 不执行')

Lo que pienso es a través de accesorios, porque los dispositivos tienen la capacidad de configuración, por lo que al definir un dispositivo cuyo alcance es sesión y luego marcar el uso en TestStarter, puedo preprocesar algunas cosas antes de importar TestStarter, luego generaré operaciones de casos de uso Ponlo en este accesorio para completar el objetivo.

# test_bootstrap.py
import pytest

@pytest.mark.usefixtures('te', 'test_cases')
class TestStarter(object):

    def test_start(self):
        pytest.skip('此为测试启动方法, 不执行')

pytest tiene un parámetro --rootdir. El propósito principal de este dispositivo es obtener el directorio de destino a través de --rootdir, encontrar los archivos de prueba .yml en él y obtener los datos de prueba después de ejecutarlo, y luego crear un testxx.py para cada directorio El contenido del archivo es el contenido de la variable de contenido, y luego estos parámetros se pasan al método pytest.main () para ejecutar la prueba del caso de prueba, es decir, ¡se ejecuta otra pytest dentro de pytest! Finalmente, elimine el archivo de prueba generado. Tenga en cuenta que el dispositivo debe definirse en conftest.py, porque pytest tiene la capacidad de autodescubrir el contenido definido en conftest y no requiere importación adicional.

# conftest.py
@pytest.fixture(scope='session')
def test_cases(request):
    """
    测试用例生成处理
    :param request:
    :return:
    """
    var = request.config.getoption("--rootdir")
    test_file = request.config.getoption("--tf")
    env = request.config.getoption("--te")
    cases = []
    if test_file:
        cases = [test_file]
    else:
        if os.path.isdir(var):
            for root, dirs, files in os.walk(var):
                if re.match(r'\w+', root):
                    if files:
                        cases.extend([os.path.join(root, file) for file in files if file.endswith('yml')])

    data = main(cases)

    content = """
import allure

from conftest import CaseMetaClass

@allure.feature('{}接口测试({}项目)')
class Test{}API(object, metaclass=CaseMetaClass):

    test_cases_data = {}
"""
    test_cases_files = []
    if os.path.isdir(var):
        for root, dirs, files in os.walk(var):
            if not ('.' in root or '__' in root):
                if files:
                    case_name = os.path.basename(root)
                    project_name = os.path.basename(os.path.dirname(root))
                    test_case_file = os.path.join(root, 'test_{}.py'.format(case_name))
                    with open(test_case_file, 'w', encoding='utf-8') as fw:
                        fw.write(content.format(case_name, project_name, case_name.title(), data.get(root)))
                    test_cases_files.append(test_case_file)

    if test_file:
        temp = os.path.dirname(test_file)
        py_file = os.path.join(temp, 'test_{}.py'.format(os.path.basename(temp)))
    else:
        py_file = var

    pytest.main([
        '-v',
        py_file,
        '--alluredir',
        'report',
        '--te',
        env,
        '--capture',
        'no',
        '--disable-warnings',
    ])

    for file in test_cases_files:
        os.remove(file)

    return test_cases_files

Como puede ver, hay una clase TestxxAPI en el archivo de prueba, que tiene solo un atributo test_cases_data y ningún método testxx, por lo que no es un caso de prueba reconocido por pytest y no puede ejecutarse en absoluto. Entonces, ¿cómo resuelve este problema? La respuesta es CaseMetaClass.

function_express = """
def {}(self, response, validata):
    with allure.step(response.pop('case_name')):
        validator(response,validata)"""

class CaseMetaClass(type):
    """
    根据接口调用的结果自动生成测试用例
    """

    def __new__(cls, name, bases, attrs):
        test_cases_data = attrs.pop('test_cases_data')
        for each in test_cases_data:
            api = each.pop('api')
            function_name = 'test' + api
            test_data = [tuple(x.values()) for x in each.get('responses')]
            function = gen_function(function_express.format(function_name),
                                    namespace={'validator': validator, 'allure': allure})
            # 集成allure
            story_function = allure.story('{}'.format(api.replace('_', '/')))(function)
            attrs[function_name] = pytest.mark.parametrize('response,validata', test_data)(story_function)

        return super().__new__(cls, name, bases, attrs)

CaseMetaClass es una metaclase. Lee el contenido del atributo test_cases_data y luego genera objetos de método dinámicamente. Cada interfaz es un método único. Después de ser decorada por la función de informe de prueba detallada de allure y la función de prueba parametrizada proporcionada por pytest, Asigne el objeto de método al atributo de clase de test + api, es decir, TestxxAPI tiene varios métodos testxx después de que se genera. En este momento, si pytest se ejecuta internamente, pytest también puede encontrar estos casos de uso y ejecutarlos.

def gen_function(function_express, namespace={}):
    """
    动态生成函数对象, 函数作用域默认设置为builtins.__dict__,并合并namespace的变量
    :param function_express: 函数表达式,示例 'def foobar(): return "foobar"'
    :return:
    """
    builtins.__dict__.update(namespace)
    module_code = compile(function_express, '', 'exec')
    function_code = [c for c in module_code.co_consts if isinstance(c, types.CodeType)][0]
    return types.FunctionType(function_code, builtins.__dict__)

En el método de generar un objeto, se debe tener en cuenta que el espacio de nombres del problema, preferiblemente el predeterminado, pasa los archivos incorporados. Dict , luego pasa al método mediante parámetros de espacio de nombres personalizados.

Seguimiento (el archivo de prueba yml se genera automáticamente)

En este punto, se completaron las funciones principales del marco. Después de varios proyectos, el efecto superó completamente las expectativas. No sea demasiado genial para escribir casos de uso, no corra demasiado rápido y el informe de prueba es claro y atractivo, pero yo Todavía estoy un poco cansado, ¿por qué?
Mi proceso actual de prueba de interfaz es, si el proyecto integra swagger, usar swagger para obtener información de interfaz y crear manualmente casos de uso para el proyecto basados ​​en la información de interfaz. Este proceso es muy repetitivo y engorroso, debido a que nuestra plantilla de caso de uso se ha corregido aproximadamente, y los casos prácticos son las diferencias entre algunos parámetros como directorio, nombre del caso de uso, método, etc., entonces creo que este proceso puede automatizarse por completo.
Debido a que swagger tiene una página web, puedo extraer información clave para crear automáticamente archivos de prueba .yml, al igual que configurar un estante. Una vez que se genera el estante del proyecto, puedo ir al caso de diseño para completar los parámetros.
Así que intenté analizar el HTML de la página de inicio de la solicitud swagger y me decepcionó que no hubiera datos reales. Más tarde supuse que se usaba ajax. Cuando abrí la consola del navegador, encontré la solicitud api-docs. Son datos json, entonces el problema es simple y el análisis de la página web es innecesario.

import re
import os
import sys

from requests import Session

template ="""
args:
  - {method}
  - {api}
kwargs:
  -
    caseName: {caseName}
    {data_or_params}:
        {data}
validator:
  -
    json:
      successed: True
"""

def auto_gen_cases(swagger_url, project_name):
    """
    根据swagger返回的json数据自动生成yml测试用例模板
    :param swagger_url:
    :param project_name:
    :return:
    """
    res = Session().request('get', swagger_url).json()
    data = res.get('paths')

    workspace = os.getcwd()

    project_ = os.path.join(workspace, project_name)

    if not os.path.exists(project_):
        os.mkdir(project_)

    for k, v in data.items():
        pa_res = re.split(r'[/]+', k)
        dir, *file = pa_res[1:]

        if file:
            file = ''.join([x.title() for x in file])
        else:
            file = dir

        file += '.yml'

        dirs = os.path.join(project_, dir)

        if not os.path.exists(dirs):
            os.mkdir(dirs)

        os.chdir(dirs)

        if len(v) > 1:
            v = {'post': v.get('post')}
        for _k, _v in v.items():
            method = _k
            api = k
            caseName = _v.get('description')
            data_or_params = 'params' if method == 'get' else 'data'
            parameters = _v.get('parameters')

            data_s = ''
            try:
                for each in parameters:
                    data_s += each.get('name')
                    data_s += ': \n'
                    data_s += ' ' * 8
            except TypeError:
                data_s += '{}'

        file_ = os.path.join(dirs, file)

        with open(file_, 'w', encoding='utf-8') as fw:
            fw.write(template.format(
                method=method,
                api=api,
                caseName=caseName,
                data_or_params=data_or_params,
                data=data_s
            ))

        os.chdir(project_)

Ahora quiero comenzar la cobertura de prueba de interfaz de un proyecto. Siempre que el proyecto integre swagger, el estante del proyecto se puede generar en segundos. Los probadores solo deben concentrarse en diseñar casos de prueba de interfaz. Creo que es muy significativo para el equipo de prueba promover y usar. También es más conveniente para personas perezosas como yo.

Supongo que te gusta

Origin blog.51cto.com/14293469/2562153
Recomendado
Clasificación