Files
xlwings/vba_test_runner.py
Misaka 2cb22df60c feat: VBA Call Chain Tracking and Full Project Testing System
## Phase 1: Core Call Chain Tracking

### New Features
- CallStack class module: Manually maintain VBA call stack
- Enhanced TestLogger: Integrated call stack management
  - LogEntry: Record on procedure entry
  - LogExit: Pop on procedure exit
  - LogError: Capture complete call chain
- Weave all procedures in module to support call chain tracking

### Data Model Updates
- TestResult new fields: error_module, call_chain
- Result format: ERROR|Module.Proc|Line|Number|Desc|CallChain

### Key Fixes
- Fixed LogSuccess overwriting error state
  LogSuccess now only sets success when no error exists
- Fixed incomplete call chain due to weaving only single procedure
  Now weaves all procedures in the entire module

## Phase 2: Full Project Testing

### New Methods
- CodeWeaver.parse_modules: Parse all VBA modules
- CodeWeaver.weave_module_all_procedures: Weave all procedures in single module
- CodeWeaver.weave_all_modules: Weave all procedures in multiple modules
- TestRunner.discover_all_tests: Auto-discover all entry points
- TestRunner.run_all_tests: Batch execute all tests
- TestRunner._weave_all_modules_inplace: In-place weave all modules

### CLI Interface
- Mode 1: python vba_test_runner.py <file> --all (full project testing)
- Mode 2: python vba_test_runner.py <file> <module> <proc>... (specific testing)

## Test Verification

### Call Chain Tracking Example
TestSuccessfulProcedure -> TestErrorProcedure -> TestTypeMismatch
Output:
  Call Chain: Module1.TestSuccessfulProcedure -> Module1.TestErrorProcedure -> Module1.TestTypeMismatch
  Location: Module1.TestTypeMismatch:20
  Source: y = 20

### Statistics
- New code: ~500 lines
- New methods: 8
- Test scenarios: Multi-level nested call chain verification passed

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 22:44:49 +08:00

960 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
VBA 自动化测试与精确报错系统
通过代码编织技术实现 VBA 宏的精确行号报错定位
"""
import re
import sys
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import xlwings as xw
@dataclass
class TestResult:
"""测试结果数据类"""
procedure_name: str
success: bool
error_number: int = 0
error_description: str = ""
error_line: int = 0
source_code: str = ""
error_module: str = "" # 新增:错误发生的模块
call_chain: str = "" # 新增:完整调用链
class CodeWeaver:
"""代码编织器 - 为 VBA 代码注入行号标签和错误处理"""
def __init__(self):
self.line_counter = 10 # 行号标签从 10 开始,步长 10
def parse_modules(self, vba_project) -> Dict[str, Dict]:
"""
解析VBA项目中的所有模块
返回: {
模块名: {
'type': 'StdModule' | 'ClassModule',
'code': str,
'procedures': {过程名: {...过程信息}},
'component': VBComponent
}
}
"""
modules = {}
for component in vba_project.VBComponents:
# 只处理标准模块和类模块
if component.Type in [1, 2]: # 1=StdModule, 2=ClassModule
module_name = component.Name
code = self._get_component_code(component)
procedures = self.parse_procedures(code)
modules[module_name] = {
'type': 'StdModule' if component.Type == 1 else 'ClassModule',
'code': code,
'procedures': procedures,
'component': component
}
return modules
def _get_component_code(self, component) -> str:
"""从VBComponent获取代码"""
code_module = component.CodeModule
line_count = code_module.CountOfLines
return code_module.Lines(1, line_count) if line_count > 0 else ""
def parse_procedures(self, code: str) -> Dict[str, Dict]:
"""
解析 VBA 代码中的所有 Sub/Function
返回: {
过程名: {
'start_line': int,
'end_line': int,
'type': 'Sub' or 'Function'
}
}
"""
procedures = {}
lines = code.split('\n')
# 正则匹配 Sub/Function 定义行
proc_pattern = re.compile(
r'^\s*(Public|Private|Friend)?\s*(Sub|Function)\s+(\w+)\s*\((.*?)\)(?:\s+As\s+(\w+))?',
re.IGNORECASE | re.MULTILINE
)
end_pattern = re.compile(r'^\s*End\s+(Sub|Function)', re.IGNORECASE)
current_proc = None
proc_start_line = 0
for i, line in enumerate(lines):
# 检查是否是过程定义
match = proc_pattern.match(line)
if match:
visibility, proc_type, proc_name = match.groups()[:3]
current_proc = proc_name
proc_start_line = i
continue
# 检查是否是过程结束
if current_proc:
end_match = end_pattern.match(line)
if end_match:
procedures[current_proc] = {
'start_line': proc_start_line,
'end_line': i,
'type': proc_type
}
current_proc = None
return procedures
def weave_procedure_with_callstack(self, code: str, proc_name: str, module_name: str) -> Tuple[str, Dict[int, str]]:
"""
为指定过程注入调用栈管理、行号和错误处理阶段1增强版
返回: (编织后的代码, {行号: 源代码})
"""
procedures = self.parse_procedures(code)
if proc_name not in procedures:
raise ValueError(f"Procedure '{proc_name}' not found in code")
proc_info = procedures[proc_name]
lines = code.split('\n')
# 提取过程代码(包括定义行和结束行)
proc_lines = lines[proc_info['start_line']:proc_info['end_line'] + 1]
# 注入行号
woven_lines, source_map = self._inject_line_numbers(proc_lines)
# 注入错误处理和调用栈管理
woven_lines = self._inject_error_handler_with_callstack(woven_lines, proc_name, module_name)
# 替换原代码中的过程
result_lines = (
lines[:proc_info['start_line']] +
woven_lines +
lines[proc_info['end_line'] + 1:]
)
return '\n'.join(result_lines), source_map
def weave_module_all_procedures(self, code: str, module_name: str) -> Tuple[str, Dict[int, str]]:
"""
为单个模块中的所有过程注入调用栈管理和错误处理
这对于调用链追踪至关重要:当测试一个过程时,
该过程调用的其他过程也需要被编织,以便记录 LogEntry/LogExit
返回: (编织后的代码, {行号: 源代码})
"""
procedures = self.parse_procedures(code)
woven_code = code
source_map = {}
# 为模块中的每个过程注入代码
for proc_name in procedures.keys():
woven_code, proc_source_map = self.weave_procedure_with_callstack(
woven_code, proc_name, module_name
)
source_map.update(proc_source_map)
return woven_code, source_map
def weave_all_modules(self, modules: Dict[str, Dict]) -> Dict[str, str]:
"""
为所有模块中的所有过程注入调用栈管理和错误处理
返回: {模块名: 编织后的代码}
"""
woven_modules = {}
for module_name, module_info in modules.items():
original_code = module_info['code']
woven_code = original_code
source_map = {}
# 为每个过程注入代码
for proc_name in module_info['procedures'].keys():
woven_code, proc_source_map = self.weave_procedure_with_callstack(
woven_code, proc_name, module_name
)
source_map.update(proc_source_map)
woven_modules[module_name] = woven_code
return woven_modules
def weave_procedure(self, code: str, proc_name: str) -> Tuple[str, Dict[int, str]]:
"""
为指定过程注入行号和错误处理
返回: (编织后的代码, {行号: 源代码})
"""
procedures = self.parse_procedures(code)
if proc_name not in procedures:
raise ValueError(f"Procedure '{proc_name}' not found in code")
proc_info = procedures[proc_name]
lines = code.split('\n')
# 提取过程代码(包括定义行和结束行)
proc_lines = lines[proc_info['start_line']:proc_info['end_line'] + 1]
# 注入行号
woven_lines, source_map = self._inject_line_numbers(proc_lines)
# 注入错误处理
woven_lines = self._inject_error_handler(woven_lines, proc_name)
# 替换原代码中的过程
result_lines = (
lines[:proc_info['start_line']] +
woven_lines +
lines[proc_info['end_line'] + 1:]
)
return '\n'.join(result_lines), source_map
def _inject_line_numbers(self, lines: List[str]) -> Tuple[List[str], Dict[int, str]]:
"""
注入行号标签10, 20, 30...
跳过:
- Dim 声明
- 注释行
- 空行
- 现有的 On Error 语句
- 过程定义和结束行
"""
result_lines = []
source_map = {}
self.line_counter = 10
# 检查是否是声明行Dim, Private, Public 等)
is_declaration = re.compile(r'^\s*(Dim|Private|Public|Friend|Const|Type|Declare)\b', re.IGNORECASE)
is_comment = re.compile(r'^\s*\'')
is_empty = re.compile(r'^\s*$')
is_on_error = re.compile(r'^\s*On\s+Error', re.IGNORECASE)
is_proc_def = re.compile(r'^\s*(Public|Private|Friend)?\s*(Sub|Function)', re.IGNORECASE)
is_proc_end = re.compile(r'^\s*End\s+(Sub|Function)', re.IGNORECASE)
is_exit_stmt = re.compile(r'^\s*Exit\s+(Sub|Function)', re.IGNORECASE)
in_declaration_block = True
for line in lines:
# 检查是否声明块结束(遇到第一个可执行语句)
if in_declaration_block and not is_declaration.match(line) and not is_comment.match(line) and not is_empty.match(line):
if not is_proc_def.match(line) and not is_proc_end.match(line):
in_declaration_block = False
# 判断是否需要注入行号
should_inject = (
not in_declaration_block and
not is_comment.match(line) and
not is_empty.match(line) and
not is_on_error.match(line) and
not is_proc_def.match(line) and
not is_proc_end.match(line) and
not is_exit_stmt.match(line)
)
if should_inject:
# 保存源代码映射
source_map[self.line_counter] = line.strip()
# 注入行号标签
result_lines.append(f"{self.line_counter}")
result_lines.append(line)
self.line_counter += 10
else:
result_lines.append(line)
return result_lines, source_map
def _inject_error_handler_with_callstack(self, lines: List[str], proc_name: str, module_name: str) -> List[str]:
"""
在过程开头添加: On Error GoTo Auto_Err_Handler
注入调用栈管理: LogEntry/LogExit
删除任何现有的 On Error 语句
在过程结束前添加错误处理块
"""
result_lines = []
on_error_pattern = re.compile(r'^\s*On\s+Error.*$', re.IGNORECASE)
proc_end_pattern = re.compile(r'^\s*End\s+(Sub|Function)', re.IGNORECASE)
exit_pattern = re.compile(r'^\s*(Exit\s+(Sub|Function))', re.IGNORECASE)
# 确定过程类型
proc_type = "Sub"
for line in lines:
if re.match(r'^\s*(Public|Private|Friend)?\s*Function', line, re.IGNORECASE):
proc_type = "Function"
break
# 在过程定义后插入 LogEntry 和 On Error 语句
inserted_log_entry = False
inserted_error_handler = False
error_handler_inserted = False
for i, line in enumerate(lines):
# 跳过现有的 On Error 语句
if on_error_pattern.match(line):
continue
# 检查是否是过程结束
is_end = proc_end_pattern.match(line)
if is_end and not error_handler_inserted:
# 在 End Sub/Function 之前插入错误处理块
result_lines.append(f"")
result_lines.append(f" Call TestLogger.LogExit()")
result_lines.append(f" Call TestLogger.LogSuccess()")
if proc_type == "Sub":
result_lines.append(f" Exit Sub")
else:
result_lines.append(f" Exit Function")
result_lines.append(f"")
result_lines.append(f"Auto_Err_Handler_{proc_name}:")
result_lines.append(f" Call TestLogger.LogError(\"{proc_name}\", \"{module_name}\", Err.Number, Err.Description, Erl)")
result_lines.append(f" Call TestLogger.LogExit()")
result_lines.append(line) # 添加 End Sub/Function
error_handler_inserted = True
elif is_end:
# 跳过后续的 End 语句(不应该有)
pass
elif exit_pattern.match(line):
# 跳过现有的 Exit 语句,替换为 LogExit + Exit
result_lines.append(f" Call TestLogger.LogExit()")
result_lines.append(line)
else:
# 在第一个非声明、非空行后插入 LogEntry 和 On Error
if not inserted_log_entry and not re.match(r'^\s*(\'|Dim|Private|Public|Const|$)', line, re.IGNORECASE):
if not re.match(r'^\s*(Public|Private|Friend)?\s*(Sub|Function)', line, re.IGNORECASE):
result_lines.append(f" Call TestLogger.LogEntry(\"{proc_name}\", \"{module_name}\")")
result_lines.append(f" On Error GoTo Auto_Err_Handler_{proc_name}")
inserted_log_entry = True
inserted_error_handler = True
result_lines.append(line)
# 如果没有找到 End追加到末尾
if not error_handler_inserted:
result_lines.append(f"")
result_lines.append(f" Call TestLogger.LogExit()")
result_lines.append(f" Call TestLogger.LogSuccess()")
if proc_type == "Sub":
result_lines.append(f" Exit Sub")
else:
result_lines.append(f" Exit Function")
result_lines.append(f"")
result_lines.append(f"Auto_Err_Handler_{proc_name}:")
result_lines.append(f" Call TestLogger.LogError(\"{proc_name}\", \"{module_name}\", Err.Number, Err.Description, Erl)")
result_lines.append(f" Call TestLogger.LogExit()")
return result_lines
def _inject_error_handler(self, lines: List[str], proc_name: str) -> List[str]:
"""
在过程开头添加: On Error GoTo Auto_Err_Handler
删除任何现有的 On Error 语句
在过程结束前添加错误处理块
"""
result_lines = []
on_error_pattern = re.compile(r'^\s*On\s+Error.*$', re.IGNORECASE)
proc_end_pattern = re.compile(r'^\s*End\s+(Sub|Function)', re.IGNORECASE)
exit_pattern = re.compile(r'^\s*(Exit\s+(Sub|Function))', re.IGNORECASE)
# 确定过程类型
proc_type = "Sub"
for line in lines:
if re.match(r'^\s*(Public|Private|Friend)?\s*Function', line, re.IGNORECASE):
proc_type = "Function"
break
# 在过程定义后插入 On Error 语句
inserted_error_handler = False
error_handler_inserted = False
for i, line in enumerate(lines):
# 跳过现有的 On Error 语句
if on_error_pattern.match(line):
continue
# 检查是否是过程结束
is_end = proc_end_pattern.match(line)
if is_end and not error_handler_inserted:
# 在 End Sub/Function 之前插入错误处理块
result_lines.append(f"")
result_lines.append(f" Call TestLogger.LogSuccess()")
if proc_type == "Sub":
result_lines.append(f" Exit Sub")
else:
result_lines.append(f" Exit Function")
result_lines.append(f"")
result_lines.append(f"Auto_Err_Handler_{proc_name}:")
result_lines.append(f" Call TestLogger.LogError(\"{proc_name}\", Err.Number, Err.Description, Erl)")
result_lines.append(line) # 添加 End Sub/Function
error_handler_inserted = True
elif is_end:
# 跳过后续的 End 语句(不应该有)
pass
elif exit_pattern.match(line):
# 跳过现有的 Exit 语句
pass
else:
# 在第一个非声明、非空行后插入 On Error
if not inserted_error_handler and not re.match(r'^\s*(\'|Dim|Private|Public|Const|$)', line, re.IGNORECASE):
if not re.match(r'^\s*(Public|Private|Friend)?\s*(Sub|Function)', line, re.IGNORECASE):
result_lines.append(f" On Error GoTo Auto_Err_Handler_{proc_name}")
inserted_error_handler = True
result_lines.append(line)
# 如果没有找到 End追加到末尾
if not error_handler_inserted:
result_lines.append(f"")
result_lines.append(f" Call TestLogger.LogSuccess()")
if proc_type == "Sub":
result_lines.append(f" Exit Sub")
else:
result_lines.append(f" Exit Function")
result_lines.append(f"")
result_lines.append(f"Auto_Err_Handler_{proc_name}:")
result_lines.append(f" Call TestLogger.LogError(\"{proc_name}\", Err.Number, Err.Description, Erl)")
return result_lines
class LoggerInjector:
"""日志模块注入器 - 向 Excel 项目注入 TestLogger 辅助模块和 CallStack 类模块"""
CALLSTACK_CLASS_CODE = """Option Explicit
Private m_Stack As Collection
Private Sub Class_Initialize()
Set m_Stack = New Collection
End Sub
Public Sub Push(ByVal procName As String, ByVal moduleName As String)
m_Stack.Add moduleName & "." & procName
End Sub
Public Sub Pop()
If m_Stack.Count > 0 Then m_Stack.Remove m_Stack.Count
End Sub
Public Function GetCallChain() As String
Dim i As Integer
Dim parts() As String
ReDim parts(1 To m_Stack.Count)
For i = 1 To m_Stack.Count
parts(i) = m_Stack(i)
Next i
GetCallChain = Join(parts, " -> ")
End Function
Public Sub Clear()
Set m_Stack = New Collection
End Sub
"""
LOGGER_MODULE_CODE = """Option Explicit
' Module-level variables to store test result
Private m_TestStatus As String
Private m_ErrModule As String
Private m_ErrProcedure As String
Private m_ErrNumber As Long
Private m_ErrDescription As String
Private m_ErrLine As Long
Private m_CallChain As String
Private m_CallStack As CallStack
Sub LogEntry(ByVal procName As String, ByVal moduleName As String)
If m_CallStack Is Nothing Then Set m_CallStack = New CallStack
m_CallStack.Push procName, moduleName
End Sub
Sub LogExit()
If Not m_CallStack Is Nothing Then m_CallStack.Pop
End Sub
Sub LogError(ByVal procName As String, ByVal moduleName As String, _
ByVal errNum As Long, ByVal errDesc As String, ByVal errLine As Long)
m_TestStatus = "ERROR"
m_ErrModule = moduleName
m_ErrProcedure = procName
m_ErrNumber = errNum
m_ErrDescription = errDesc
m_ErrLine = errLine
If Not m_CallStack Is Nothing Then
m_CallChain = m_CallStack.GetCallChain()
Else
m_CallChain = ""
End If
End Sub
Sub LogSuccess()
' 只有在没有错误时才设置为成功
' 这确保子过程的错误不会被父过程覆盖
If m_TestStatus <> "ERROR" Then
m_TestStatus = "SUCCESS"
End If
' 只有在没有错误时才清空调用栈
If m_TestStatus <> "ERROR" Then
If Not m_CallStack Is Nothing Then m_CallStack.Clear
End If
End Sub
Function GetResult() As String
If m_TestStatus = "SUCCESS" Then
GetResult = "SUCCESS"
Else
GetResult = "ERROR|" & m_ErrModule & "." & m_ErrProcedure & "|" & _
m_ErrLine & "|" & m_ErrNumber & "|" & m_ErrDescription & "|" & _
m_CallChain
End If
End Function
Sub Initialize()
m_TestStatus = "SUCCESS"
If Not m_CallStack Is Nothing Then m_CallStack.Clear
End Sub
"""
def inject_or_replace(self, wb, module_name: str = "TestLogger") -> None:
"""
如果模块存在则删除,然后注入新的 Logger 模块和 CallStack 类模块
"""
# 获取 VBA 项目
vba_project = wb.api.VBProject
# 检查并删除旧的 CallStack 类模块
for component in vba_project.VBComponents:
if component.Name == "CallStack":
vba_project.VBComponents.Remove(component)
break
# 注入 CallStack 类模块
callstack_module = vba_project.VBComponents.Add(2) # 2 = vbext_ct_ClassModule
callstack_module.Name = "CallStack"
callstack_module.CodeModule.AddFromString(self.CALLSTACK_CLASS_CODE)
# 检查并删除旧的 Logger 模块
for component in vba_project.VBComponents:
if component.Name == module_name:
vba_project.VBComponents.Remove(component)
break
# 注入新 Logger 模块
new_module = vba_project.VBComponents.Add(1) # 1 = vbext_ct_StdModule
new_module.Name = module_name
new_module.CodeModule.AddFromString(self.LOGGER_MODULE_CODE)
class TestRunner:
"""测试执行器 - 管理 Excel 生命周期,执行测试,返回结果"""
def __init__(self, file_path: str, visible: bool = True):
"""
初始化测试执行器
Args:
file_path: Excel 文件路径
visible: 是否显示 Excel 窗口
"""
self.file_path = file_path
self.visible = visible
self.app = None
self.wb = None
self.code_weaver = CodeWeaver()
self.logger_injector = LoggerInjector()
self.source_map = {} # 行号 -> 源代码映射
def run_test(self, module_name: str, proc_name: str) -> TestResult:
"""
执行测试的完整流程:
1. 打开 Excel 文件
2. 读取原始 VBA 代码
3. 使用 CodeWeaver 编织代码
4. 注入/替换 Logger 模块
5. 热替换目标模块代码
6. 执行宏
7. 获取结果
8. 关闭工作簿(不保存)
"""
try:
# 1. 打开 Excel
self.app = xw.App(visible=self.visible)
self.wb = self.app.books.open(self.file_path)
# 2. 读取原始 VBA 代码
original_code = self._get_vba_code(module_name)
# 3. 编织整个模块的所有过程(以支持调用链追踪)
# 注意:为了追踪调用链,我们需要编织模块中的所有过程
woven_code, self.source_map = self.code_weaver.weave_module_all_procedures(
original_code, module_name
)
# 4. 注入 Logger 模块
self.logger_injector.inject_or_replace(self.wb)
# 5. 热替换目标模块代码
self._replace_module_code(module_name, woven_code)
# 6. 执行宏
self._execute_macro(f"{module_name}.{proc_name}")
# 7. 获取结果
result = self._get_test_result(proc_name)
return result
except Exception as e:
return TestResult(
procedure_name=proc_name,
success=False,
error_number=0,
error_description=f"Test execution failed: {str(e)}",
error_line=0,
source_code="",
error_module="",
call_chain=""
)
finally:
# 8. 关闭工作簿(不保存)
self._cleanup()
def _get_vba_code(self, module_name: str) -> str:
"""从 Excel 读取指定模块的 VBA 代码"""
vba_project = self.wb.api.VBProject
for component in vba_project.VBComponents:
if component.Name == module_name:
code_module = component.CodeModule
line_count = code_module.CountOfLines
return code_module.Lines(1, line_count) if line_count > 0 else ""
raise ValueError(f"Module '{module_name}' not found in VBA project")
def _replace_module_code(self, module_name: str, new_code: str) -> None:
"""热替换模块代码"""
vba_project = self.wb.api.VBProject
for component in vba_project.VBComponents:
if component.Name == module_name:
code_module = component.CodeModule
# 删除所有现有代码
if code_module.CountOfLines > 0:
code_module.DeleteLines(1, code_module.CountOfLines)
# 添加新代码
code_module.AddFromString(new_code)
return
raise ValueError(f"Module '{module_name}' not found in VBA project")
def _execute_macro(self, proc_name: str) -> None:
"""执行 VBA 宏"""
try:
self.app.api.Run(proc_name)
except Exception as e:
# 错误会被 VBA 的错误处理捕获,这里不需要处理
pass
def _get_test_result(self, proc_name: str) -> TestResult:
"""从 Logger 模块获取测试结果(增强版,支持调用链)"""
try:
result_str = self.app.api.Run("TestLogger.GetResult")
if result_str == "SUCCESS":
return TestResult(
procedure_name=proc_name,
success=True
)
else:
# 解析错误信息: ERROR|Module.Proc|Line|Number|Desc|CallChain
parts = result_str.split('|')
if len(parts) >= 6:
error_module_proc = parts[1]
error_line = int(parts[2])
error_number = int(parts[3])
error_desc = parts[4]
call_chain = parts[5]
# 分离模块和过程名
error_module = ""
error_proc = error_module_proc
if '.' in error_module_proc:
error_module, error_proc = error_module_proc.split('.', 1)
# 从 Source Map 获取源代码
source_code = self.source_map.get(error_line, "Source code not found")
return TestResult(
procedure_name=error_proc,
success=False,
error_number=error_number,
error_description=error_desc,
error_line=error_line,
source_code=source_code,
error_module=error_module,
call_chain=call_chain
)
except Exception as e:
return TestResult(
procedure_name=proc_name,
success=False,
error_number=0,
error_description=f"Failed to get test result: {str(e)}",
error_line=0,
source_code="",
error_module="",
call_chain=""
)
def _cleanup(self):
"""清理资源,关闭 Excel"""
try:
if self.wb:
self.wb.api.Close(False)
if self.app:
self.app.quit()
except:
pass
# ==================== Phase 2: 全项目测试方法 ====================
def discover_all_tests(self) -> List[Dict]:
"""
发现所有可测试的过程
返回: [
{
'module': str,
'procedure': str,
'type': 'Sub' | 'Function',
'is_entry_point': bool
},
...
]
"""
# 打开Excel如果未打开
if not self.wb:
self.app = xw.App(visible=self.visible)
self.wb = self.app.books.open(self.file_path)
vba_project = self.wb.api.VBProject
modules = self.code_weaver.parse_modules(vba_project)
all_tests = []
for module_name, module_info in modules.items():
for proc_name, proc_info in module_info['procedures'].items():
# 判断是否是入口点
is_entry_point = self._is_entry_point(module_name, proc_name)
all_tests.append({
'module': module_name,
'procedure': proc_name,
'type': proc_info['type'],
'is_entry_point': is_entry_point
})
return all_tests
def _is_entry_point(self, module_name: str, proc_name: str) -> bool:
"""
判断过程是否可以作为测试入口点
规则:
1. 过程名不以"Worksheet_""Workbook_"开头(排除事件过程)
2. 过程名不以"Class_"开头(排除类模块内部方法,可选)
"""
# 排除事件过程
if proc_name.startswith(('Worksheet_', 'Workbook_', 'Document_', 'Class_')):
return False
# 排除Logger相关过程
if proc_name.startswith(('LogEntry', 'LogExit', 'LogError', 'LogSuccess', 'GetResult', 'Initialize')):
return False
# 简化实现:假设所有非事件过程都是入口点
return True
def run_all_tests(self) -> List[TestResult]:
"""
执行全项目测试
Returns:
所有测试结果的列表
"""
# 发现所有测试
all_tests = self.discover_all_tests()
entry_tests = [t for t in all_tests if t['is_entry_point']]
print(f"发现 {len(entry_tests)} 个可测试的入口点")
print(f"总过程数: {len(all_tests)}")
# 打开Excel如果未打开
if not self.wb:
self.app = xw.App(visible=self.visible)
self.wb = self.app.books.open(self.file_path)
# 一次性编织所有模块
self._weave_all_modules_inplace()
# 执行所有测试
results = []
for i, test in enumerate(entry_tests, 1):
print(f"\n[{i}/{len(entry_tests)}] 测试 {test['module']}.{test['procedure']}")
try:
result = self._run_single_test(test['module'], test['procedure'])
results.append(result)
print_test_result(result)
except Exception as e:
print(f" [ERROR] 测试执行失败: {str(e)}")
results.append(TestResult(
procedure_name=test['procedure'],
success=False,
error_description=f"Test execution failed: {str(e)}"
))
return results
def _weave_all_modules_inplace(self):
"""在Excel中直接编织所有模块"""
vba_project = self.wb.api.VBProject
modules = self.code_weaver.parse_modules(vba_project)
# 注入增强的Logger模块和CallStack类
self.logger_injector.inject_or_replace(self.wb)
# 编织所有模块
woven_modules = self.code_weaver.weave_all_modules(modules)
# 热替换所有模块代码
for module_name, woven_code in woven_modules.items():
try:
self._replace_module_code(module_name, woven_code)
except Exception as e:
print(f" 警告: 无法编织模块 {module_name}: {str(e)}")
def _run_single_test(self, module_name: str, proc_name: str) -> TestResult:
"""执行单个测试(用于批量测试)"""
# 初始化Logger
try:
self.app.api.Run("TestLogger.Initialize")
except:
pass
# 执行宏
try:
self._execute_macro(f"{module_name}.{proc_name}")
except:
pass # 错误会被VBA错误处理捕获
# 获取结果
return self._get_test_result(proc_name)
def print_test_result(result: TestResult, index: int = None, total: int = None):
"""打印单个测试结果(增强版,支持调用链)"""
prefix = f"[{index}/{total}] " if index and total else ""
if result.success:
print(f"{prefix}[PASS] {result.procedure_name}")
else:
print(f"{prefix}[FAIL] {result.procedure_name}")
print(f" Error: {result.error_description}")
# 显示位置信息
if result.error_module:
print(f" Location: {result.error_module}.{result.procedure_name}:{result.error_line}")
else:
print(f" Location: {result.procedure_name}:{result.error_line}")
# 显示调用链
if result.call_chain:
print(f" Call Chain: {result.call_chain}")
# 显示源代码
print(f" Source: {result.source_code}")
def print_summary(results: List[TestResult]):
"""打印测试汇总"""
passed = sum(1 for r in results if r.success)
failed = sum(1 for r in results if not r.success)
total = len(results)
print("\n===== Test Summary =====")
print(f"Passed: {passed}/{total}")
print(f"Failed: {failed}/{total}")
def main():
"""主入口函数(支持两种模式:全项目测试和指定过程测试)"""
if len(sys.argv) < 2:
print("用法1: python vba_test_runner.py <excel_file> --all")
print("用法2: python vba_test_runner.py <excel_file> <module_name> <procedure1> [procedure2] ...")
print("示例1: python vba_test_runner.py demo.xlsm --all")
print("示例2: python vba_test_runner.py demo.xlsm Module1 TestErrorProcedure")
sys.exit(1)
excel_file = sys.argv[1]
# 模式1全项目测试
if len(sys.argv) == 2 and sys.argv[1] == "--all":
# 这种情况应该是: python vba_test_runner.py --all
# 但实际上应该是: python vba_test_runner.py <file> --all
print("错误: 缺少Excel文件路径")
print("正确用法: python vba_test_runner.py <excel_file> --all")
sys.exit(1)
elif len(sys.argv) >= 3 and sys.argv[2] == "--all":
print("===== VBA 全项目自动化测试 =====\n")
runner = TestRunner(excel_file, visible=False)
try:
results = runner.run_all_tests()
print_summary(results)
finally:
runner._cleanup()
# 模式2指定过程测试保持向后兼容
elif len(sys.argv) >= 3:
module_name = sys.argv[2]
procedures = sys.argv[3:]
if not procedures:
print("错误: 必须指定至少一个过程名")
sys.exit(1)
print(f"===== VBA 批量测试结果 =====\n")
results = []
for i, proc_name in enumerate(procedures, 1):
runner = TestRunner(excel_file, visible=False)
result = runner.run_test(module_name, proc_name)
results.append(result)
print_test_result(result, i, len(procedures))
print_summary(results)
else:
print("错误: 无效的参数")
print("用法1: python vba_test_runner.py <excel_file> --all")
print("用法2: python vba_test_runner.py <excel_file> <module_name> <procedure1> [procedure2] ...")
sys.exit(1)
if __name__ == "__main__":
main()