- CLI
- 编程语言
- 开发工具
【免费下载链接】elvish
Powerful scripting language & versatile interactive shell
本文基于 Elvish 项目作者 Qi Xiao 在 2024 年伦敦 Gophers 大会上的分享(对应仓库内讲稿 website/slides/2024-09-london-gophers.md)展开。文章以"如何为你自己的编程语言写测试"为主线,完整还原 Elvish 测试方案的两次迭代演进:从传统的表驱动测试,到自研
.elvtstranscript 文本 DSL,再到用 VS Code 插件把"手动抄写测试"这一步彻底自动化。读者将掌握:为什么解释器天然适合 transcript 式测试、.elvts文件的具体语法与运行机制、ELVISH_TRANSCRIPT_RUN编辑器协议的原理,以及这套方案如何被复用到终端应用(widget)的测试上。
一、背景:Elvish 是什么,它需要测试什么
Elvish 既是一门编程语言,也是一个现代化的交互式 shell(详见仓库根目录 README.md 与演讲讲稿的 Intro 部分)。它像 bash / zsh,但更现代:
- 更强大的交互特性:开箱即用就有语法高亮、
Tab补全、Ctrl-L目录历史、Ctrl-R命令历史、Ctrl-N文件系统导航器; - 可编程:例如可以用一行 Elvish 代码定制提示符:
set edit:prompt = { print (whoami)@(tilde-abbr $pwd)'$ ' }- 完整的编程语言:列表
[foo bar]、映射[&key=value]、lambda{|x| ...}、并行遍历peach等真实语言特性,可以直接用于 shell 脚本:
# [foo bar] - list # [&key=value] - map var hosts = [[&name=a &cmd='apt update'] [&name=b &cmd='pacman -Syu']] # peach = "parallel each" # {|h| ...} - lambda peach {|h| ssh root@$h[name] $h[cmd] } $hosts同时它保留了一切熟悉的 shell 能力——运行外部命令、管道、通配符,还支持递归通配符:
vim main.go cat *.go | wc -l # Elvish also supports recursive wildcards cat **.go | wc -l这就带来了两个需要被测试的对象:解释器(把代码文本变成输出)和终端应用(接收键盘事件、渲染界面)。演讲的核心,正是围绕这两者设计一套低成本、高覆盖的测试体系。
二、测试策略:让写测试变得"真正容易"
演讲先立下一个总原则:
- 测试很重要,它让我们在修改代码时对正确性有信心;
- 但测试策略里最重要的一件事,是让"创建和维护测试"变得极其容易;
- 容易写的测试 → 更多的测试 → 更高的测试覆盖率(讲稿中给出 Elvish 达到92% 测试覆盖率)。
为什么解释器适合做这件事?因为解释器的 API 极其简单:
- 输入:一段代码;
- 输出:文本 + 值。
在 Elvish 里,交互式会话天然就是"输入一行代码、得到输出"的序列:
~> echo hello world hello world ~> put [hello world] [foo bar] ▶ [hello world] ▶ [foo bar](注意值输出统一使用▶前缀,这是 pkg/eval/evaltest/test_transcript.go 中valuePrefix定义的约定。)既然 API 如此简单,测试的表达形式就可以非常贴近真实使用场景——这正是后面一切设计的前提。
三、迭代一:表驱动测试(table-driven tests)
第一个版本是最经典的做法:把"代码 + 期望输出"写进一张 Go 表,循环执行对比:
// Simplified interpreter API func Interpret(code string) ([]any, string) var tests = []struct{ code string wantValues []any wantText string }{ {code: "echo foo", wantText: "foo\n"}, } func TestInterpreter(t *testing.T) { for _, test := range tests { gotValues, gotText := Interpret(test.code) // Compare with test.wantValues and test.wantText } }此时添加一个测试用例需要三步:
- 实现新功能;
- 在终端里手动验证:
~> str:join , [a b] ▶ 'a,b'- 把这次交互手工转换成 Go 测试用例:
{code: "str:join , [a b]", wantValues: []any{"a,b"}}问题显而易见:第 3 步是纯机械劳动——而"计算机最擅长重复劳动"。
四、迭代二:transcript 测试——自研.elvtsDSL
第二个版本的思想是:既然第 3 步只是把"终端 transcript"翻译成表,那不如直接以终端 transcript 本身作为测试的存储格式。
于是就有了.elvts(Elvish transcript)文件:把终端会话原样记录下来:
~> str:join , [a b] ▶ 'a,b'然后用//go:embed把文件嵌入,交给一个parseTranscripts解析器生成测试表:
//go:embed tests.elvts const transcripts string func TestInterpreter(t *testing.T) { tests := parseTranscripts(transcripts) for _, test := range tests { /* ... */ } }这背后的哲学是:拥抱文本格式。虽然失去了严格的表结构,但实践中完全无所谓——文本格式带来的是极低的创建与维护成本。
4.1.elvts的完整语法(仓库源码级)
.elvts的解析器实现在 pkg/transcript/transcript.go,包注释本身就是一份完整的格式规范,要点如下:
基本语法:以 prompt(~>、/开头等)开头的行被视为代码,代码行可以延续到后续"缩进与 prompt 对齐"的行;其余行视为输出。识别 prompt 的正则定义在 pkg/transcript/transcript.go#L400-L402:
// PromptPattern defines how to match prompts, used to determine which lines // start the code part of an interaction. var PromptPattern = regexp.MustCompile(`^[~/][^ ]*> `)例如下面的片段里,echo lorem与缩进的echo ipsum属于同一次交互的两行代码,lorem和ipsum是它的输出:
~> echo foo foo ~> echo lorem echo ipsum lorem ipsum标题与会话树:支持# h1 #、## h2 ##、### h3 ###三级标题,把 transcript 分割成一棵"会话树",标题就是会话名。解析逻辑在 pkg/transcript/transcript.go#L358-L368 的parseHeading中实现。
注释与指令:以//开头或全为/的行是注释,会被忽略;而以//开头但不是注释的行是指令(directive),只能出现在会话开头(详见 pkg/transcript/transcript.go#L481-L489)。
.elv文件中的行内 transcript:.elv源码文件里的 elvdoc 文档注释中可以包含```elvish-transcript代码块,每个代码块都是一个独立的 transcript,命名为文件路径/符号名/代码块名。这一能力由 pkg/transcript/transcript.go#L197-L241 的parseElv实现——也就是说,文档里的示例本身就是可执行的测试。
4.2 运行机制:evaltest包
实际驱动这些测试运行的是 pkg/eval/evaltest 包。典型用法是每个测试包写一个入口函数(见 pkg/eval/evaltest/test_transcript.go#L1-L17):
import ( "embed" "src.elv.sh/pkg/eval/evaltest" ) //go:embed *.elv *.elvts var transcripts embed.FS func TestTranscripts(t *testing.T) { evaltest.TestTranscriptsInFS(t, transcripts) }TestTranscriptsInFS会扫描 FS 中所有.elv与.elvts文件,把每个 transcript 会话作为一个子测试运行。
该包还内置了一套预设 setup 函数(pkg/eval/evaltest/test_transcript.go#L271-L318),可通过指令在 transcript 文件中直接引用:
| 指令 | 作用 |
|---|---|
//skip-test | 跳过该测试(常用于.d.elv中仅供文档展示的示例) |
//in-temp-dir | 在临时目录中运行 |
//set-env $name $value | 设置环境变量 |
//unset-env $name | 取消环境变量 |
//eval $code | 先求值一段 Elvish 代码 |
//only-on $cond | 类似//go:build约束,满足条件才运行(支持unix、32bit、64bit等标签) |
//deprecation-level $x | 设置弃用告警级别 |
默认指令只作用于当前会话;加上each:前缀(如//each:set-env)则对全部子会话生效。
为了可测试性,该包还保证输出顺序的确定性:当代码同时写值输出与字节输出、或同时写 stdout 与 stderr 时,终端中顺序本无保证,evalAndCollectOutput(pkg/eval/evaltest/test_transcript.go#L371-L402)会统一收集并规范化(如剥离 SGR 颜色转义、统一换行符),使测试结果可预期。
仓库内真实用例可参考 e2e/script_test.elvts——它直接以终端会话的形式测试elvish -c和脚本文件执行:
////////////// # run script # ////////////// # with -c # ~> ./elvish -c 'echo hello from -c' hello from -c # with file # ~> echo 'echo hello from file' > script.elv ./elvish script.elv hello from file4.3 剩余的问题:复制粘贴仍是工作
这个版本下,添加测试用例变成:
- 实现新功能;
- 在终端手动验证;
- 把终端 transcript复制进
tests.elvts。
比起手写 Go 表已经轻松很多,但"复制"这一步依然是纯机械工作——能不能连复制都省掉?
五、迭代 2.1:VS Code 编辑器扩展——把测试写作变成编辑器操作
答案是为.elvts文件写一个编辑器扩展:运行光标处的代码,并把输出自动插入到光标下方。仓库中的实现位于 vscode/src/transcript.ts,注册了两个命令(见 vscode/src/transcript.ts#L7-L14):
elvish.updateTranscriptOutputForCodeAtCursor:更新光标处代码的输出;elvish.openTranscriptPromptBelow:在光标下方插入一个新的 prompt 行。
5.1 更新输出:ELVISH_TRANSCRIPT_RUN协议
updateTranscriptOutputForCodeAtCursor的完整流程(vscode/src/transcript.ts#L22-L70):
- 先保存当前文档(测试运行的是磁盘上的内容);
- 以当前文件与光标行号设置环境变量,并运行测试:
const { error, stdout } = await exec( "go test -run TestTranscripts", { cwd: dir, env: { ...process.env, ELVISH_TRANSCRIPT_RUN: `${base}:${lineno}` }, }); - 若测试失败,从输出中匹配
UPDATE (.*)$,解析出一段 JSON,其中包含需要替换的行区间与新内容:const { fromLine, toLine, content } = JSON.parse(match[1]) as UpdateInstruction; - 用编辑器 API 把
[fromLine, toLine)区间的内容替换为新输出。
这段机器可读的UPDATE指令正是由evaltest包产生的。协议定义在 pkg/eval/evaltest/test_transcript.go#L75-L106:
设置
ELVISH_TRANSCRIPT_RUN=文件名:行号后:
- 只运行该交互所属的会话,且只运行到该交互为止;
- 若实际输出与文件内容不符,测试失败,并输出一段机器可读的更新指令。
例如对下面这段(第 12 行起):
12 ~> echo foo 13 echo bar 14 lorem 15 ipsum运行env ELVISH_TRANSCRIPT_RUN=foo_test.elvts:12 go test -run TestTranscripts,会得到(行号区间左闭右开):
UPDATE {"fromLine": 14, "toLine": 16, "content": "foo\nbar\n"}一个实现细节值得注意:VS Code 的行号是0-based,而ELVISH_TRANSCRIPT_RUN协议与UPDATE指令都是1-based,因此扩展里做了显式换算(vscode/src/transcript.ts#L28-L31 的注释专门说明了这一点)。
5.2 插入新交互:openTranscriptPromptBelow
openTranscriptPromptBelow(vscode/src/transcript.ts#L85-L124)做的事情是:
- 从光标所在行向上搜索最近的 prompt 或标题行,确定当前使用的 prompt 样式(默认
~>); - 向下找到第一个标题/prompt 行,确定插入位置;
- 在光标下方插入一行新的 prompt,并把光标移动到 prompt 之后。
配合这两个命令,开发流程变成(讲稿的 demo 部分):
- 实现新功能;
- 直接在编辑器的
tests.elvts里手动输入并运行:
~> use str ~> str:join , [a b] ▶ 'a,b'输出由扩展自动补全。至此,"写测试"作为开发中的一个独立步骤被彻底消除——测试与开发合而为一。
六、附注:外部测试文件的依赖注入技巧(testexport)
演讲中穿插了一个与 transcript 无关、但非常实用的 Go 测试技巧。经典的依赖注入写法是把包级变量留一个可替换的口子:
// in foo.go package foo var stdout = os.Stdout func Hello() { fmt.Fprintln(stdout, "Hello!") } // in foo_test.go package foo func TestHello(t *testing.T) { stdout = ... ... }但如果测试是外部测试包(package foo_test)呢?直接导出stdout会让它成为公开 API 的一部分,并不理想。解法是再引入一个仅供内部测试用的文件:
// foo.go is unchanged // in testexport_test.go package foo // an internal test file var Stdout = &stdout // in foo_test.go package foo_test // an external test file func TestHello(t *testing.T) { *foo.Stdout = ... ... }内部测试文件testexport_test.go属于package foo,可以把私有变量的指针导出;外部测试包通过指针间接改写,而不污染公共 API。
这个模式在 Elvish 仓库里被大量使用。例如 pkg/eval/testexport_test.go 导出了一批可变指针:
package eval // Pointers to variables that can be mutated for testing. var ( GetHome = &getHome Getwd = &getwd OSExit = &osExit TimeAfter = &timeAfter TimeNow = &timeNow NextEvalCount = &nextEvalCount ... )仓库中类似的testexport文件还出现在 pkg/edit/highlight/testexport_test.go、pkg/md/testexport_test.go、pkg/mods/platform/testexport_test.go 等处,读者可以直接查阅这些文件了解完整用法。
七、把同样的方法论用到终端应用:widget 测试
解释器解决了,接下来是终端应用(交互界面)的测试。
7.1 Widget 抽象
像 GUI 应用一样,Elvish 的终端应用由一系列widget组成。抽象定义在 pkg/cli/tk/widget.go#L11-L17:
// Widget is the basic component of UI; it knows how to handle events and how to // render itself. type Widget interface { Renderer MaxHeighter Handler }即一个 widget 由三件事构成:
Render(width, height int) *term.Buffer:把自身渲染成一个Buffer;MaxHeight(width, height int) int:渲染所需的最大高度;Handle(event term.Event) bool:处理一个终端事件,返回是否已处理。
其中:
Buffer:存储富文本(带样式的文本)与光标位置;Event:键盘事件(以及其他事件)。
以CodeArea(代码输入区,实现见 pkg/cli/tk/codearea.go)为例:
- 保存文本内容与光标位置;
Render输出包含当前内容与光标的Buffer;Handle处理各类按键:按a插入a;按Backspace删除光标左侧字符;按Left光标左移。
7.2 为什么 widget 测试很啰嗦
widget 的 API 同样简单:输入Event,输出Buffer。但:输入输出常常是多个、且交错的——一个典型的测试流程是:
- 按
x、按y,渲染并检查; - 按
Left,渲染并检查; - 按
Backspace,渲染并检查。
如果用传统断言硬写,测试会变得冗长且难写。
7.3 解法:给 widget 创建 Elvish 绑定,然后……用 transcript 测试
演讲给出的思路:为 widget 暴露一组 Elvish 绑定(比如send发送事件、render渲染输出),于是测试就退化成我们已经有完整工具的 transcript 测试:
~> send [x y]; render xy ~> send [Left]; render xy ~> send [Backspace]; render y这看起来就像截图测试(screenshot tests)——只不过"截图"(渲染结果)直接内嵌在测试文件里,无需外部图片资源、无需视觉对比工具。
7.4 编码文本样式与光标位置
真实的render输出比上面更精细一些:需要把文本样式与光标位置也编码进文本。演讲展示了实际的渲染结果(R/G等字母代表不同样式,̅是光标标记):
~> send [e c o]; render ┌────────────────────────────────────────┐ │eco │ │RRR ̅̂ │ └────────────────────────────────────────┘ ~> send [Left]; render ┌────────────────────────────────────────┐ │eco │ │RRR̅̂ │ └────────────────────────────────────────┘ ~> send [h]; render ┌────────────────────────────────────────┐ │echo │ │GGGG̅̂ │ └────────────────────────────────────────┘这种"用单字符代表样式、把光标内联进文本"的编码方式,与仓库中 pkg/cli/clitest/apptest.go 的测试设施一脉相承:Styles定义了一张"字符 → 样式"映射表(如_下划线、b粗体、v绿色、c青色表示注释等,见 pkg/cli/clitest/apptest.go#L12-L28),Fixture.MakeBuffer与TestTTY则负责把这种带样式的文本标记行构建成预期的Buffer并比对(pkg/cli/clitest/apptest.go#L79-L87)。
7.5 还能更简单吗?
至此,测试仍然需要手动誊写测试会话。演讲在结尾提出了下一个演进方向:能否直接录制真实的 TUI 会话?——即把真实终端里的操作与渲染自动录制成 transcript。这为测试体系留出了一个明确的下一步想象空间。
八、结论:Elvish 测试策略的三条启示
- 让测试容易:测试数量与测试成本直接相关,成本越低覆盖越高;
- 拥抱文本:文本格式虽然"结构松散",但换来的是极低的创建与维护成本(该思路的先行者是 Mercurial 的测试体系,讲稿中提到了这一 prior art);
- 拥抱编辑器:把"生成测试"从人工流程变成编辑器内的一键操作,让写测试融入日常开发而不是额外负担。
这套方法论的价值不限于 Elvish:任何"代码进、输出出"的组件(解释器、编译器、REPL、甚至 widget 化的 TUI)都可以套用——先定义贴近真实会话的文本格式,再用编辑器插件把格式的维护自动化。
九、继续深入仓库
若想进一步研究本文涉及的实现,可以从以下路径入手:
- 讲稿原文:website/slides/2024-09-london-gophers.md
- transcript 格式规范与解析器:pkg/transcript/transcript.go
- transcript 测试运行器与
ELVISH_TRANSCRIPT_RUN协议:pkg/eval/evaltest/test_transcript.go - VS Code 扩展实现:vscode/src/transcript.ts
- 终端 widget 抽象:pkg/cli/tk/widget.go
- CLI 测试设施(FakeTTY / Fixture / 样式编码):pkg/cli/clitest/apptest.go
- 外部测试依赖注入示例:pkg/eval/testexport_test.go
- 真实 transcript 测试文件:e2e/script_test.elvts
- CLI
- 编程语言
- 开发工具
【免费下载链接】elvish
Powerful scripting language & versatile interactive shell
相关推荐
templ Elements 深度指南:在 Go 组件中渲染 HTML 元素
templ Elements 深度指南:在 Go 组件中渲染 HTML 元素 templ 是一种用 Go 编写 HTML 用户界面的语言。在 templ 组件中
CLI编程语言开发工具LibreChat测试贡献:编写测试用例与提高测试覆盖率
LibreChat测试贡献:编写测试用例与提高测试覆盖率 引言 在开源项目开发中,测试是确保代码质量和稳定性的关键环节。LibreChat作为一个功能丰富的Ch
人工智能大模型AI 应用交互助手Dayz-Cheat-H4ck-A1mbot常见问题:从编译错误到游戏崩溃的解决方案
Dayz Cheat H4ck A1mbot常见问题:从编译错误到游戏崩溃的解决方案 Dayz Cheat H4ck A1mbot是一个基于C++开发的DayZ
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考