Pytest 测试用例自动生成:接口自动化进阶实践

引言:为什么我们要抛弃 "手写用例"?

在接口自动化实践中,当项目规模扩大、用例数量激增时,传统"手写Pytest用例"的模式往往会陷入瓶颈。

做接口自动化的同学,大概率都踩过这样的硬编码坑:写一条 "新增 - 查询 - 删除" 的流程用例,要重复写 3 个接口的请求、参数与断言代码;不同同事写的用例,有的把数据塞代码里,有的存 Excel,交接时看得头大;新手没代码基础,想加个用例还要先学 Python 语法。

一、遇到的3个核心痛点

我们公司在维护Pytest接口自动化项目时,深刻感受到手写用例带来的诸多困扰,随着项目规模扩大,问题愈发凸显:

  1. 用例编写效率低,重复劳动多。一条流程用例要调用多个接口,每个接口的请求头、参数、断言都要手写,浪费时间。
  2. 代码混乱无规范,维护成本高。测试同学各自为战,测试数据存储方式不一样(硬编码、data.py、Excel等);并且重复编写"发送请求""数据库查询"等通用功能,导致项目冗余代码堆积,新人接手时难以梳理逻辑。
  3. 门槛高,新手难上手。无Python基础的测试同学,需先学习requests库、Pytest语法、断言写法等技术内容,再结合混乱的项目结构,入门难度大,难以快速参与用例编写。

二、核心解决方案:数据与逻辑分离,自动生成测试用例

针对上述痛点,我们提出核心解决方案:测试人员仅负责"设计测试数据"(基于YAML),用例生成器自动完成"用例代码编写",通过"数据与逻辑分离"的思路,从根源解决手写用例的弊端。

1. 核心设计思路

  1. 把 "测试数据" 和 "用例逻辑" 彻底分开,使数据与逻辑解耦。将接口参数、断言规则、前置后置操作等测试数据,按约定格式存入YAML文件,测试人员无需关注代码逻辑,专注业务数据设计。
  2. 自动生成 Pytest 测试用例文件。定义一个用例生成器模块,,读取YAML文件中的测试数据,自动校验格式并生成标准化的Pytest用例代码,完全替代手写用例。

2. 方案核心优势

  1. 零代码门槛:测试人员无需编写Python代码,只需按模板填写YAML,降低技术要求。
  2. 输出标准化:生成的用例命名、目录结构、日志格式、断言方式完全统一,告别代码混乱。
  3. 批量高效生成:支持整个目录的 YAML 文件批量生成,一次生成上百条用例;
  4. 零维护成本:接口变更时,只改 YAML 数据,生成器重新运行即可更新用例。

3. 完整实施流程

完整流程为:编写YAML测试数据运行生成器自动生成测试用例执行自动生成的Pytest用例

三、关键步骤:从 YAML 设计到自动生成用例

下面通过"实操步骤+代码示例"的方式,详细说明方案的落地过程,以"新增设备→查询设备→解绑设备"的完整流程用例为例。

第一步:设计标准化YAML测试数据格式

YAML文件是方案的核心,需兼顾"完整性"与"易用性",既要覆盖接口测试的全场景需求,又要让测试人员容易理解和填写。

我们设计的YAML格式支持:基础信息配置、前置/后置操作、多接口步骤串联、多样化断言(常规断言+数据库断言)。

YAML示例如下(test_device_bind.yaml):

yaml 复制代码
# test_device_bind.yaml
testcase:
  name: bind_device  # 用例唯一标识,建议和文件名一致(去掉test_)
  description: 新增设备→查询设备→解绑设备  # 用例说明,清晰易懂
  allure:  # Allure报告配置,方便统计
    epic: 商家端
    feature: 设备管理
    story: 新增设备
    
  setups:  # 前置操作:执行测试前的准备(如数据库查询、数据初始化)
    - id: check_database
      description: 检查设备是否已存在
      operation_type: db  # 操作类型:db=数据库操作
      query: SELECT id FROM device WHERE imei = '865403062000000'
      expected: id  # 预期查询结果存在id字段

  steps:  # 核心测试步骤:每个步骤对应一个接口请求
    - id: device_bind  # 步骤唯一标识,用于跨步骤取值
      description: 新增设备
      project: merchant  # 所属项目(用于获取对应的host、token)
      path: '/device/bind'  # 接口路径
      method: POST  # 请求方法
      headers:
        Content-Type: 'application/json'
        Authorization: '{{merchant.token}}'  # 从全局变量取merchant的token
      data:  # 请求参数
        code: deb45899-957-10972b35515
        name: test_device_name
        imei: '865403062000000'
      assert:  # 断言配置,支持多种断言类型
        - type: equal  # 等于断言
          field: code  # 响应字段:code
          expected: 0  # 预期值
        - type: is not None  # 非空断言
          field: data.id  # 响应字段:data.id
        - type: equal
          field: message
          expected: success

    - id: device_list  # 第二个步骤:查询新增的设备
      description: 查询设备列表
      project: merchant
      path: '/device/list'
      method: GET
      headers:
        Content-Type: 'application/json'
        Authorization: '{{merchant.token}}'
      data:
        goodsId: '{{steps.device_bind.data.id}}'  # 跨步骤取值:从device_bind步骤的响应中取id
      assert:
        - type: equal
          field: status_code  # 断言HTTP状态码
          expected: 200
        - type: equal
          field: data.code
          expected: '{{steps.device_bind.data.code}}'  # 跨步骤取参数
        - type: mysql_query  # 数据库断言:查询设备是否存在
          query: SELECT id FROM users WHERE name='test_device_name'
          expected: id

  teardowns:  # 后置操作:测试完成后清理数据(如解绑设备、删除数据库记录)
    - id: device_unbind
      description: 解绑设备
      operation_type: api  # 操作类型:api=接口请求
      project: plateform
      path: '/device/unbind'
      method: POST
      headers:
        Content-Type: 'application/json'
        Authorization: '{{merchant.token}}'
      data:
        deviceId: '{{steps.device_bind.data.id}}'  # 跨步骤取新增设备的id
      assert:
        - type: equal
          field: code
          expected: 0

    - id: clear_database
      description: 清理数据库
      operation_type: db  # 数据库操作
      query: DELETE FROM device WHERE id = '{{steps.device_bind.data.id}}'

第二步:编写用例生成器(自动生成的 "核心引擎")

用例生成器的作用是:读取 YAML 文件→校验数据格式→生成标准的 Pytest 用例代码,支持单个文件或目录批量处理。

以下是生成器核心代码(case_generator.py),关键逻辑已添加详细注释:

python 复制代码
# case_generator.py
# @author:  xiaoqq

import os
import yaml
from utils.log_manager import log


class CaseGenerator:
	"""
	测试用例文件生成器
	"""
	def generate_test_cases(self, project_yaml_list=None, output_dir=None):
		"""
		根据YAML文件生成测试用例并保存到指定目录
		:param project_yaml_list: 列表形式,项目名称或YAML文件路径
		:param output_dir: 测试用例文件生成目录
		"""
		# 如果没有传入project_yaml_list,默认遍历tests目录下所有project
		if not project_yaml_list:
			project_yaml_list = ["tests/"]
		
		# 遍历传入的project_yaml_list
		for item in project_yaml_list:
			if os.path.isdir(item):  # 如果是项目目录,如tests/merchant
				self._process_project_dir(item, output_dir)
			elif os.path.isfile(item) and item.endswith('.yaml'):  # 如果是单个YAML文件
				self._process_single_yaml(item, output_dir)
			else:  # 如果是项目名称,如merchant
				project_dir = os.path.join("tests", item)
				self._process_project_dir(project_dir, output_dir)
		
		log.info("测试用例生成完毕!")
	
	def _process_project_dir(self, project_dir, output_dir):
		"""
		处理项目目录,遍历项目下所有YAML文件生成测试用例
		:param project_dir: 项目目录路径
		:param output_dir: 测试用例文件生成目录
		"""
		for root, dirs, files in os.walk(project_dir):
			for file in files:
				if file.endswith('.yaml'):
					yaml_file = os.path.join(root, file)
					self._process_single_yaml(yaml_file, output_dir)
	
	def _process_single_yaml(self, yaml_file, output_dir):
		"""
		处理单个YAML文件,生成对应的测试用例文件
		:param yaml_file: YAML文件路径
		:param output_dir: 测试用例文件生成目录
		"""
		# 读取YAML文件内容
		_test_data = self.load_test_data(yaml_file)
		validate_test_data = self.validate_test_data(_test_data)
		if not validate_test_data:
			log.warning(f"{yaml_file} 数据校验不通过,跳过生成测试用例。")
			return
		test_data = _test_data['testcase']
		teardowns = test_data.get('teardowns')
		validate_teardowns = self.validate_teardowns(teardowns)
		
		# 生成测试用例文件的相对路径。yaml文件路径有多个层级时,获取项目名称,以及tests/后、yaml文件名前的路径
		relative_path = os.path.relpath(yaml_file, 'tests')
		path_components = relative_path.split(os.sep)
		project_name = path_components[0] if path_components[0] else path_components[1]
		# 移除最后一个组件(文件名)
		if path_components:
			path_components.pop()  # 移除最后一个元素
		directory_path = os.path.join(*path_components)	# 重新组合路径
		directory_path = directory_path.rstrip(os.sep)	# 确保路径不以斜杠结尾
		
		module_name = test_data['name']
		description = test_data.get('description')
		# 日志记录中的测试用例名称
		case_name = f"test_{module_name} ({description})" if description is not None else f"test_{module_name}"
		
		# 判断test_data中的name是否存在"_",存在则去掉将首字母大写组成一个新的字符串,否则首字母大写
		module_class_name = (''.join(s.capitalize() for s in module_name.split('_'))
							 if '_' in module_name else module_name.capitalize())
		file_name = f'test_{module_name}.py'
		
		# 生成文件路径
		if output_dir:
			file_path = os.path.join(output_dir, directory_path, file_name)
		else:
			file_path = os.path.join('test_cases', directory_path, file_name)
		
		# 检查test_cases中对应的.py文件是否存在,存在则跳过生成
		if os.path.exists(file_path):
			log.info(f"测试用例文件已存在,跳过生成: {file_path}")
			return
		
		# 创建目录
		os.makedirs(os.path.dirname(file_path), exist_ok=True)
		
        # 解析Allure配置
		allure_epic = test_data.get("allure", {}).get("epic", project_name)
		allure_feature = test_data.get("allure", {}).get("feature")
		allure_story = test_data.get("allure", {}).get("story", module_name)
		
        # 生成并写入用例代码
		with open(file_path, 'w', encoding='utf-8') as f:
            # 写入导入语句
			f.write(f"# Auto-generated test module for {module_name}\n")
			f.write(f"from utils.log_manager import log\n")
			f.write(f"from utils.globals import Globals\n")
			f.write(f"from utils.variable_resolver import VariableResolver\n")
			f.write(f"from utils.request_handler import RequestHandler\n")
			f.write(f"from utils.assert_handler import AssertHandler\n")
			if validate_teardowns:
				f.write(f"from utils.teardown_handler import TeardownHandler\n")
				f.write(f"from utils.project_login_handler import ProjectLoginHandler\n")
			f.write(f"import allure\n")
			f.write(f"import yaml\n\n")
			
            # 写入类装饰器(Allure配置)
			f.write(f"@allure.epic('{allure_epic}')\n")
			if allure_feature:
				f.write(f"@allure.feature('{allure_feature}')\n")
			f.write(f"class Test{module_class_name}:\n")
			
            # 写入setup_class(类级前置操作)
			f.write(f"    @classmethod\n")
			f.write(f"    def setup_class(cls):\n")
			f.write(f"        log.info('========== 开始执行测试用例:{case_name} ==========')\n")
			f.write(f"        cls.test_case_data = cls.load_test_case_data()\n")	# 获取测试数据
			# 如果存在teardowns,则将步骤列表转换为字典, 在下面的测试方法中通过 id 查找步骤的信息
			if validate_teardowns:
				f.write(f"        cls.login_handler = ProjectLoginHandler()\n")
				f.write(f"        cls.teardowns_dict = {{teardown['id']: teardown for teardown in cls.test_case_data['teardowns']}}\n")
				f.write(f"        for teardown in cls.test_case_data.get('teardowns', []):\n")
				f.write(f"            project = teardown.get('project')\n")
				f.write(f"            if project:\n")
				f.write(f"                cls.login_handler.check_and_login_project(project, Globals.get('env'))\n")
			# 将步骤列表转换为字典, 在下面的测试方法中通过 id 查找步骤的信息
			f.write(f"        cls.steps_dict = {{step['id']: step for step in cls.test_case_data['steps']}}\n")
			f.write(f"        cls.session_vars = {{}}\n")
			f.write(f"        cls.global_vars = Globals.get_data()\n")  # 获取全局变量
			# 创建VariableResolver实例并保存在类变量中
			f.write(f"        cls.VR = VariableResolver(global_vars=cls.global_vars, session_vars=cls.session_vars)\n")
			f.write(f"        log.info('Setup completed for Test{module_class_name}')\n\n")
			
            # 写入加载测试数据的静态方法
			f.write(f"    @staticmethod\n")
			f.write(f"    def load_test_case_data():\n")
			f.write(f"        with open(r'{yaml_file}', 'r', encoding='utf-8') as file:\n")
			f.write(f"            test_case_data = yaml.safe_load(file)['testcase']\n")
			f.write(f"        return test_case_data\n\n")
			
            # 写入核心测试方法
			f.write(f"    @allure.story('{allure_story}')\n")
			f.write(f"    def test_{module_name}(self):\n")
			f.write(f"        log.info('Starting test_{module_name}')\n")
			# 遍历步骤,生成接口请求和断言代码
			for step in test_data['steps']:
				step_id = step['id']
				step_project = step.get("project") # 场景测试用例可能会请求不同项目的接口,需要在每个step中指定对应的project
				f.write(f"        # Step: {step_id}\n")
				f.write(f"        log.info(f'开始执行 step: {step_id}')\n")
				f.write(f"        {step_id} = self.steps_dict.get('{step_id}')\n")
				if step_project:
					f.write(f"        project_config = self.global_vars.get('{step_project}')\n")
				else:
					f.write(f"        project_config = self.global_vars.get('{project_name}')\n")
				# 生成请求代码
				f.write(f"        response = RequestHandler.send_request(\n")
				f.write(f"            method={step_id}['method'],\n")
				f.write(f"            url=project_config['host'] + self.VR.process_data({step_id}['path']),\n")
				f.write(f"            headers=self.VR.process_data({step_id}.get('headers')),\n")
				f.write(f"            data=self.VR.process_data({step_id}.get('data')),\n")
				f.write(f"            params=self.VR.process_data({step_id}.get('params')),\n")
				f.write(f"            files=self.VR.process_data({step_id}.get('files'))\n")
				f.write(f"        )\n")
				f.write(f"        log.info(f'{step_id} 响应:{{response}}')\n")
				f.write(f"        self.session_vars['{step_id}'] = response\n")
				# 生成断言代码
				if 'assert' in step:
					f.write(f"        db_config = project_config.get('mysql')\n")
					f.write(f"        AssertHandler().handle_assertion(\n")
					f.write(f"            asserts=self.VR.process_data({step_id}['assert']),\n")
					f.write(f"            response=response,\n")
					f.write(f"            db_config=db_config\n")
					f.write(f"        )\n\n")
			
			# 写入teardown_class(类级后置操作)
			if validate_teardowns:
				f.write(f"    @classmethod\n")
				f.write(f"    def teardown_class(cls):\n")
				f.write(f"        log.info('Starting teardown for the Test{module_class_name}')\n")
				for teardown_step in teardowns:
					teardown_step_id = teardown_step['id']
					teardown_step_project = teardown_step.get("project")
					f.write(f"        {teardown_step_id} = cls.teardowns_dict.get('{teardown_step_id}')\n")
					if teardown_step_project:
						f.write(f"        project_config = cls.global_vars.get('{teardown_step_project}')\n")
					else:
						f.write(f"        project_config = cls.global_vars.get('{project_name}')\n")
					# 处理API类型的后置操作
					if teardown_step['operation_type'] == 'api':
						f.write(f"        response = RequestHandler.send_request(\n")
						f.write(f"            method={teardown_step_id}['method'],\n")
						f.write(f"            url=project_config['host'] + cls.VR.process_data({teardown_step_id}['path']),\n")
						f.write(f"            headers=cls.VR.process_data({teardown_step_id}.get('headers')),\n")
						f.write(f"            data=cls.VR.process_data({teardown_step_id}.get('data')),\n")
						f.write(f"            params=cls.VR.process_data({teardown_step_id}.get('params')),\n")
						f.write(f"            files=cls.VR.process_data({teardown_step_id}.get('files'))\n")
						f.write(f"        )\n")
						f.write(f"        log.info(f'{teardown_step_id} 响应:{{response}}')\n")
						f.write(f"        cls.session_vars['{teardown_step_id}'] = response\n")
						if 'assert' in teardown_step:
							# if any(assertion['type'].startswith('mysql') for assertion in teardown_step['assert']):
							# 	f.write(f"        db_config = project_config.get('mysql')\n")
							f.write(f"        db_config = project_config.get('mysql')\n")
							f.write(f"        AssertHandler().handle_assertion(\n")
							f.write(f"            asserts=cls.VR.process_data({teardown_step_id}['assert']),\n")
							f.write(f"            response=response,\n")
							f.write(f"            db_config=db_config\n")
							f.write(f"        )\n\n")
					# 处理数据库类型的后置操作
					elif teardown_step['operation_type'] == 'db':
						f.write(f"        db_config = project_config.get('mysql')\n")
						f.write(f"        TeardownHandler().handle_teardown(\n")
						f.write(f"            asserts=cls.VR.process_data({teardown_step_id}),\n")
						f.write(f"            db_config=db_config\n")
						f.write(f"        )\n\n")
						f.write(f"        pass\n")
					else:
						log.info(f"未知的 operation_type: {teardown_step['operation_type']}")
						f.write(f"        pass\n")
				f.write(f"        log.info('Teardown completed for Test{module_class_name}.')\n")
			f.write(f"\n        log.info(f\"Test case test_{module_name} completed.\")\n")
		
		log.info(f"已生成测试用例文件: {file_path}")
		
	@staticmethod
	def load_test_data(test_data_file):
        """读取YAML文件,处理读取异常"""
		try:
			with open(test_data_file, 'r', encoding='utf-8') as file:
				test_data = yaml.safe_load(file)
			return test_data
		except FileNotFoundError:
			log.error(f"未找到测试数据文件: {test_data_file}")
		except yaml.YAMLError as e:
			log.error(f"YAML配置文件解析错误: {e},{test_data_file} 跳过生成测试用例。")
	
	@staticmethod
	def validate_test_data(test_data):
		 """校验测试数据格式是否符合要求"""
		if not test_data:
			log.error("test_data 不能为空.")
			return False
		if not test_data.get('testcase'):
			log.error("test_data 必须包含 'testcase' 键.")
			return False
		if not test_data['testcase'].get('name'):
			log.error("'testcase' 下的 'name' 字段不能为空.")
			return False
		steps = test_data['testcase'].get('steps')
		if not steps:
			log.error("'testcase' 下的 'steps' 字段不能为空.")
			return False
		
		for step in steps:
			if not all(key in step for key in ['id', 'path', 'method']):
				log.error("每个步骤必须包含 'id', 'path', 和 'method' 字段.")
				return False
			if not step['id']:
				log.error("步骤中的 'id' 字段不能为空.")
				return False
			if not step['path']:
				log.error("步骤中的 'path' 字段不能为空.")
				return False
			if not step['method']:
				log.error("步骤中的 'method' 字段不能为空.")
				return False

		return True
	
	@staticmethod
	def validate_teardowns(teardowns):
		"""
		验证 teardowns 数据是否符合要求
		:param teardowns: teardowns 列表
		:return: True 如果验证成功,否则 False
		"""
		if not teardowns:
			# log.warning("testcase 下的 'teardowns' 字段为空.")
			return False
		
		for teardown in teardowns:
			if not all(key in teardown for key in ['id', 'operation_type']):
				log.warning("teardown 必须包含 'id' 和 'operation_type' 字段.")
				return False
			if not teardown['id']:
				log.warning("teardown 中的 'id' 字段为空.")
				return False
			if not teardown['operation_type']:
				log.warning("teardown 中的 'operation_type' 字段为空.")
				return False
			
			if teardown['operation_type'] == 'api':
				required_api_keys = ['path', 'method', 'headers', 'data']
				if not all(key in teardown for key in required_api_keys):
					log.warning("对于 API 类型的 teardown,必须包含 'path', 'method', 'headers', 'data' 字段.")
					return False
				if not teardown['path']:
					log.warning("teardown 中的 'path' 字段为空.")
					return False
				if not teardown['method']:
					log.warning("teardown 中的 'method' 字段为空.")
					return False
			
			elif teardown['operation_type'] == 'db':
				if 'query' not in teardown or not teardown['query']:
					log.warning("对于数据库类型的 teardown,'query' 字段不能为空.")
					return False

		return True

if __name__ == '__main__':
    # 运行生成器,生成指定YAML文件的用例
	CG = CaseGenerator()
	CG.generate_test_cases(project_yaml_list=["tests/merchant/test_device_bind.yaml"])

第三步:运行生成器,自动生成Pytest用例

运行上述生成器代码后,会自动在指定目录(默认test_cases)生成标准化的Pytest用例文件(如test_device_bind.py),无需手动修改,可通过项目入口文件执行(入口文件详细代码可参考文末开源项目)。

生成的用例代码示例(关键部分):

python 复制代码
# Auto-generated test module for device_bind
from utils.log_manager import log
from utils.globals import Globals
from utils.variable_resolver import VariableResolver
from utils.request_handler import RequestHandler
from utils.assert_handler import AssertHandler
from utils.teardown_handler import TeardownHandler
import allure
import yaml

@allure.epic('商家端')
@allure.feature('设备管理')
class TestDeviceBind:
    @classmethod
    def setup_class(cls):
        log.info('========== 开始执行测试用例:test_device_bind (新增设备) ==========')
        cls.test_case_data = cls.load_test_case_data()
        cls.steps_dict = {step['id']: step for step in cls.test_case_data['steps']}
        cls.session_vars = {}
        cls.global_vars = Globals.get_data()
        cls.VR = VariableResolver(global_vars=cls.global_vars, session_vars=cls.session_vars)
        log.info('Setup 完成')

    @staticmethod
    def load_test_case_data():
        with open(r'tests/merchant\device_management\test_device_bind.yaml', 'r', encoding='utf-8') as file:
            test_case_data = yaml.safe_load(file)['testcase']
        return test_case_data

    @allure.story('新增设备')
    def test_device_bind(self):
        log.info('开始执行 test_device_bind')
        
        # Step: device_bind
        log.info(f'开始执行 step: device_bind')
        device_bind = self.steps_dict.get('device_bind')
        project_config = self.global_vars.get('merchant')
        response = RequestHandler.send_request(
            method=spu_deviceType['method'],
            url=project_config['host'] + self.VR.process_data(device_bind['path']),
            headers=self.VR.process_data(device_bind.get('headers')),
            data=self.VR.process_data(device_bind.get('data')),
            params=self.VR.process_data(device_bind.get('params')),
            files=self.VR.process_data(device_bind.get('files'))
        )
        log.info(f'device_bind 请求结果为:{response}')
        self.session_vars['device_bind'] = response
        db_config = project_config.get('mysql')
        AssertHandler().handle_assertion(
            asserts=self.VR.process_data(device_bind['assert']),
            response=response,
            db_config=db_config
        )

        # Step: device_list
        log.info(f'开始执行 step: device_list')
        device_list = self.steps_dict.get('device_list')
        project_config = self.global_vars.get('merchant')
        response = RequestHandler.send_request(
            method=device_list['method'],
            url=project_config['host'] + self.VR.process_data(device_list['path']),
            headers=self.VR.process_data(device_list.get('headers')),
            data=self.VR.process_data(device_list.get('data')),
            params=self.VR.process_data(device_list.get('params')),
            files=self.VR.process_data(device_list.get('files'))
        )
        log.info(f'device_list 请求结果为:{response}')
        self.session_vars['device_list'] = response
        db_config = project_config.get('mysql')
        AssertHandler().handle_assertion(
            asserts=self.VR.process_data(device_list['assert']),
            response=response,
            db_config=db_config
        )
      	
        log.info(f"Test case test_device_bind completed.")

	@classmethod
    def teardown_class(cls):
    	# 示例代码省略
    	......

        log.info(f'Teardown completed for TestDeviceBind.')

四、其他核心工具类

生成的用例文件依赖多个自定义工具类,这些工具类封装了通用功能,确保用例可正常运行。以下是各工具类的核心作用(详细实现可参考文末开源项目):

工具类 作用
log_manager 统一日志记录,输出用例执行过程
Globals 存储全局配置,如各项目的host、token、数据库连接信息、环境变量等。
VariableResolver 解析 YAML 中的变量(如{{steps.device_bind.data.id}}),支持全局变量、跨步骤变量取值。
RequestHandler 统一发送 HTTP 请求,处理超时、重试
AssertHandler 解析YAML中的断言配置,支持常规断言(等于、非空、包含等)和数据库断言。
TeardownHandler 处理后置操作,支持接口请求型和数据库操作型的后置清理逻辑。

五、方案落地价值:重构后我们获得了什么?

  1. 效率翻倍:用例编写时间减少 70%+。以前写一条 3 步流程用例要 15 分钟,现在写 YAML 只需要 5 分钟,生成用例秒级完成,还不用关心代码格式。
  2. 维护成本大幅降低:接口变更时,仅需修改对应YAML文件的相关字段(如参数、断言),重新运行生成器即可更新用例,无需全局搜索和修改代码,避免引入新bug。
  3. 入门门槛极低:无Python基础的测试人员,只需学习简单的YAML格式规则,按模板填写数据即可参与用例编写,团队协作效率大幅提升。
  4. 项目规范统一:所有用例的命名、目录结构、日志格式、断言方式均由生成器统一控制,彻底告别"各自为战"的混乱局面,项目可维护性显著增强。

六、后续优化方向

目前方案已满足核心业务需求,但仍有优化空间,后续将重点推进以下方向:

  1. 支持用例间依赖:实现用例级别的数据传递,比如用例A的输出作为用例B的输入,满足更复杂的业务场景。
  2. 增强YAML灵活性:支持在YAML中调用自定义Python函数(如生成随机数、加密参数),提升数据设计的灵活性。
  3. 简化YAML编写:增加通用配置默认值(如默认请求头、默认项目配置),减少重复填写工作。
  4. 多数据源支持:新增Excel/CSV导入功能,满足不熟悉YAML格式的测试人员需求,进一步降低使用门槛。

七、参考项目

如果想直接落地,可以参考我的开源示例项目:api-auto-test,里面包含了完整的工具类实现、YAML 模板、生成器代码和执行脚本。

相关推荐
小鸡吃米…2 小时前
Python - 发送电子邮件
开发语言·python
yaoh.wang2 小时前
力扣(LeetCode) 70: 爬楼梯 - 解法思路
python·算法·leetcode·面试·职场和发展·动态规划·递归
大佬,救命!!!2 小时前
python对应sql操作
开发语言·python·sql·学习笔记·学习方法
Learner__Q2 小时前
每天五分钟:二分查找-LeetCode高频题解析_day4
python·算法·leetcode
Darkershadow2 小时前
Python学习之使用pycharts
开发语言·python
写代码的【黑咖啡】2 小时前
Python 中的控制流程:掌握程序的逻辑跳转
服务器·javascript·python
Wpa.wk2 小时前
自动化测试(java) - PO模式了解
java·开发语言·python·测试工具·自动化·po模式
徐先生 @_@|||2 小时前
Java/Maven 对比 Python/PyPI
开发语言·python
嘻嘻嘻开心3 小时前
Collection接口
linux·windows·python