☰
用 ctags 仓库地图驱动黑盒测试:从 aider 实战对话看 AI 如何仅凭符号签名编写单元测试
2026/10/25 11:25:21 网站建设 项目流程

用 ctags 仓库地图驱动黑盒测试:从 aider 实战对话看 AI 如何仅凭符号签名编写单元测试

【免费下载链接】aideraider is AI pair programming in your terminal项目地址: https://gitcode.com/GitHub_Trending/ai/aider

本篇文章围绕 aider 官方示例对话记录 examples/add-test.md 展开,逐段解读一次真实的“黑盒测试”开发过程:在完全没有拿到被测函数源码、也不知道仓库其余代码的情况下,aider 仅凭借一份基于 ctags 的高层仓库地图(repo map),自主推断出被测方法的签名、所需依赖类与实例化方式,写出单元测试,并在/run执行失败的反馈中自动修正调用方式,最终让测试通过。读完本文,你将理解 aider 的仓库地图机制如何把“符号元数据”变成 LLM 的编码依据,掌握/add、/run、自动提交在“测试先行”工作流中的配合方式,并看到同一个测试在当前仓库源码与测试集中的真实落点。

这份对话记录是什么

aider/website/examples/目录收录了一系列真实的聊天记录(chat transcripts),用于展示 aider 在不同场景下的行为,其索引见 examples/README.md。本份add-test.md是其中聚焦“黑盒测试”的一份:

This transcript shows aider creating a black box test case,without being given access to the source code of the function being tested or any of the other code in the repo.

也就是说,用户只告诉 aider 一个目标——为cmd_add()编写测试并运行验证——但从不把被测函数或相关源码加入对话。aider 能够完成这个任务,靠的是它自主读取的仓库地图(repo map)中携带的“符号与函数签名”元数据。

记录中特别指出,需要先说明的是:这段对话录制于 aider 较早期的版本(对话中的cmd_add实现与当前源码已有差异),但其展示的核心机制——仓库地图驱动的符号级推理、以/run串联“写测试→跑测试→读报错→改代码”的闭环——在今天的代码库中依然成立,且后续我们会用当前源码与测试逐一印证。

黑盒能力的来源:ctags / 仓库地图里的符号元数据

什么是 aider 的仓库地图

aider 每次把仓库概况发送给模型时,并不会完整读取所有文件,而是生成一份高层的仓库地图。它通过 tree-sitter / ctags 语言标签,抽取出仓库中各类语言的所有符号定义和函数签名(例如函数名、参数列表、类名),却不包含函数体实现。

关于仓库地图的机制与设计动机,可参考 repomap 说明文档、ctags 说明文档 以及发布仓库地图功能的历史文章 2023-05-25-ctags.md。核心思想是:对 LLM 而言,“仓库里有哪些符号、它们长什么签名”这一层元数据,往往比文件正文更高效——既大幅节省上下文 token,又足以支撑跨文件的结构性推理。

对话中 LLM 仅凭地图完成了哪些推理

记录中详细列出了 aider 在没有源码的前提下,仅依赖地图元数据做到的事:

  • 找到函数签名:定位用户要求补测试的cmd_add()函数签名;
  • 确定所属类:判断它是Commands(对话文本中写作Command)类的方法,因此测试需要先实例化一个对象才能调用;
  • 推导依赖:识别出构造Commands实例需要传入InputOutput与Coder两个对象;
  • 逐层拆解构造参数:推断出实例化InputOutput所需的参数;
  • 对复杂依赖做替身:判断Coder类过于复杂、不适合真实构造,于是决定用MagicMock打桩。

这五步是典型的“符号地图→调用链推导→测试脚手架”推理路径。LLM 并不需要读懂Commands的实现,只要地图里有类的层次、构造签名与被测方法签名,它就能像开发者阅读 API 文档一样拼出合法的调用序列。

对话全记录解读(一):编写第一个测试

以下是记录中完整的首次交互。aider 通过命令行aider tests/test_commands.py启动,把尚不存在的测试文件加入对话,并将其纳入 git:

> $ aider tests/test_commands.py > Creating empty file tests/test_commands.py > Added tests/test_commands.py to the chat > Files not tracked in .git: > - tests/test_commands.py > > Add them? y > Added tests/test_commands.py to the git repo > Commit b9cea2c Added new files to the git repo: tests/test_commands.py > Using git repo: .git

用户随后下达需求,明确把测试约束为“跑在临时目录里、验证文件被真正创建”:

add a test for cmd_add that passes in 'foo.txt' and 'bar.txt' and ensures they have both been created after the test. run the test in a tmp dir.

aider 给出了它的测试方案——新建test_cmd_add方法,用tempfile.TemporaryDirectory()建临时目录并切换工作目录,然后调用cmd_add并断言foo.txt、bar.txt都被创建。注意:因为cmd_add()定义时没有类型注解,aider 只能“合理但不正确”地猜测它接收一个文件列表。这一猜测构成了后续失败的伏笔。aider 以编辑块(edit block)格式给出改动:

tests/test_commands.py <<<<<<< ORIGINAL ======= import os import tempfile from unittest import TestCase from unittest.mock import MagicMock from aider.commands import Commands from aider.io import InputOutput class TestCommands(TestCase): def test_cmd_add(self): # Create a temporary directory and change the current working directory with tempfile.TemporaryDirectory() as tmpdir: os.chdir(tmpdir) # Initialize the Commands and InputOutput objects io = InputOutput(pretty=False, yes=True) coder = MagicMock() commands = Commands(io, coder) # Call the cmd_add method with 'foo.txt' and 'bar.txt' commands.cmd_add(["foo.txt", "bar.txt"]) # Check if both files have been created in the temporary directory self.assertTrue(os.path.exists("foo.txt")) self.assertTrue(os.path.exists("bar.txt")) >>>>>>> UPDATED

Applied edit to tests/test_commands.py Commit e2d5212 aider: Added a test methodtest_cmd_addthat creates a temporary directory, calls thecmd_addmethod with 'foo.txt' and 'bar.txt', and checks if both files have been created in the temporary directory.

值得注意的测试设计:用InputOutput(pretty=False, yes=True)构造一个不依赖真实交互的输入输出对象(yes=True使确认类提问自动通过),而把更复杂的Coder用MagicMock()顶替。这正是对话开头强调“地图足以支撑实例化推理”的体现。写入后 aider 自动提交,提交信息以aider:前缀概括本次改动。

对话全记录解读(二):/run 执行失败与自我纠错

aider 接下来用/run命令执行测试。/run是 aider 内置的 shell 执行命令,会在子进程中运行给定命令并把输出带回对话(详见下文源码分析)。第一次执行以失败告终:

/run pytest tests/test_commands.py
============================= test session starts ============================== platform darwin -- Python 3.11.3, pytest-7.3.1, pluggy-1.0.0 rootdir: /Users/gauthier/Projects/aider collected 1 item tests/test_commands.py F [100%] =================================== FAILURES =================================== __________________________ TestCommands.test_cmd_add ___________________________ self = <tests.test_commands.TestCommands testMethod=test_cmd_add> def test_cmd_add(self): # Create a temporary directory and change the current working directory with tempfile.TemporaryDirectory() as tmpdir: os.chdir(tmpdir) # Initialize the Commands and InputOutput objects io = InputOutput(pretty=False, yes=True) coder = MagicMock() commands = Commands(io, coder) # Call the cmd_add method with 'foo.txt' and 'bar.txt' > commands.cmd_add(["foo.txt", "bar.txt"]) /Users/gauthier/Projects/aider/tests/test_commands.py:20: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <aider.commands.Commands object at 0x10b412b50> args = ['foo.txt', 'bar.txt'] def cmd_add(self, args): "Add matching files to the chat session" added_fnames = [] files = self.coder.get_all_relative_files() > for word in args.split(): E AttributeError: 'list' object has no attribute 'split' /Users/gauthier/Projects/aider/aider/commands.py:161: AttributeError =========================== short test summary info ============================ FAILED tests/test_commands.py::TestCommands::test_cmd_add - AttributeError: '... ============================== 1 failed in 0.34s ===============================

Add the output to the chat? [y/n] (y): y

在“把输出加入对话”得到确认后,报错栈被回灌给模型。LLM 立刻定位到问题本质:cmd_add内部执行args.split(),说明它期望的是字符串而不是列表——于是把调用改为传一个“以空格分隔文件名列表”的字符串:

It seems that thecmd_addmethod inaider/commands.pyis expecting a string as input, but in the test, we are passing a list. I will update the test to pass a string instead of a list.

tests/test_commands.py <<<<<<< ORIGINAL # Call the cmd_add method with 'foo.txt' and 'bar.txt' commands.cmd_add(["foo.txt", "bar.txt"]) ======= # Call the cmd_add method with 'foo.txt' and 'bar.txt' as a single string commands.cmd_add("foo.txt bar.txt") >>>>>>> UPDATED

Applied edit to tests/test_commands.py Commit d1551b4 aider: Updated test to pass a string instead of a list when callingcmd_addmethod.

对话全记录解读(三):测试通过

修正后的再次执行得到绿色结果,黑盒测试最终落地:

/run pytest tests/test_commands.py
============================= test session starts ============================== platform darwin -- Python 3.11.3, pytest-7.3.1, pluggy-1.0.0 rootdir: /Users/gauthier/Projects/aider collected 1 item tests/test_commands.py . [100%] ============================== 1 passed in 0.38s ===============================

整个工作流呈现一个清晰的闭环:地图驱动猜测 → 写出测试 → /run 执行 → 真实报错校正猜测 → 测试转绿。值得注意的是,模型犯的“类型猜测错误”并未造成多大代价,因为/run提供了低成本、强信号的验证渠道——这正是“让 AI 动手写代码时搭配可执行反馈”的意义所在。

源码印证(一):cmd_add 的“字符串参数”契约延续至今

对话中暴露的AttributeError: 'list' object has no attribute 'split'发生在当时的旧实现上;在当前仓库中,cmd_add的实现已重写为 commands.py:

def cmd_add(self, args): "Add files to the chat so aider can edit them or review them in detail" all_matched_files = set() filenames = parse_quoted_filenames(args)

参数args依然是单个字符串,内部通过 parse_quoted_filenames 解析:

def parse_quoted_filenames(args): filenames = re.findall(r"\"(.+?)\"|(\S+)", args) filenames = [name for sublist in filenames for name in sublist if name] return filenames

这个函数用正则re.findall处理args,天然要求字符串输入——如果今天仍向cmd_add传入一个list,同样会在解析阶段失败(re.findall无法处理列表)。也就是说,对话中总结出的契约“cmd_add接受一个用空格分隔文件名的字符串,而不是文件名列表”在当前代码中依然成立,只是解析更健壮了:现在它支持带引号的文件名(例如含空格的文件可写作"my file.txt"),并且支持 glob 通配、目录递归等行为(cmd_add后续逻辑通过glob_filtered_to_repo展开通配符,并对 gitignore、只读文件、图片、越界路径做了完整校验)。

当前测试集中已经“记录”了这一正确用法

对话最终的修复形态——commands.cmd_add("foo.txt bar.txt")——与当前仓库 tests/basic/test_commands.py 中同名的test_cmd_add几乎如出一辙:

def test_cmd_add(self): # Initialize the Commands and InputOutput objects io = InputOutput(pretty=False, fancy_input=False, yes=True) from aider.coders import Coder coder = Coder.create(self.GPT35, None, io) commands = Commands(io, coder) # Call the cmd_add method with 'foo.txt' and 'bar.txt' as a single string commands.cmd_add("foo.txt bar.txt") # Check if both files have been created in the temporary directory self.assertTrue(os.path.exists("foo.txt")) self.assertTrue(os.path.exists("bar.txt"))

两处差异体现了代码库演进:其一,当前测试的InputOutput额外传入fancy_input=False,因为今天InputOutput默认启用 fancy 交互输入(见 io.py 的构造签名),纯单元测试场景需要显式关闭;其二,当前测试不再用MagicMock顶替Coder,而是通过Coder.create(self.GPT35, None, io)创建真实编码器——这说明如今的cmd_add实现依赖更真实的仓库上下文(git 跟踪文件、路径校验等),mock 难以满足。测试基类也演进出专门工具:setUp/tearDown与ChdirTemporaryDirectory、GitTemporaryDirectory(tests/basic/test_commands.py)。围绕cmd_add,当前测试集还覆盖了 glob 模式、空匹配创建、引号文件名、特殊字符目录、子目录相对路径、越界路径、只读文件提升等约二十种场景——把黑盒测试的“一个正确用例”扩展成了系统的契约验证矩阵。

源码印证(二):/run 如何把命令输出变成模型的上下文

对话中“Add the output to the chat? [y/n] (y): y”这个交互,在源码里对应 cmd_run:

def cmd_run(self, args, add_on_nonzero_exit=False): "Run a shell command and optionally add the output to the chat (alias: !)" exit_status, combined_output = run_cmd( args, verbose=self.verbose, error_print=self.io.tool_error, cwd=self.coder.root ) ... token_count = self.coder.main_model.token_count(combined_output) k_tokens = token_count / 1000 if add_on_nonzero_exit: add = exit_status != 0 else: add = self.io.confirm_ask(f"Add {k_tokens:.1f}k tokens of command output to the chat?")

run_cmd负责实际执行子进程(见 run_cmd.py)。命令完成后,aider 先估算输出 token 数并询问用户是否回灌;确认后,输出被包装进prompts.run_output模板(定义于 prompts.py),以一条用户消息形式追加进cur_messages,紧接着补一条assistant: "Ok."占位。之后模型就能“看到”完整的 pytest 堆栈并自行修复。若执行失败(非零退出码),还会把输入占位符设为What's wrong? Fix,主动引导下一轮修复——对话中报错后模型立即给出修正方案,正是这条机制在起作用。

这个“工具输出即模型上下文”的设计,是黑盒测试闭环成立的关键技术前提:模型无需猜测实现细节,只需运行真实测试、阅读真实报错,就能收敛到正确调用方式。

从这段对话提炼的实战要点

  • 仓库地图是“以最少 token 获得全局符号信息”的手段:只要地图质量足够(符号完整、签名准确),LLM 就能对未读源码的模块做合理推断。要理解地图的生成范围与取舍,可查看 repomap.py 的实现及 repomap 文档。
  • 黑盒测试的脚手架策略可复用:复杂依赖(对话中的Coder)用MagicMock,真正参与断言的 IO 层则构造真实最小实例(InputOutput(pretty=False, yes=True)),并把测试隔离到临时目录。这是让“AI 生成的测试”在不确定环境中可复现的通用手法。
  • /run是把 AI 从“猜”推向“验证”的杠杆:写完测试立刻跑、把真实失败回灌,模型就能基于报错而非臆测做修正。若希望在测试失败时无条件自动回灌输出(用于 lint/test 自动执行场景),cmd_run的add_on_nonzero_exit参数提供了支持。
  • 无类型注解的边界最容易猜错:对话中cmd_add未声明参数类型,模型按直觉推断为list。反过来说,cmd_add在当前实现中接收空格分隔的字符串(可含引号与 glob),在 commands.py 有明确实现依据;为被测代码补充类型注解,能显著减少这类“合理但不正确”的猜测。
  • 自动提交让每次 AI 改动都可追溯:对话中b9cea2c、e2d5212、d1551b4三个提交分别对应“加入文件”“新增测试”“修正调用”,每次Applied edit后立即以aider:前缀提交。这种“一个改动一个提交、提交信息即变更摘要”的习惯,让失败与修复的因果链条一目了然。

总体而言,这份对话记录演示的不只是“写一个测试”,而是一套可复用的方法论:让 LLM 借助符号级仓库地图建立调用契约,用最小化测试脚手架验证契约,再通过真实命令反馈迭代修正契约。结合当前仓库的 commands.py、tests/basic/test_commands.py 与 io.py,你可以清楚地看到这段历史对话中的用例如何演化为今天仍然有效的回归测试。

【免费下载链接】aideraider is AI pair programming in your terminal项目地址: https://gitcode.com/GitHub_Trending/ai/aider

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询