瓜瓜
瓜瓜
发布于 2024-04-18 / 33 阅读
0
0

基于unittest 从0 到1搭建接口自动化框架

最近开始学习接口自动化框架,并且初步完成了基于unittest的接口自动化框架的搭建。本文将详细介绍如何从0到1搭建一个基于unittest的接口自动化框架,并分享在搭建过程中需要注意的关键点。

接口自动化框架的设计思路

首先我们在搭建接口自动化框架时需要思考以下问题:

  1. 选择什么框架?(unittest VS Pytest)

选择 unittest 的核心原因:

- Python 原生支持,无需额外安装库,适合团队快速统一环境

- 面向对象的设计模式更符合传统开发习惯,降低上手成本

- 天然兼容 Jenkins 等 CI/CD 工具,便于后续持续集成扩展

虽然Pytest也是一个非常流行的测试框架,具有更丰富的插件生态和更简洁的语法,但unittest作为Python的原生测试框架,具有更好的兼容性和更低的入门门槛,特别适合需要快速统一环境的团队。

  1. 如何确保有完善的日志输出来快速定位问题?

  • 封装日志输出方法,将用例名称,用例描述,请求信息,断言失败异常信息等作为日志输出,提升代码可读性以及控制台输出信息可读性。

  1. 在团队协作开发中如何提高编码效率和代码可读性?

  • 断言方法封装:在封装断言方法后,团队成员都可以基于公共方法的调用。

  • 日志的自动输出:通过日志装饰器来实现用例中日志的自动输出,减少冗余代码,并且让测试执行流程更清晰

  • 变量管理:例如提取用户身份信息、host,path等接口请求的必要信息,将其放置在配置文件中进行管理。

  • 业务逻辑:每个用例执行前,需要确保测试数据的准确性,因此在每个用例执行之前,我们需要清空数据,新建测试数据。因此对于这类业务逻辑我们可以提前封装。

通过以上手段从而实现高效编码以及高可读性的代码。

基于以上思考,我们可以提炼出搭建接口自动化框架时需要的设计点:

  1. 业务逻辑公共方法的封装

  2. 断言逻辑的封装

  3. 日志输出的封装

  4. 环境信息的参数化

  5. 接口信息的参数化

  6. 测试用例的管理

提炼出以上内容后,我们可以基于以上设计要点进行项目的目录结构设计。

框架搭建步骤

step1: 基础框架的搭建

在设计目录结构时,我们需要遵循模块化和可扩展性的原则。通过将业务逻辑、通用方法、测试数据、测试用例等分别放置在不同的目录中,可以方便地进行维护和扩展。

project_root/  
├── business_common/    # 业务层通用方法  
│   ├── __init__.py  
│   ├── requestFunc.py   # get/post请求方法封装 
│   ├── dataClear.py   # 测试数据初始化/清理  
│   └── dataCreate.py   # 测试数据新建  
├── common/             # 核心工具封装  
│   ├── __init__.py  
│   ├── logOutput.py       # 日志装饰器+输出控制  
│   ├── assert_common.py   # 自定义断言方法(请求校验/JSON 验证)  
│   └── yamlLoad.py        # 配置文件读取(环境变量动态注入)  
├── data/               # 数据管理  
│   ├── env_config/     # 多环境配置  
│   │   ├── online/        # 生产环境  
│   │   ├── offiline/      # 测试环境  
│   ├── api_config/       # 接口信息管理  
│        
├── test_case/          # 测试用例集  
│   ├── __init__.py  
│   └── test_login/    # 登录接口测试集,按业务模块划分用例文件 
│   │   ├── test_input.py  # input 参数测试用例
│   │   ├── test_handle.py # 约束条件测试用例
│   │   ├── test_major.py  # 主流程测试用例 
├── logs/               # 自动生成的日志目录  
├── report/             # 测试报告输出 
└── main.py              # 主执行入口

business_common--业务层通用方法:数据清空,测试数据新建,请求方法的封装等。

common--通用方法封装:断言封装,日志封装,配置文件读取方法封装

logs--日志存储

data--配置存储 : 接口信息,测试账号信息

testCase--用例集

main--项目执行入口

step2: 实现common方法

  • 实现日志生成方法,满足用例的日志输出和日志存储的方法

import inspect
import time
from datetime import datetime
import os
from colorama import Fore
from main import DIR
import functools
timeout = 10

def case(text):
    """用于输出case相关信息,如用例名称,用例描述等"""
    formatted_time = datetime.now().strftime('%H:%M:%S:%f')[:-3]
    stack = inspect.stack() # 获取当前调用栈的信息
    # 获取待用case 函数的文件名以及代码行号
    code_path = f'{os.path.basename(stack[1].filename)}:{stack[1].lineno}'
    content = f'[Case]{formatted_time}-{code_path}>>{text}'
    print(Fore.LIGHTCYAN_EX + content)
    str_time = datetime.now().strftime("%Y%m%d")
    log_file = os.path.join(DIR, 'logs', f'{str_time}_info.log')
    with open(file=log_file, mode='a', encoding='utf-8') as f:
        f.write(content + '\n')


def info(text):
    """用于输出用例执行时的数据,如:用例步骤描述,请求数据,返回数据等"""
    formatted_time = datetime.now().strftime('%H:%M:%S:%f')[:-3]
    stack = inspect.stack()
    code_path = f'{os.path.basename(stack[1].filename)}:{stack[1].lineno}'
    content = f'[Info]{formatted_time}-{code_path}>>{text}'
    print(Fore.LIGHTGREEN_EX + content)
    str_time = datetime.now().strftime("%Y%m%d")
    log_file = os.path.join(DIR, 'logs', f'{str_time}_info.log')
    with open(file=log_file, mode='a', encoding='utf-8') as f:
        f.write(content + '\n')

def error(text):
    """用于输出用例执行时的数据,如:用例步骤描述,请求数据,返回数据等"""
    formatted_time = datetime.now().strftime('%H:%M:%S:%f')[:-3]
    stack = inspect.stack()
    code_path = f'{os.path.basename(stack[1].filename)}:{stack[1].lineno}'
    content = f'[error]{formatted_time}-{code_path}>>{text}'
    print(Fore.LIGHTCYAN_EX + content)
    str_time = datetime.now().strftime("%Y%m%d")
    # 将error log 写入 info.log文件,确保log的完整性
    log_file = os.path.join(DIR, 'logs', f'{str_time}_info.log')
    with open(file=log_file, mode='a', encoding='utf-8') as f:
        f.write(content + '\n')
    # 将 error 单独写入 error log,便于查看error 信息
    log_file = os.path.join(DIR, 'logs', f'{str_time}_error.log')
    with open(file=log_file, mode='a', encoding='utf-8') as f:
        f.write(content + '\n')

def case_decoration(func):
    """装饰器函数:可以自动的为每个测试用例输出case级别的log信息,
    并且为测试用例添加 执行统计时间和超时检测"""
    @functools.wraps(func) # 解决参数冲突问题
    def case_improve(*args, **kwargs):
        start = time.perf_counter() # 获取当前时间戳,记录测试用例执行的开始时间
        class_name = args[0].__class__.__name__ # 获取类名
        method_name = func.__name__ #获取方法的名称
        docstring = inspect.getdoc(func) # 获取方法注释
        case('-----------------------------------------------')
        case(f'Method Name:{method_name}, Class Name:{class_name}')
        case(f'Test Description:{docstring} ')
        func(*args, **kwargs)  # 执行测试用例
        end = time.perf_counter()
        handle_time = end - start
        case('Case run time: %.2fs' % handle_time)
        if handle_time > timeout:
            error('case run timeout!')

    return case_improve

def class_case_decoration(cls):
    """用例的日志类级别装饰器,用于自动为类中以 testCase 开头的方法应用 case_decoration 装饰器"""
    # 获取类中所有的成员方法
    for name, method in inspect.getmembers(cls, inspect.isfunction):
        if name.startswith('testCase'):
            # 只对以 testCase 开头 的方法进行装饰
            setattr(cls, name, case_decoration(method))
    return cls
  • 实现通用断言方法,满足接口返回体的断言方法

import unittest
from common.logOutput import error

class AssertCommon(unittest.TestCase):
    """"通用断言类:对于返回体内容的断言做了封装。"""
    def __assertIn(self, expect, actual):
        if expect not in actual:
            error_msg = f'The expected field does not exist. expect result:{expect}, actual result:{actual}'
            error('Assert failed!' + error_msg)
            self.fail(error_msg)
    def __assertEqual(self,expect, actual):
        if expect != actual:
            error_msg = f'Does not match the expected result,expect result:{expect}, actual result:{actual}'
            error('Assert failed!' + error_msg)
            self.fail(error_msg)

    def code_assert(self, expect, actual):
        # 对于返回状态码的校验
        if expect != actual:
            error_msg = f'res code different, expect code:{expect}, actual code:{actual}'
            error('Assert failed!' + error_msg)
            self.fail(error_msg)

    def json_assert(self, expect, actual):
        """
        json通用断言方法
        :param expect: 定义预期返回体
        :param actual: 实际返回的json
        :return: 断言成功返回None,断言失败触发fail
        """
        for key, value in expect.items():
            # 校验字段是否存在
            self.__assertIn(key,actual.keys())
        # 校验返回体中是否存在多余字段
        self.__assertEqual(len(expect.keys()), len(actual.keys()))

        for key, value in expect.items():
            # 进行数据类型的校验
            if isinstance(value, type):
                self.__assertEqual(value, type(actual[key]))
            # 对嵌套列表的类型和精准值进行校验
            elif isinstance(value, list):
                for i in range(len(value)):
                    if isinstance(value[i], type):
                        self.__assertEqual(value[i], type(actual[key][i]))
                        # 对于列表字典的嵌套结构,递归判断
                    elif isinstance(value[i], dict):
                        self.json_assert(value[i], actual[key][i])
                    else:
                        self.__assertEqual(value[i], actual[key][i])
            else:
                # 普通字段精准值校验
                self.__assertEqual(value, actual[key])
  • 实现yaml配置读取方法,满足不同维度配置的读取,并且实现环境切换的方法

from main import DIR,ENVIRON
import  yaml

class YamlRead:
    """yaml 配置文件的读取方法"""
    @staticmethod
    def env_config():
        """环境变量的读取"""
        with open(file=f'{DIR}/data/envConfig/{ENVIRON}/envConfig.yml', mode='r',encoding='utf-8') as f:
            return yaml.load(f, Loader = yaml.FullLoader)

    @staticmethod
    def api_config():
        """api变量的读取"""
        with open(file=f'{DIR}/data/apiConfig/apiConfig.yml', mode='r',encoding='utf-8') as f:
            return yaml.load(f, Loader = yaml.FullLoader)

step3:main实现

定义全局变量DIR,获取文件路径。

定义环境参数,用于控制执行测试用例时所使用的对应环境参数

实现不同范围的用例执行(全量用例 or 主流程测试)

import unittest
import os

DIR = os.path.dirname(os.path.abspath(__file__))
logs_dir = os.path.join(DIR, 'logs')
if not os.path.exists(logs_dir):
    os.makedirs(logs_dir)

ENVIRON = 'Offline'  # online -> 线上环境, Offline -> 测试环境。

if __name__ == '__main__':
    # 通过run_pattern 来控制当前执行用例的测试范围, all 是全部用例执行,smoking 是主流程测试
    run_pattern = 'all'
    if run_pattern == 'all':
        pattern = 'test_*.py'
    elif run_pattern == 'smoking':
        pattern = 'test_major*.py'
    else:
        pattern = run_pattern + '.py'
    suite = unittest.TestLoader().discover('./test_case', pattern=pattern)
    runner = unittest.TextTestRunner()
    runner.run(suite)

step4:转换所有主流程测试用例

在完成step3 之后,接口自动化的框架基本成熟,接下来需要将测试用例中所有接口的主流程进行转化。优先确保主流程的测试,可以在开发结束后就立即执行所有主流程的测试,以便于提前发现问题。

以其中一个接口的主流程为例:test_case/createContent/test_major.py

import time
import unittest

import requests

from business_common.dataClear import all_note_clear
from business_common.requestFunc import BusinessRequest
from common.logOutput import case, info, error, class_case_decoration
from parameterized import parameterized
from common.assert_common import AssertCommon
from common.yamlLoad import YamlRead

@class_case_decoration
class CreateNoteContent(unittest.TestCase):
    ac = AssertCommon()
    br = BusinessRequest()
    env_config = YamlRead().env_config()
    api_config = YamlRead().api_config()['create_noteContent']

    host = env_config['host']
    sid = env_config['sid']
    user_id = env_config['user_id']
    path = api_config['path']
    mustKeys = api_config['mustKeys']
    url = host + path

    def setUp(self) -> None:
        all_note_clear(self.user_id, self.sid)

    def testCase01_major(self):
        """新建内容主流程的测试用例"""
        headers = {
            'x-user-key': str(self.user_id),
            'cookie': f'{self.sid}'
        }
        note_id = str(int(time.time() * 1000))
        body = {
            "noteId": note_id,
            "title": "{测试tilte}",
            "summary": "{测试summary}",
            "body": "{测试body}"
        }
        expect_res = {
            "responseTime": int,
            "contentVersion": 1,
            "contentUpdateTime": int
        }
        info('[step] 请求接口创建便签内容')
        res = requests.post(url=self.url, headers=headers, json=body)
        # res = self.br.post(self.url, self.user_id, self.sid, body=body,headers=headers)
        self.ac.code_assert(200, res.status_code)
        self.ac.json_assert(expect_res, res.json())

step5:business_common实现

  1. 封装请求方法

  2. 封装数据创建和数据清理方法

step6:编写input测试用例

在编写 input 测试用例时,需要注意以下两点:

  • 通用的测试点实现参数化,这样可以减少冗余代码,并增强代码可读性。例如对必填项参数的校验(以其中一个测试用例为例):

  • 非通用的测试点直接输出测试用例

@parameterized.expand(mustKeys)
    def testCase05_input_mustKey_remove(self,key):
        """新建内容:必填项缺失校验的测试用例"""
        headers = {
            'x-user-key': str(self.user_id),
            'cookie': f'{self.sid}'
        }
        note_id = str(int(time.time() * 1000))
       body = {
            "noteId": note_id,
            "title": "{测试tilte}",
            "summary": "{测试summary}",
            "body": "{测试body}"
        }
        body.pop(key)
        expect_res = {
            "errorcode": -2009,
            "errormsg": "参数不正确"
        }
        res = self.br.post(self.url, self.user_id, self.sid, body=body,headers=headers)
        self.ac.code_assert(200, res.status_code)
        self.ac.json_assert(expect_res, res.json())

step7:编写全量的handle测试用例

对接口的约束条件部分的测试用例进行转化。确保各个接口在特定约束条件下也能正常响应。

以下是其中某个接口的某个约束条件测试用例:

    def testCase01_handle_updateDeleteNote(self):
        """更新已经被删除的note内容"""
        info('【前置步骤1】请求新增note主体和新增note内容接口,构建一条数据')
        note_datas = self.dCreate.create_note(1, self.sid, self.user_id)
        info('前置步骤2】将新建数据删除')
        all_note_clear(self.user_id, self.sid)
        body = {
            "noteId": note_datas[0]['noteId'],
            "title": "xxxxxxxxxxx",
            "summary": "xxxxxxxxxxx",
            "body": "xxxxxxxxxxx",
            "localContentVersion": 1,
            "BodyType": 0
        }
        info('[step] 请求内容更新接口')
        res = self.br.post(self.url, self.sid, self.user_id, body=body)
        expect_res = {
            "errorCode": -2009,
            "errorMsg": ""
        }
        self.ac.code_assert(400, res.status_code)
        self.ac.json_assert(expect_res, res.json())

总结

通过以上的介绍,我们完成了一个基于unittest的基础接口自动化框架的搭建。不过以上框架还有继续优化的空间,比如可以结合Allure生成详细的测试报告,也可以在遇到更复杂的业务逻辑时进行进一步的完善。


评论