"""
测试用例生成器 - 完整实现
从需求文档(PRD)到结构化用例,完整链路
依赖安装:
pip install openai openpyxl
requests
使用方式:
python test_case_generator.py
--prd prd.txt --project "登录模块"
"""
import json
import time
import argparse
from pathlib import Path
from openai import OpenAI
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment,
PatternFill
import requests
# ============================================================
# 配置区
# ============================================================
# 大模型配置
LLM_API_KEY = "your-api-key-here"
LLM_BASE_URL = "https://api.openai.com/v1"
LLM_MODEL = "gpt-4o-mini"
# 测试平台API配置
PLATFORM_BASE_URL = "https://test-platform.company.com/api/v1"
PLATFORM_TOKEN = "your-platform-token-here"
# 批处理参数
BATCH_SIZE = 6 # 超过6个测试点自动分批,6是舒适区(S4-03经验)
HISTORY_CASE_LIMIT = 20 # 历史用例最多取20条
# 初始化大模型客户端
llm_client = OpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
# ============================================================
# 大模型调用封装
# ============================================================
def call_llm(prompt: str, input_data: str, temperature:
float = 0.3) -> str:
"""
调用大模型,返回纯文本响应。
temperature默认0.3,测试场景需要稳定性而非创造性。
Args:
prompt:
系统提示词/指令
input_data:
输入内容(PRD文本、上一步输出等)
temperature:
温度参数
Returns:
大模型返回的文本
"""
try:
response
= llm_client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role":
"system", "content": prompt},
{"role":
"user", "content": input_data}
],
temperature=temperature,
response_format={"type":
"json_object"} #
强制JSON输出
)
return
response.choices[0].message.content
except Exception as e:
print(f"[ERROR]
LLM调用失败: {e}")
raise
def call_llm_json(prompt: str, input_data: str,
temperature: float = 0.3) -> dict:
"""
调用大模型并解析为JSON dict。
自动处理大模型偶尔返回非标准JSON的情况。
"""
raw = call_llm(prompt, input_data,
temperature)
try:
return json.loads(raw)
except json.JSONDecodeError:
# 尝试提取JSON块(大模型有时会包裹markdown代码块)
import re
match =
re.search(r'\{.*\}|\[.*\]', raw, re.DOTALL)
if match:
return
json.loads(match.group())
raise ValueError(f"无法解析LLM输出为JSON:
{raw[:200]}")
# ============================================================
# 人工确认环节
# ============================================================
def confirm(label: str, content: str) ->
bool:
"""
人工确认环节。打印内容,等待用户确认。
这是"暂停键"——在关键步骤留人工把关。
Args:
label: 步骤名称
content:
待确认内容
Returns:
True=确认通过,False=需要重跑
"""
print(f"\n{'='*60}")
print(f"【人工确认】{label}")
print(f"{'='*60}")
print(content)
print(f"{'='*60}")
while True:
choice =
input("确认无误?(y=继续 / n=重跑此步 / q=退出): ").strip().lower()
if choice
== 'y':
return
True
elif choice
== 'n':
return
False
elif choice
== 'q':
print("用户退出。")
exit(0)
else:
print("请输入
y/n/q")
# ============================================================
# Prompt 模板(每一步的指令)
# ============================================================
# 第一步:拆测试维度
PROMPT_SPLIT_DIMENSIONS = """\
你是一位资深测试工程师。请分析以下需求文档,按以下五个维度拆分测试点:
1. 功能验证
2. 边界值
3. 异常场景
4. 性能关注点
5. 安全风险
每个维度列出3-5个测试点,测试点要具体、可测试。
输出格式(JSON):
{
"dimensions": [
{"维度":
"功能验证", "测试点": ["登录成功",
"登录失败", "密码错误"]},
{"维度": "边界值", "测试点":
["密码最短长度", "密码最长长度"]}
]
}
注意:
- 测试点要基于需求文档内容,不要凭空捏造
- 每个维度的测试点不超过5个
- 如果某个维度在需求中没有相关内容,返回空数组
"""
# 第二步:按维度+历史参考写用例
PROMPT_GENERATE_CASES = """\
你是一位资深测试工程师。请根据以下测试维度和历史用例参考,编写具体的测试用例。
输入数据格式(JSON):
{
"dimensions": [...],
// 测试维度和测试点
"history_cases":
[...] // 同模块历史用例参考(可能为空)
}
每条用例必须包含以下字段:
- 模块: 功能模块名称
- 场景描述: 一句话说清楚测什么场景
- 前置条件: 执行用例前需要满足的条件
- 操作步骤: 逐步描述操作(数组,每步一个字符串)
- 预期结果: 执行后应该出现的结果
- 优先级: P0/P1/P2
- 备注: 特殊说明(可为空字符串)
输出格式(JSON):
{
"cases": [
{
"模块":
"登录",
"场景描述":
"正确的账号密码登录成功",
"前置条件":
"账号已注册且状态正常",
"操作步骤":
["打开登录页面", "输入正确账号", "输入正确密码",
"点击登录按钮"],
"预期结果":
"登录成功,跳转到首页",
"优先级":
"P0",
"备注":
""
}
]
}
要求:
- 参考历史用例的写法风格,但不要照搬
- 每个测试点至少生成一条用例
- 操作步骤要具体到可执行,不要写"执行相关操作"这种模糊描述
"""
# 覆盖率自查
PROMPT_COVERAGE_CHECK = """\
你是一位测试质量审查员。请对照需求文档原文,检查测试用例的覆盖率。
输入数据(JSON):
{
"prd": "需求文档原文",
"test_cases": [生成的测试用例列表]
}
请逐段检查需求文档,找出"有需求描述但没有对应测试用例"的地方。
输出格式(JSON):
{
"gaps": [
{
"prd段落":
"需求文档中的原文片段",
"遗漏说明":
"为什么没有覆盖到",
"建议":
"应该补充什么用例"
}
],
"coverage_rate": "预估覆盖率(如85%)"
}
注意:
- 只报告确实遗漏的,不要把"已覆盖但不够细"的算作遗漏
- 如果所有段落都有覆盖,返回空gaps数组
"""
# 不确定区域标注
PROMPT_UNCERTAINTY = """\
你是一位测试工程师。在刚才生成测试用例的过程中,有些地方你可能不确定。
请回顾你生成的用例和需求文档,标注你"不确定"或"觉得可能遗漏"的地方。
不确定的典型情况:
- 需求文档描述模糊,你做了假设但不确定是否正确
- 涉及业务规则,你没有足够的上下文判断
- 跨模块联动,你不确定是否需要测试
- 边界值不明确,你按常规处理但可能有特殊情况
输出格式(JSON):
{
"uncertainties":
[
{
"区域":
"不确定的具体内容",
"原因":
"为什么不确定",
"建议操作":
"建议人工确认什么"
}
]
}
注意:
- 诚实标注,不确定就说不确定
- 不要为了显得全面而编造不确定项
- 如果确实没有不确定的地方,返回空数组
"""
# ============================================================
# 测试平台API封装(S4-05的封装层)
# ============================================================
class TestPlatformAPI:
"""
测试平台API封装。
所有HTTP请求细节集中在这里,主流程只调用方法。
换平台/换接口/换鉴权方式,只改这里。
"""
def __init__(self, base_url:
str, token: str):
self.base_url
= base_url.rstrip("/")
self.session
= requests.Session()
self.session.headers.update({
"Authorization":
f"Bearer {token}",
"Content-Type":
"application/json"
})
def search_cases(self, module_name: str, limit:
int = 20) -> list:
"""
搜索同模块的历史用例。
用于第二步的历史参考。
"""
url = f"{self.base_url}/testcases/search"
params =
{
"module": module_name,
"limit":
limit,
"sort":
"update_time:desc" # 按更新时间倒序,取最新的
}
resp =
self.session.get(url, params=params, timeout=10)
resp.raise_for_status()
data = resp.json()
# 实际API返回可能包了一层壳,取data字段
if isinstance(data,
dict) and "data" in data:
return
data["data"]
return data
def create_case(self, case_data:
dict) -> dict:
"""
创建单条测试用例。
返回平台返回的用例信息(含用例ID)。
"""
url = f"{self.base_url}/testcases"
resp = self.session.post(url,
json=case_data, timeout=10)
resp.raise_for_status()
return
resp.json()
def batch_create_cases(self,
cases: list) -> list:
"""
批量创建测试用例。
返回每条用例的创建结果(含用例ID和平台链接)。
"""
results
= []
for i, case
in enumerate(cases):
try:
result
= self.create_case(case)
results.append({
"场景描述":
case.get("场景描述", ""),
"status":
"success",
""case_id":
result.get("data", {}).get("id",
""),
""url":
result.get("data", {}).get("url",
"")
})
print(f" [{i+1}/{len(cases)}] 创建成功: {case.get('场景描述',
'')}")
except
Exception as e:
results.append({
""场景描述":
case.get("场景描述", ""),
""status":
"failed",
""error":
str(e)
})
print(f" [{i+1}/{len(cases)}] 创建失败: {case.get('场景描述',
'')} - {e}")
#
避免请求太快被限流
time.sleep(0.3)
return results
# ============================================================
# 数据转换层(S4-05:大模型输出 → API格式)
# ============================================================
def transform_cases_for_platform(cases: list,
project_name: str) -> list:
"""
将大模型输出的用例格式,转换为测试平台API要求的格式。
字段映射、结构重组、默认值填充都在这里。
大模型输出格式:
{"模块":
"...", "场景描述": "...",
"操作步骤": [...], "预期结果": "...",
...}
平台API格式:
{"name":
"...", "module": "...",
"test_steps": [{"action":
"...", "expected": "..."}],
"expect_result": "...",
"priority": "...", "project":
"..."}
"""
transformed = []
for case in cases:
# 字段映射
platform_case
= {
"name":
case.get("场景描述", ""),
"module":
case.get("模块", ""),
"project":
project_name,
"priority":
case.get("优先级", "P2"),
"precondition":
case.get("前置条件", ""),
"expect_result":
case.get("预期结果", ""),
}
# 操作步骤转换:从字符串数组转为对象数组
steps = case.get("操作步骤",
[])
if isinstance(steps, list):
platform_case["test_steps"]
= [
{"action": step, "expected":
""} for step in steps
]
elif isinstance(steps, str):
platform_case["test_steps"]
= [{"action": steps, "expected":
""}]
else:
platform_case["test_steps"]
= []
# 必填字段校验
if not platform_case["name"]:
print(f"
[WARN] 用例缺少场景描述,跳过: {case}")
continue
if not platform_case["test_steps"]:
print(f"
[WARN] 用例缺少操作步骤,跳过: {platform_case['name']}")
continue
transformed.append(platform_case)
return transformed
def transform_history_for_llm(history_cases:
list) -> list:
"""
将平台返回的历史用例,转换为大模型参考用的精简格式。
平台返回的字段很多,只取有用的,避免token浪费。
"""
simplified = []
for case in history_cases[:HISTORY_CASE_LIMIT]:
simplified.append({
"场景": case.get("name", ""),
"步骤":
[s.get("action", "") if
isinstance(s, dict) else str(s)
for s in case.get("test_steps", [])],
"预期":
case.get("expect_result", ""),
"优先级": case.get("priority",
"")
})
return simplified
# ============================================================
# Excel导出模块(第二版新增)
# ============================================================
def export_to_excel(cases: list, output_path:
str, coverage_gaps: list = None):
"""
按团队模板格式导出Excel。
包含用例明细和覆盖率自查报告两个Sheet。
"""
wb = Workbook()
# --- Sheet1: 测试用例 ---
ws = wb.active
ws.title = "测试用例"
# 表头样式
header_font = Font(bold=True,
size=11)
header_fill = PatternFill(start_color="4472C4",
end_color="4472C4", fill_type="solid")
header_font_white = Font(bold=True,
size=11, color="FFFFFF")
headers = ["用例编号",
"模块", "场景描述", "前置条件",
"操作步骤",
"预期结果",
"优先级", "备注"]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1,
column=col, value=header)
cell.font
= header_font_white
cell.fill
= header_fill
cell.alignment
= Alignment(horizontal="center", vertical="center")
# 填充用例数据
for idx, case in enumerate(cases,
1):
steps =
case.get("操作步骤", [])
if isinstance(steps,
list):
steps_text = "\n".join(f"{i}.
{s}" for i, s in enumerate(steps, 1))
else:
steps_text = str(steps)
row_data
= [
f"TC-{idx:04d}",
case.get("模块",
""),
case.get("场景描述",
""),
case.get("前置条件", ""),
steps_text,
case.get("预期结果",
""),
case.get("优先级",
"P2"),
case.get("备注",
"")
]
for col,
value in enumerate(row_data, 1):
cell = ws.cell(row=idx + 1, column=col, value=value)
cell.alignment
= Alignment(wrap_text=True, vertical="top")
# 列宽设置
col_widths = [12, 15, 30,
25, 40, 30, 8, 15]
for col, width in enumerate(col_widths,
1):
ws.column_dimensions[chr(64
+ col)].width = width
# --- Sheet2: 覆盖率自查报告 ---
if coverage_gaps:
ws2 = wb.create_sheet("覆盖率自查")
gap_headers
= ["PRD段落", "遗漏说明", "建议补充"]
for col,
header in enumerate(gap_headers, 1):
cell = ws2.cell(row=1, column=col, value=header)
cell.font
= header_font_white
cell.fill
= PatternFill(start_color="ED7D31",
end_color="ED7D31", fill_type="solid")
cell.alignment
= Alignment(horizontal="center")
for idx,
gap in enumerate(coverage_gaps, 2):
ws2.cell(row=idx,
column=1, value=gap.get("prd段落", ""))
ws2.cell(row=idx,
column=2, value=gap.get("遗漏说明", ""))
ws2.cell(row=idx,
column=3, value=gap.get("建议", ""))
for
col in range(1, 4):
ws2.cell(row=idx, column=col).alignment
= Alignment(wrap_text=True, vertical="top")
ws2.column_dimensions["A"].width
= 40
ws2.column_dimensions["B"].width
= 35
ws2.column_dimensions["C"].width
= 35
wb.save(output_path)
print(f"\n[INFO] Excel已导出:
{output_path}")
# ============================================================
# 分批处理逻辑(S4-03:超过BATCH_SIZE自动分批)
# ============================================================
def generate_cases_batched(dimensions: list,
history_ref: list) -> list:
"""
按维度分批生成用例。
超过BATCH_SIZE个测试点就分批,结果合并。
防止大模型注意力衰减导致后面的测试点被"遗忘"。
"""
# 展开所有测试点
all_points = []
for dim in dimensions:
for point
in dim.get("测试点", []):
all_points.append({"维度":
dim["维度"], "测试点": point})
print(f"\n[INFO] 共 {len(all_points)}
个测试点,BATCH_SIZE={BATCH_SIZE}")
# 分批
if len(all_points) <= BATCH_SIZE:
batches
= [all_points]
else:
batches
= [all_points[i:i + BATCH_SIZE]
for i in range(0, len(all_points), BATCH_SIZE)]
print(f"[INFO]
测试点超过{BATCH_SIZE}个,分 {len(batches)} 批处理")
# 逐批生成
all_cases = []
for i, batch in enumerate(batches):
print(f"\n[INFO]
正在处理第 {i+1}/{len(batches)} 批...")
input_data
= json.dumps({
"dimensions":
batch,
"history_cases":
history_ref
}, ensure_ascii=False)
result =
call_llm_json(PROMPT_GENERATE_CASES, input_data)
batch_cases
= result.get("cases", [])
all_cases.extend(batch_cases)
print(f"[INFO]
第 {i+1} 批生成 {len(batch_cases)} 条用例")
return all_cases
# ============================================================
# 主流程:完整7步链路
# ============================================================
def generate_test_cases(prd_text: str, project_name:
str,
output_excel: str = None,
write_to_platform:
bool = False) -> list:
"""
测试用例生成器主流程。
完整链路:
1. 输入PRD → 拆测试维度(人工确认)
2. 搜历史用例 → 筛选相关的作为参考
3. 按维度+历史参考 → 写具体用例(自动分批)
4. 覆盖率自查 → 标注PRD中未被覆盖的段落
5. 人工补充 → 补"不确定区域"
6. 按团队模板 → 导出Excel
7. 调API → 写入测试平台(确认后写入)
Args:
prd_text:
PRD需求文档文本
project_name:
项目/模块名称
output_excel:
Excel输出路径(为空则不导出)
write_to_platform:
是否写入测试平台
Returns:
生成的测试用例列表
"""
print(f"\n{'#'*60}")
print(f"# 测试用例生成器启动")
print(f"# 项目: {project_name}")
print(f"# PRD长度: {len(prd_text)}
字符")
print(f"{'#'*60}")
# ============================================================
# Step 1: 拆测试维度
# ============================================================
print("\n--- Step 1/7:
拆测试维度 ---")
while True:
result =
call_llm_json(PROMPT_SPLIT_DIMENSIONS,
prd_text)
dimensions
= result.get("dimensions", [])
# 展示拆分结果
display
= []
for dim
in dimensions:
points = dim.get("测试点", [])
display.append(f"【{dim['维度']}】{'
| '.join(points) if points else '(空)'}")
ok = confirm("测试维度拆分",
"\n".join(display))
if ok:
break
print("[INFO]
重新拆分维度...")
total_points = sum(len(d.get("测试点",
[])) for d in dimensions)
print(f"\n[INFO] 维度拆分完成:
{len(dimensions)}个维度, {total_points}个测试点")
# ============================================================
# Step 2: 搜索历史用例作为参考
# ============================================================
print("\n--- Step 2/7:
搜索历史用例 ---")
history_ref = []
try:
platform
= TestPlatformAPI(PLATFORM_BASE_URL, PLATFORM_TOKEN)
raw_history
= platform.search_cases(module_name=project_name,
limit=HISTORY_CASE_LIMIT)
# 转换为精简格式,避免token浪费
history_ref
= transform_history_for_llm(raw_history)
print(f"[INFO]
找到 {len(raw_history)} 条历史用例,精简后取 {len(history_ref)}
条作为参考")
except Exception as e:
print(f"[WARN]
获取历史用例失败(不影响主流程): {e}")
print("[INFO]
将不使用历史参考,仅基于PRD生成")
# ============================================================
# Step 3: 按维度 + 历史参考 → 写具体用例(自动分批)
# ============================================================
print("\n--- Step 3/7:
生成测试用例 ---")
all_cases = generate_cases_batched(dimensions,
history_ref)
print(f"\n[INFO] 共生成
{len(all_cases)} 条测试用例")
# ============================================================
# Step 4: 覆盖率自查
# ============================================================
print("\n--- Step 4/7:
覆盖率自查 ---")
coverage_input = json.dumps({
"prd":
prd_text,
"test_cases":
all_cases
}, ensure_ascii=False)
coverage_result = call_llm_json(PROMPT_COVERAGE_CHECK,
coverage_input)
gaps = coverage_result.get("gaps",
[])
coverage_rate = coverage_result.get("coverage_rate",
"未知")
print(f"[INFO] 预估覆盖率:
{coverage_rate}")
if gaps:
print(f"[INFO]
发现 {len(gaps)} 处遗漏:")
for i, gap
in enumerate(gaps, 1):
print(f" {i}. PRD段落: {gap.get('prd段落',
'')[:50]}...")
print(f" 遗漏说明: {gap.get('遗漏说明', '')}")
print(f"
建议: {gap.get('建议', '')}")
else:
print("[INFO]
未发现明显遗漏")
# ============================================================
# Step 5: 人工补充(不确定区域 + 遗漏补充)
# ============================================================
print("\n--- Step 5/7:
人工补充 ---")
# 标注不确定区域
uncertainty_input = json.dumps({
"prd":
prd_text,
"test_cases":
all_cases
}, ensure_ascii=False)
uncertainty_result = call_llm_json(PROMPT_UNCERTAINTY,
uncertainty_input)
uncertainties = uncertainty_result.get("uncertainties",
[])
if uncertainties:
print(f"\n[AI标注的"不确定区域"](共{len(uncertainties)}处):")
for i,
u in enumerate(uncertainties, 1):
print(f" {i}. 区域: {u.get('区域', '')}")
print(f" 原因: {u.get('原因', '')}")
print(f"
建议: {u.get('建议操作', '')}")
print(f"\n[覆盖率遗漏](共{len(gaps)}处):")
for i, gap in enumerate(gaps,
1):
print(f"
{i}. {gap.get('prd段落', '')[:60]}")
print(f"
建议: {gap.get('建议', '')}")
# 展示生成的用例供确认
case_display = []
for idx, case in enumerate(all_cases,
1):
case_display.append(
f"{idx}. [{case.get('优先级', 'P2')}] {case.get('场景描述',
'')} "
f"({case.get('模块',
'')})"
)
ok = confirm(
"用例确认(请检查后决定是否继续)",
"\n".join(case_display)
+ f"\n\n覆盖率: {coverage_rate}"
f"\n遗漏:
{len(gaps)}处 | 不确定: {len(uncertainties)}处"
)
if not ok:
print("[INFO]
用例需要调整,重新生成...")
# 这里可以选择重新生成或让用户手动编辑后继续
# 简化处理:直接退出,用户修改PRD后重跑
print("[提示]
如需调整,请修改PRD后重新运行,或手动编辑生成的用例")
return
all_cases
# ============================================================
# Step 6: 导出Excel
# ============================================================
if output_excel:
print(f"\n---
Step 6/7: 导出Excel ---")
export_to_excel(all_cases,
output_excel, gaps)
# ============================================================
# Step 7:
写入测试平台(确认后写入)
# ============================================================
if write_to_platform:
print("\n---
Step 7/7: 写入测试平台 ---")
ok = confirm("写入测试平台",
f"即将写入
{len(all_cases)} 条用例到测试平台\n"
f"f"项目:
{project_name}\n"
f"f"平台:
{PLATFORM_BASE_URL}\n\n"
f"f"确认写入?")
if not ok:
print("[INFO] 跳过写入平台,用例已导出到本地")
return
all_cases
# 数据转换:大模型格式 → 平台API格式
platform_cases = transform_cases_for_platform(all_cases,
project_name)
print(f"[INFO]
数据转换完成: {len(platform_cases)} 条用例待写入")
# 批量写入
platform = TestPlatformAPI(PLATFORM_BASE_URL,
PLATFORM_TOKEN)
results = platform.batch_create_cases(platform_cases)
# 统计结果
success_count = sum(1
for r in results if r["status"] ==
"success")
failed_count = len(results)
- success_count
print(f"\n[结果]
写入完成: 成功 {success_count} 条, 失败 {failed_count}
条")
# 打印平台链接
for r in results:
if
r["status"] == "success"
and r.get("url"):
print(f" - {r['场景描述']}:
{r['url']}")
print(f"\n{'#'*60}")
print(f"# 测试用例生成完成!")
print(f"# 共 {len(all_cases)}
条用例")
print(f"# 覆盖率: {coverage_rate}")
if output_excel:
print(f"# Excel: {output_excel}")
print(f"{'#'*60}")
return all_cases
# ============================================================
# 命令行入口
# ============================================================
def main():
parser = argparse.ArgumentParser(description="测试用例生成器")
parser.add_argument("--prd",
required=True, help="PRD文件路径")
parser.add_argument("--project",
required=True, help="项目/模块名称")
parser.add_argument("--excel",
default=None, help="Excel输出路径(不指定则不导出)")
parser.add_argument("--platform",
action="store_true", help="是否写入测试平台")
args = parser.parse_args()
# 读取PRD
prd_path = Path(args.prd)
if not prd_path.exists():
print(f"[ERROR] PRD文件不存在: {prd_path}")
return
prd_text = prd_path.read_text(encoding="utf-8")
if not prd_text.strip():
print("[ERROR] PRD文件为空")
return
# 默认Excel输出路径
excel_path = args.excel
or f"测试用例_{args.project}_{int(time.time())}.xlsx"
# 运行
generate_test_cases(
prd_text=prd_text,
project_name=args.project,
output_excel=excel_path,
write_to_platform=args.platform
)
if __name__ == "__main__":
main() |