
“精准测试” 这个概念对很多开发者来说并不陌生。其核心思想是:根据本次代码提交所修改的内容,精确识别出受影响的业务模块或接口,并仅针对这些部分执行测试用例,从而大幅提升测试效率和覆盖率。
目前网上关于精准测试的技术文章大多集中于 Java 生态(如结合 JaCoCo、JGit 做变更分析),而针对 .NET 技术栈的相关实践相对较少。本文将分享我们在一个大型 .NET 项目中实现的 精准测试系统原型,并通过 Python 脚本解析 C# 源码,从 Git 提交记录出发,自动定位变更接口及其依赖服务,为后续自动化测试与 AI 审查提供数据支持。
传统 CI/CD 流程中,每次代码提交都会触发全量回归测试,存在以下痛点:
而精准测试的目标就是:
✅ 只测该测的接口,只跑相关的场景用例
我们设计的这套精准测试机制具备以下功能:
| 功能 | 描述 |
|---|---|
| 🔍 变更文件识别 | 基于 Git 提交差异,获取所有被修改的 .cs 文件 |
| 🧠 接口方法提取 | 解析 C# 文件中的 public/private 方法名,特别是 Controller 和 Service 层 |
| 🔗 调用链追踪 | 根据命名规范与引用关系,反向推导出哪些 API 接口调用了变更的服务逻辑 |
| 🤖 后续扩展计划 | 将变更代码片段提交给 AI 模型进行缺陷检测;通过测试平台调用相关自动化用例 |
最终目标是构建一条闭环流水线:
为了验证系统的有效性,我们选取了过去 50+ 个历史版本 进行回溯测试:
| 指标 | 数据 |
|---|---|
| 成功识别变更接口数 | 95% 以上 |
| 未识别原因分析 | 主要集中在开发不规范写法(如匿名函数、动态调用、反射使用等)导致静态分析失效 |
✅ 结论:对于标准 MVC 架构下的 .NET 项目,该方法具有很高的实用性和准确性。
整个流程分为以下几个步骤:
graph TD
A[Git Commit] --> B[获取变更文件]
B --> C[扫描指定目录.cs文件]
C --> D[提取类名 & 方法名]
D --> E[分析方法体代码块]
E --> F[建立文件-方法-接口映射]
F --> G[匹配变更文件对应API]
G --> H[输出待测接口列表]
遍历目录,筛选出符合条件的 C# 文件(排除 debug、DTO、Models 等非主逻辑文件)
def get_file(dir):
"""
获取指定目录下需要分析的 .cs 文件
:param dir: 项目 src 目录路径
:return: 文件路径列表
"""
path = []
if os.path.isdir(dir):
for root, dirs, files in os.walk(dir):
for file in files:
p = os.path.join(root, file)
# 过滤关键词:只保留可能包含业务逻辑的文件
file_type_partten = 'Models|Module|DTO|Dto|Service|Controller'
if re.findall(file_type_partten, file).__len__() < 1:
continue
if p.endswith('.cs') and ('debug' not in p.lower()):
path.append(p)
return path
忽略 Models, DTO 类文件(通常无逻辑)
保留 Controller, Service 文件作为重点分析对象
✅ 函数:get_method(path)
利用正则匹配常见方法声明模式(同步、异步、泛型、Task 返回值等)
def get_method(path):
"""
从 C# 文件中提取所有方法名
支持 async Task<T>, Task<T>, void, static 等多种语法
:param path: 文件路径
:return: 方法名列表
"""
api_methods = []
with open(path, 'r+', encoding='utf-8', errors='ignore') as f:
content = f.read()
# 移除单行注释干扰
content = re.sub(r"\n\s+//.*", "", content)
# 匹配各种方法签名
methods = re.findall(
r'[public,private]\s+static.*\s+([A-Za-z]+)\(|'
r'[public,private]\s+static.*\s+([A-Za-z]+)<[^>]*>\(|'
r'Task<.*>\s+([A-Za-z]+)\(|'
r'Task<.*>\s+([A-Za-z]+)<[^>]*>\(|'
r'[private,protected,public].*async\s+Task.*[^,]\s+([A-Za-z]+)\s*\(|'
r'[private,protected,public].*async\s+Task.*\s+([A-Za-z]+)<[^>]*>\(',
content
)
for method in methods:
method = list(set(method))
for m in method:
if m != '':
api_methods.append(m.strip())
return list(set(api_methods))
public async Task<IActionResult> GetUser(int id)
private Task<bool> ValidateInput(string input)
public static string BuildToken()
public List<User> GetAllUsers()
识别继承关系和服务注入上下文
def get_class(path):
"""
提取文件中定义的所有类名(包括父类)
:param path: 文件路径
:return: 类名列表
"""
cname_list = []
if not os.path.exists(path):
return cname_list
with open(path, 'r+', encoding='utf-8', errors='ignore') as f:
item = f.read()
results = re.findall(r'public\s+(static\s+)?class\s+\w+', item)
for result in results:
# 提取类名
class_name = re.search(r'class\s+(\w+)', result).group(1)
cname_list.append(class_name)
return list(set(cname_list))
用于判断方法体是否完整读取完毕
def isValid(s):
"""
判断字符串中的括号是否合法闭合
用来判断代码块是否结束
"""
while '{}' in s or '()' in s or '[]' in s:
s = s.replace('{}', '')
s = s.replace('[]', '')
s = s.replace('()', '')
return s == ''
关键功能:不仅能拿到方法名,还能提取出完整的方法体内容,便于后续送入 AI 分析
def get_code(path, api, isline=False):
"""
获取某个方法的完整代码片段
:param path: 文件路径
:param api: 方法名
:param isline: 是否返回起止行号
:return: 代码字符串 或 (代码, 起始行, 结束行)
"""
count = 0
code = ''
brackets = '' # 记录 { } 数量
line_num = 0
start_line = 0
end_line = 0
with open(path, 'r+', encoding='utf-8') as file:
for line in file:
line_num += 1
# 跳过注释
stripped = line.strip()
if stripped.startswith("//") or stripped.startswith("///"):
continue
# 匹配方法声明行(注意单词边界)
if re.findall(r'\b%s\b' % re.escape(api), line) \
and (stripped.startswith('public') or stripped.startswith('private')):
start_line = line_num
brackets += ''.join(re.findall(r'[{}]', line))
code += line
count = 1
continue
if count > 0:
brackets += ''.join(re.findall(r'[{}]', line))
code += line
# 处理一行内完成的 lambda 表达式
if '=>' in line and 'async' in line:
end_line = line_num
break
# 括号闭合则结束
if isValid(brackets):
end_line = line_num
break
if isline:
return code, start_line, end_line
else:
return code
提供给 AI 模型做代码质量分析
生成变更摘要报告
差异对比工具集成
假设某 Service 被 Controller 使用,则可通过 using 关键字或路径结构关联
def get_file_path(srcpath, using):
"""
根据命名空间或路径关键字查找相关 .cs 文件
:param srcpath: 源码根目录
:param using: 关键词列表(如 ["UserService", "OrderController"])
:return: 匹配文件路径列表
"""
resfile = []
files = get_file(srcpath)
for file in files:
format_file = file.replace(srcpath + "\\", "").replace("\\", ".")
for u in using:
if u in format_file:
resfile.append(file)
return resfile
整合前面所有信息,建立「变更文件 → 方法 → 接口」映射关系
def get_change_api(change_file_path, side_api, projectcollection):
"""
查询哪个 API 受到了指定文件和方法的影响
:param change_file_path: 被修改的文件路径
:param side_api: 修改的方法名
:param projectcollection: 项目结构元数据集合
:return: 影响的接口列表
"""
change_apis = []
for project in projectcollection:
apis = project.get('apis', [])
for api in apis:
methods = api.get('methods', [])
for method in methods:
method_file = method.get('methodfile')
method_name = method.get('method')
# 匹配变更文件和方法
if change_file_path == method_file and side_api == method_name:
change_apis.append({
'project': project.get('name'),
'api_controller': api.get('apiname'),
'affected_method': method_name,
'file_path': change_file_path,
'code_snippet': get_code(change_file_path, method_name)
})
return change_apis
在 CI 环境中克隆仓库并初始化 Git
获取最近一次 commit 的变更文件列表
遍历每个变更文件,提取修改的方法
使用上述脚本分析这些方法所属的服务及调用链
输出一份 JSON 报告:affected_apis.json
调用测试平台 API 执行关联用例
repo = Repo(".")
commits = list(repo.iter_commits('main', max_count=1))
diff_files = commits[0].diff(commits[0].parents[0])
changed_cs_files = []
for diff_item in diff_files:
if diff_item.a_path.endswith(".cs"):
full_path = os.path.join(repo.working_tree_dir, diff_item.a_path)
changed_cs_files.append(full_path)
print("Changed .cs files:", changed_cs_files)
完整代码见:
https://github.com/1373822829/tools/tree/master/2.%E4%BB%A3%E7%A0%81%E6%89%AB%E6%8F%8F_.netcore