news 2026/11/2 18:10:33

给编程语言写测试:Elvish 如何用自研 transcript DSL 与 VS Code 插件构建高覆盖率测试体系

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
给编程语言写测试:Elvish 如何用自研 transcript DSL 与 VS Code 插件构建高覆盖率测试体系
  • CLI
  • 编程语言
  • 开发工具

【免费下载链接】elvish

Powerful scripting language & versatile interactive shell

项目地址:https://gitcode.com/gh_mirrors/el/elvish
点击查看免费下载

本文基于 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 } }

此时添加一个测试用例需要三步:

  1. 实现新功能;
  2. 在终端里手动验证:
~> str:join , [a b] ▶ 'a,b'
  1. 把这次交互手工转换成 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 file

4.3 剩余的问题:复制粘贴仍是工作

这个版本下,添加测试用例变成:

  1. 实现新功能;
  2. 在终端手动验证;
  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):

  1. 先保存当前文档(测试运行的是磁盘上的内容);
  2. 以当前文件与光标行号设置环境变量,并运行测试:
    const { error, stdout } = await exec( "go test -run TestTranscripts", { cwd: dir, env: { ...process.env, ELVISH_TRANSCRIPT_RUN: `${base}:${lineno}` }, });
  3. 若测试失败,从输出中匹配UPDATE (.*)$,解析出一段 JSON,其中包含需要替换的行区间与新内容:
    const { fromLine, toLine, content } = JSON.parse(match[1]) as UpdateInstruction;
  4. 用编辑器 API 把[fromLine, toLine)区间的内容替换为新输出。

这段机器可读的UPDATE指令正是由evaltest包产生的。协议定义在 pkg/eval/evaltest/test_transcript.go#L75-L106:

设置ELVISH_TRANSCRIPT_RUN=文件名:行号后:

  1. 只运行该交互所属的会话,且只运行到该交互为止;
  2. 若实际输出与文件内容不符,测试失败,并输出一段机器可读的更新指令。

例如对下面这段(第 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)做的事情是:

  1. 从光标所在行向上搜索最近的 prompt 或标题行,确定当前使用的 prompt 样式(默认~>);
  2. 向下找到第一个标题/prompt 行,确定插入位置;
  3. 在光标下方插入一行新的 prompt,并把光标移动到 prompt 之后。

配合这两个命令,开发流程变成(讲稿的 demo 部分):

  1. 实现新功能;
  2. 直接在编辑器的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。但:输入输出常常是多个、且交错的——一个典型的测试流程是:

  1. 按x、按y,渲染并检查;
  2. 按Left,渲染并检查;
  3. 按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

项目地址:https://gitcode.com/gh_mirrors/el/elvish
点击查看免费下载
上一篇:Blender形状键保护插件SKkeeper终极使用指南:如何应用修改器不丢失形状键
下一篇:天龙八部单机版GM工具:TlbbGmTool完整使用指南

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 0:39:40

从零手搓AI编程智能体:LangGraph+LangChain+FastAPI环境搭建实战

1. 为什么我要从零手搓一个AI编程智能体先说结论:市面上现成的AI编程助手不少,但真正能贴合自己团队工作流、能接私有代码库、能按自己规则跑自动化任务的,几乎没有。我从去年开始陆续试过各种方案,要么是黑盒产品改不动&#xff…

作者头像 李华
网站建设 2026/10/25 10:36:37

gstack不是工具,而是Git+Node.js+AI协同调试的认知信号

1. “gstack”不是工具名,而是开发者误输时的高频自嘲梗最近在好几个技术群和论坛里,反复看到有人发:“求个 gstack 安装教程”“gstack 怎么配置”“gstack 和 Claude Code 哪个好”,甚至还有人贴出报错截图:command …

作者头像 李华
网站建设 2026/10/19 17:52:44

从提示词到技能库:手把手搭建AI Agent可复用技能体系

先说明一下背景。过去大半年我一直在折腾各种Agent项目,从早期的纯提示词工程,到接各种工具调用,再到最近半年开始认真研究并落地了一套自建的agent-skills体系。中间踩过的坑、推翻重来的次数,比过去三年写业务代码加起来还多。今…

作者头像 李华
网站建设 2026/10/19 17:52:43

Channels 后台任务与 Worker:用固定通道名实现低延迟任务队列

后端WebSocket异步编程 【免费下载链接】channels Developer-friendly asynchrony for Django 项目地址: https://gitcode.com/gh_mirrors/ch/channels 点击查看 免费下载 导读 Channels 的 channel layers 不仅用于不同 ASGI 应用实例之间的实时通信,…

作者头像 李华
网站建设 2026/10/19 17:52:44

(论文速读)一种用于图像超分辨率的有效扩散变换结构

论文题目:Effective Diffusion Transformer Architecture for Image Super-Resolution(一种用于图像超分辨率的有效扩散变换结构)会议:AAAI 2025摘要:近年来的研究表明,扩散模型在图像超分辨率领域有着广阔…

作者头像 李华
网站建设 2026/11/1 15:02:08

LLM驱动多智能体外联系统实践:从线索挖掘到触达编排的完整闭环

1. 为什么我会自己动手做一个外联Agent而不是直接买现成工具做商务拓展和渠道合作这几年,我每天有大量时间耗在"找人、查背景、写第一封联系信"这三件事上。市面上不是没有自动化工具,但它们的毛病太统一了:要么只做邮件模板群发&a…

作者头像 李华