最近开始学习接口自动化框架,并且初步完成了基于unittest的接口自动化框架的搭建。本文将详细介绍如何从0到1搭建一个基于unittest的接口自动化框架,并分享在搭建过程中需要注意的关键点。
接口自动化框架的设计思路
首先我们在搭建接口自动化框架时需要思考以下问题:
选择什么框架?(unittest VS Pytest)
选择 unittest
的核心原因:
- Python 原生支持,无需额外安装库,适合团队快速统一环境
- 面向对象的设计模式更符合传统开发习惯,降低上手成本
- 天然兼容 Jenkins 等 CI/CD 工具,便于后续持续集成扩展
虽然Pytest也是一个非常流行的测试框架,具有更丰富的插件生态和更简洁的语法,但unittest作为Python的原生测试框架,具有更好的兼容性和更低的入门门槛,特别适合需要快速统一环境的团队。
如何确保有完善的日志输出来快速定位问题?
封装日志输出方法,将用例名称,用例描述,请求信息,断言失败异常信息等作为日志输出,提升代码可读性以及控制台输出信息可读性。
在团队协作开发中如何提高编码效率和代码可读性?
断言方法封装:在封装断言方法后,团队成员都可以基于公共方法的调用。
日志的自动输出:通过日志装饰器来实现用例中日志的自动输出,减少冗余代码,并且让测试执行流程更清晰
变量管理:例如提取用户身份信息、host,path等接口请求的必要信息,将其放置在配置文件中进行管理。
业务逻辑:每个用例执行前,需要确保测试数据的准确性,因此在每个用例执行之前,我们需要清空数据,新建测试数据。因此对于这类业务逻辑我们可以提前封装。
通过以上手段从而实现高效编码以及高可读性的代码。
基于以上思考,我们可以提炼出搭建接口自动化框架时需要的设计点:
业务逻辑公共方法的封装
断言逻辑的封装
日志输出的封装
环境信息的参数化
接口信息的参数化
测试用例的管理
提炼出以上内容后,我们可以基于以上设计要点进行项目的目录结构设计。
框架搭建步骤
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实现
封装请求方法
封装数据创建和数据清理方法
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生成详细的测试报告,也可以在遇到更复杂的业务逻辑时进行进一步的完善。