codecompanion.nvim 测试指南:基于 mini.test 为 Neovim Lua 插件编写高质量自动化测试
2026/9/17 3:11:38 网站建设 项目流程

codecompanion.nvim 测试指南:基于 mini.test 为 Neovim Lua 插件编写高质量自动化测试

【免费下载链接】codecompanion.nvim✨ AI Coding, Vim Style项目地址: https://gitcode.com/GitHub_Trending/co/codecompanion.nvim

导读

本文以 codecompanion.nvim 仓库内的实践文档.codecompanion/tests/mini_test_testing.md为骨架,系统讲解如何使用mini.test为 Neovim Lua 插件编写测试:从测试文件组织、内置断言、hooks、参数化测试,到基于子进程的屏幕截图断言与自定义测试运行,并结合 codecompanion.nvim 自身的测试代码(tests/目录)给出可直接落地的实战模板。读完本文,你将能够为自己的 Neovim 插件搭建一套既支持交互式调试、又能在 CI 中无头运行的测试体系。

为什么选择 mini.test

为 Neovim Lua 插件写测试很难,写好更难。mini.test的设计目标是在保持足够灵活性的同时把难度降到可接受的水平,它刻意选择了一种"更啰嗦、更程序化"的测试书写风格,与基于plenary.nvim的 busted 风格(describe/it)形成对照。前者更贴近"直接写函数、直接断言"的 Lua 直觉,后者则偏重 DSL 化的可读性。

需要强调的是,mini.test 默认也支持 busted 风格,两种写法可以并存。本文其余部分统一采用官方推荐的 test set(测试集)方式。

整体组织方式:一个测试文件对应一个测试集

文档给出的通用方法论非常清晰:

  • 按 Lua 文件组织测试,每个测试文件都位于tests/目录下,并以test_前缀命名(如tests/test_hello_lines.lua);
  • 每个文件返回一个测试集,测试集由MiniTest.new_set()创建;
  • 每个测试动作是绑定在测试集字段上的独立函数,函数抛错即测试失败;
  • 强烈建议在测试动作内使用自定义的子 Neovim 进程(即 child process)执行真实测试,避免污染当前会话。

完整推荐的项目结构如下(其中"Mandatory"为必须,"Recommended"为推荐):

. ├── deps │ └── mini.nvim # 必须:mini.test 依赖 ├── lua │ └── hello_lines │ └── init.lua # 必须:被测插件 ├── Makefile # 推荐 ├── scripts │ ├── minimal_init.lua # 必须:无头 Neovim 启动脚本 │ └── minitest.lua # 推荐 └── tests └── test_hello_lines.lua # 必须:测试文件

这一结构在 codecompanion.nvim 中得到了完整印证:仓库根目录有Makefile(内含testtest_file目标),scripts/minimal_init.lua负责初始化最小环境,tests/下按模块划分了大量test_*.lua文件。

搭建最小测试环境

1. 引入 mini.nvim 依赖

建议将 mini.nvim 存放在deps/mini.nvim目录,用git拉取(--filter=blob:none为部分克隆,可显著减少下载量):

mkdir -p deps git clone --filter=blob:none <mini.nvim 仓库地址> deps/mini.nvim

2. 最小启动脚本 scripts/minimal_init.lua

该脚本保证 Neovim 进程能识别被测插件与 mini.test 依赖,仅在无头模式(#vim.api.nvim_list_uis() == 0)下才初始化 mini.test,避免影响交互式使用:

-- 把当前目录加入 'runtimepath',以便使用 'lua' 文件 vim.cmd([[let &rtp.=','.getcwd()]]) -- 仅在调用无头 Neovim 时(如 `make test`)设置 'mini.test' if #vim.api.nvim_list_uis() == 0 then -- 假设 'mini.nvim' 存放在 'deps/mini.nvim' vim.cmd('set rtp+=deps/mini.nvim') -- 设置 'mini.test' require('mini.test').setup() end

对照 codecompanion.nvim 的scripts/minimal_init.lua可以看到它在此基础上进一步强化了"测试可控性":引入deps/plenary.nvimdeps/nvim-treesitter、固定颜色主题保证截图稳定,并把所有可能触网的 HTTP 方法替换为直接报错的函数——任何测试若尝试真实网络请求都会立刻失败。这是一种值得借鉴的"测试边界"策略:测试环境内禁止一切不可控的外部副作用。

3. Makefile 简化运行与 CI 集成

文档推荐用 Makefile 同时服务本地命令行与 CI,模板如下:

# 运行全部测试文件 test: deps/mini.nvim nvim --headless --noplugin -u ./scripts/minimal_init.lua -c "lua MiniTest.run()" # 运行 $FILE 环境变量指定的测试文件 test_file: deps/mini.nvim nvim --headless --noplugin -u ./scripts/minimal_init.lua -c "lua MiniTest.run_file('$(FILE)')" # 下载 'mini.nvim' deps/mini.nvim: @mkdir -p deps git clone --filter=blob:none <mini.nvim 仓库地址> $@

codecompanion.nvim 的Makefile与之一致地实现了test目标,并额外设置了LC_ALL=C以保证输出环境稳定:

test: deps @echo Testing... LC_ALL=C nvim --headless --noplugin -u ./scripts/minimal_init.lua -c "lua MiniTest.run()"

4. 两种运行方式

mini.test 开箱即用地支持两种运行模式:

  • 交互式:在当前 Neovim 会话内直接执行:lua MiniTest.run()/:lua MiniTest.run_file()/:lua MiniTest.run_at_location()。默认配置下结果展示在一个浮动窗口中,按q关闭。这种方式非常适合调试测试本身,但可能影响当前环境,因此测试内部应使用子进程来规避副作用。
  • 无头模式(从 shell):用启动脚本拉起无头 Neovim 并执行lua MiniTest.run(),配合上述 Makefile 即为make test,结果直接打印在 shell 中,是 CI 的标准姿势。

第一个测试:从函数到断言

测试集字段上的函数就是测试用例,函数抛错则测试失败,测试文件返回单个测试集。最朴素的写法:

local T = MiniTest.new_set() T['works'] = function() local x = 1 + 1 if x ~= 2 then error('`x` is not equal to 2') end end return T

手写if ... error() ... end太过繁琐,因此 mini.test 内置了最小但够用的断言集合MiniTest.expect

local T = MiniTest.new_set() T['works'] = function() local x = 1 + 1 MiniTest.expect.equality(x, 2) end return T

测试集支持嵌套,这为后面结合 hooks 与参数化打下了基础:

local T = MiniTest.new_set() T['big scope'] = new_set() T['big scope']['works'] = function() MiniTest.expect.equality(1 + 1, 2) end T['big scope']['also works'] = function() MiniTest.expect.equality(2 + 2, 4) end T['out of scope'] = function() MiniTest.expect.equality(3 + 3, 6) end return T

若偏爱 busted 风格,也可等价写成describe/it(此时不要返回测试集):

describe('big scope', function() it('works', function() MiniTest.expect.equality(1 + 1, 2) end) end) it('out of scope', function() MiniTest.expect.equality(3 + 3, 6) end)

内置断言与自定义断言

最常用的四个内置断言如下(eqMiniTest.expect.equality的常用别名,因其高频使用而值得单独定义):

local T = MiniTest.new_set() local expect, eq = MiniTest.expect, MiniTest.expect.equality local x = 1 + 1 T['expect.equality'] = function() eq(x, 2) end T['expect.no_equality'] = function() expect.no_equality(x, 1) end T['expect.error'] = function() -- 函数抛错则通过 expect.error(function() if x == 2 then error('Deliberate error') end end) end T['expect.no_error'] = function() -- 函数不抛错则通过 expect.no_error(function() if x ~= 2 then error('This should not be thrown') end end) end return T

对于反复出现的断言模式,MiniTest.new_expectation(subject, predicate, fail_context)允许封装自定义断言,三个参数分别是:断言主题描述、谓词函数、失败上下文格式化函数。例如封装"字符串匹配"断言:

local T = MiniTest.new_set() local expect_match = MiniTest.new_expectation( 'string matching', -- 断言主题 function(str, pattern) return str:find(pattern) ~= nil end, -- 谓词 function(str, pattern) -- 失败上下文 return string.format('Pattern: %s\nObserved string: %s', vim.inspect(pattern), str) end ) T['string matching'] = function() local x = 'abcd' expect_match(x, '^a') -- 通过 expect_match(x, 'x') -- 失败 end return T

该用例失败时输出(来自文档原文):

FAIL in "tests/test_basics.lua | string matching": Failed expectation for string matching. Pattern: "x" Observed string: abcd Traceback: tests/test_basics.lua:20

codecompanion.nvim 在tests/expectations.lua中大量使用该机制,定义了一批带良好失败信息的自定义断言,例如is_trueis_falseeq_info(在eq基础上增加第三条说明消息),并通过tests/helpers.lua合并进公共 helper 供所有测试复用:

H.eq_info = MiniTest.new_expectation( "values are equal", function(left, right) return vim.deep_equal(left, right) end, function(left, right, msg) return string.format("Left: %s\nRight: %s\nMsg: %s", vim.inspect(left), vim.inspect(right), msg) end )

Hooks:在固定阶段注入行为

hooks 是无参函数,在测试执行的规定阶段被调用,属于测试集级别的特性,共四种:

Hook触发时机
pre_once第一个(过滤后的)节点执行前
pre_case每个用例执行前(含嵌套)
post_case每个用例执行后(含嵌套)
post_once最后一个(过滤后的)节点执行后

示例(体会计数递增的顺序逻辑):

local new_set = MiniTest.new_set local expect, eq = MiniTest.expect, MiniTest.expect.equality local T = new_set() local n = 0 local increase_n = function() n = n + 1 end T['hooks'] = new_set({ hooks = { pre_once = increase_n, pre_case = increase_n, post_case = increase_n, post_once = increase_n }, }) T['hooks']['work'] = function() -- 此时 n 被递增两次:pre_once 与 pre_case eq(n, 2) end T['hooks']['work again'] = function() -- 再递增两次:上一用例的 post_case 与本用例的 pre_case eq(n, 4) end T['after hooks set'] = function() -- 再递增两次:上一用例的 post_case 与 T['hooks'] 的 post_once eq(n, 6) end return T

hooks 最常见的实战用途是管理子进程生命周期——在pre_case中重启干净的子进程并加载被测插件,在post_once中停止子进程(详见后文"子进程测试"一节)。codecompanion.nvim 的测试大量遵循此模式,例如tests/test_init.luaT["cli()"]测试集在pre_once里启动子进程并注入测试配置,pre_case里清理上一次用例遗留的 buffer 与窗口,post_once统一child.stop

参数化测试:一份用例、多组输入

参数化是 mini.test 的显著特性,与 hooks 一样属于测试集特性。每组参数都是一个数组(以便一次传入多个实参),多个参数化层级会做笛卡尔积"相乘"。

local new_set = MiniTest.new_set local eq = MiniTest.expect.equality local T = new_set() -- 每组参数是数组,允许同时参数化多个实参 T['parametrize'] = new_set({ parametrize = { { 1 }, { 2 } } }) -- 展开为两个用例,第一个失败 T['parametrize']['works'] = function(x) eq(x, 2) end -- 嵌套参数化:用例数相乘(2 × 2 = 4 个),其中两个失败 T['parametrize']['nested'] = new_set({ parametrize = { { '1' }, { '2' } } }) T['parametrize']['nested']['works'] = function(x, y) eq(tostring(x), y) end -- 多实参参数化:两组都通过 T['parametrize multiple arguments'] = new_set({ parametrize = { { 1, 1 }, { 2, 2 } } }) T['parametrize multiple arguments']['works'] = function(x, y) eq(x, y) end return T

在文档的hello_lines示例中,set_lines()parametrize = { {}, { 0, { 'a' } }, { 0, { 1, 2, 3 } } }一次性覆盖了默认参数、单行输入与多行输入三种场景。

n_retry:缓解固有偶发失败

某些测试天生不稳定(如验证带延迟的事件序列在慢速 CI 机器上可能偶发失败)。测试集的n_retry字段表示每个用例最多重试多少次直至成功:

local new_set = MiniTest.new_set local T = new_set() -- 每个用例最多尝试 5 次 T['n_retry'] = new_set({ n_retry = 5 }) -- n_retry = 1 时每两次运行失败 1 次;n_retry = 5 时约每 32 次失败 1 次 T['n_retry']['case'] = function() math.randomseed(vim.loop.hrtime()) assert(math.random() < 0.5) end return T

在 codecompanion.nvim 的测试里也有直接体现,如tests/interactions/chat/tools/builtin/test_run_command.lua中通过vim.wait(...)轮询异步状态来等待工具执行完成,这类依赖时序的用例正是n_retry的典型适用对象。

MiniTest.current 与用例辅助函数

MiniTest.current表保存当前执行的测试信息:all_cases是所有将被执行的用例(顺序化的数组),case是当前正在执行的用例。每个 case 是一个"顺序执行的最小单元",包含argsdatadescexec(执行状态、失败列表、备注)、hookstest等字段。文档还给出一个实用调试技巧:故意断言eq(MiniTest.current.all_cases, 0)制造失败,从而在失败信息中打印出完整的 case 结构(含各字段默认值),是理解内部数据结构的最快途径。

与之配套的用例辅助函数:

  • MiniTest.skip(reason):跳过剩余执行并留下说明性备注,用例以"with notes"状态通过;
  • MiniTest.add_note(msg):为用例添加备注,最终状态带 "with notes" 后缀;
  • MiniTest.finally(fn):注册一个函数,无论本用例成功还是出错都会在该用例结束后执行。

综合示例与输出(来自文档原文):

local T = MiniTest.new_set() T['skip()'] = function() if 1 + 1 == 2 then MiniTest.skip('Apparently, 1 + 1 is 2') end error('1 + 1 is not 2') end T['add_note()'] = function() MiniTest.add_note('This test is not important.') error('Custom error.') end T['finally()'] = function() MiniTest.finally(function() if #MiniTest.current.case.exec.fails > 0 then MiniTest.add_note('This test is flaky.') end end) error('Expected error from time to time') end return T
NOTE in "tests/test_basics.lua | skip()": Apparently, 1 + 1 is 2 FAIL in "tests/test_basics.lua | add_note()": tests/test_basics.lua:16: Custom error. NOTE in "tests/test_basics.lua | add_note()": This test is not important. FAIL in "tests/test_basics.lua | finally()": tests/test_basics.lua:28: Expected error from time to time NOTE in "tests/test_basics.lua | finally()": This test is flaky.

其中finally()的实现模式——检测#MiniTest.current.case.exec.fails > 0后追加"此用例易碎"的备注——是标记 flaky 测试的实用技巧。

自定义测试运行:收集与执行两个阶段

一次测试运行分为两个阶段,配置全部通过MiniTest.run({ ... })opts参数传入:

  • 收集(Collection):source 每个合适的文件(可自定义),把所有测试集合并为单个测试集,再转换为顺序化的用例数组,最后按可自定义的谓词过滤用例;
  • 执行(Execution):以"调度式异步"的方式逐个安全执行用例(依次经历 pre-hooks、测试动作、post-hooks),并把结果写入MiniTest.current.all_cases中对应 case 的exec字段(执行本身不产生输出),同时调用可自定义的 reporter 方法来实时展示结果。

收集:自定义文件与过滤

利用测试集的data字段携带自定义信息(如快慢分类),再通过collect.find_filescollect.filter_cases定制收集范围:

local new_set = MiniTest.new_set local T = new_set() T['fast'] = new_set({ data = { type = 'fast' } }) T['fast']['first test'] = function() end T['fast']['second test'] = function() end T['slow'] = new_set({ data = { type = 'slow' } }) T['slow']['first test'] = function() vim.loop.sleep(1000) end T['slow']['second test'] = function() vim.loop.sleep(1000) end return T

只跑单个文件中的 "fast" 用例:

MiniTest.run({ collect = { find_files = function() return { 'tests/test_basics.lua' } end, filter_cases = function(case) return case.data.type == 'fast' end, } })

执行:自定义 reporter 与失败即停

reporter 需要实现start(cases)update(case_num)finish()等方法。例如结束后在命令行打印状态汇总表:

local reporter = { finish = function() local summary = {} for _, c in ipairs(MiniTest.current.all_cases) do local state = c.exec.state summary[state] = (summary[state] or 0) + 1 end print(vim.inspect(summary, { newline = ' ', indent = '' })) end, } MiniTest.run({ execute = { reporter = reporter } })

execute还支持stop_on_first_error等选项,用于首个用例失败即中止整轮执行,节省调试时间。

子进程测试:mini.test 的灵魂功能

mini.test 与其他 Lua 测试框架最大的不同,在于在测试中刻意使用自定义的子 Neovim 进程:理想情况下每个测试都应在一个仅加载了最小配置(如能加载被测插件)的全新 Neovim 进程中完成。MiniTest.new_child_neovim()返回一个子进程对象,附带大量实用方法:启动/停止/重启、重定向执行(在当前进程写代码、在子进程执行)、模拟按键、测试屏幕状态等。

原理与限制

当前进程与子进程通过 RPC 消息通信(见:h RPC),因此可以在子进程内编程执行代码、取回输出并断言。子进程是无头但功能完整的 Neovim,足以测试 extmarks、浮动窗口等界面特性。文档同时如实指出了两点局限:

  • 受 RPC 协议实现限制,函数与 userdata 无法在子进程的输入输出中传递,报错特征为Cannot convert given lua type;通常解法是把逻辑搬到子进程一侧(如定义并使用全局函数,注意重启后会被"遗忘");
  • 偶尔出现"挂起":进程停止执行且无输出,多因 Neovim 被"阻塞"等待用户输入,常见诱因是 hit-enter 提示(调高提示高度解决)或 Operator-pending 模式(退出该模式解决)。大多数辅助方法在能推断"立即执行会导致挂起"时会主动抛错。

推荐的生命周期管理

在每个用例前重启干净子进程,全部用例结束后统一停止:

local child = MiniTest.new_child_neovim() local T = MiniTest.new_set({ hooks = { pre_case = function() -- 用自定义 'init.lua' 重启子进程 child.restart({ '-u', 'scripts/minimal_init.lua' }) -- 加载被测插件 child.lua([[M = require('hello_lines')]]) end, -- 全部用例结束后停止 post_once = child.stop, }, }) -- 在此定义测试 return T

执行 Lua 代码:lua() 与 lua_get()

child.lua(code, ...)在子进程中执行任意 Lua 字符串,是vim.api.nvim_exec_lua()的封装;child.lua_get(s, ...)等价于child.lua('return ' .. s, ...),适合取返回值:

local eq = MiniTest.expect.equality local child = MiniTest.new_child_neovim() local T = MiniTest.new_set({ hooks = { pre_case = function() child.restart({ '-u', 'scripts/minimal_init.lua' }) child.lua([[M = require('hello_lines')]]) end, post_once = child.stop, }, }) T['lua()']['works'] = function() child.lua('_G.n = 0; _G.n = _G.n + 1') eq(child.lua('return _G.n'), 1) end T['lua()']['can use tested plugin'] = function() eq(child.lua('return M.compute()'), { 'Hello world' }) eq(child.lua([[return M.compute({'a', 'b'})]]), { 'Hello a', 'Hello b' }) end T['lua_get()'] = function() child.lua('_G.n = 0') eq(child.lua_get('_G.n'), child.lua('return _G.n')) end return T

codecompanion.nvim 对其进行了再封装:tests/helpers.lua里的Helpers.child_start(child)在重启后还会清空 statusline、laststatuscmdheight,保证子进程界面状态一致,是"最小化不可控因素"的典型处理。

管理选项与状态:重定向表

仅靠字符串式 Lua 执行写复杂测试会迅速变得笨拙,因此 mini.test 提供了大量"重定向"便捷助手——在当前进程写代码,自动在子进程执行:

  • child.api.xxx(...):等价于在子进程执行vim.api.xxx(...)并返回结果(经vim.rpcrequest());
  • child.cmd(...)/child.cmd_capture(...):执行 Vimscript,后者捕获输出;
  • child.fn.*child.loop.*child.lsp.*等:覆盖大部分 Neovim 核心功能的vim.fn/vim.loop/vim.lsp重定向表;
  • 作用域变量与选项重定向表:child.b.*(buffer 变量)、child.w.*child.t.*child.v.*child.g.*,以及child.bo.*/child.wo.*/child.o.*选项表,均可读写。

示例:

local new_set = MiniTest.new_set local eq = MiniTest.expect.equality local child = MiniTest.new_child_neovim() local T = MiniTest.new_set({ hooks = { pre_case = function() child.restart({ '-u', 'scripts/minimal_init.lua' }) child.lua([[M = require('hello_lines')]]) end, post_once = child.stop, }, }) T['api()/api_notify()'] = function() -- 首个 buffer 默认 readonly,会拖慢测试,先关掉 child.api.nvim_set_option_value('readonly', false, { buf = 0 }) child.api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa' }) eq(child.api.nvim_buf_get_lines(0, 0, -1, true), { 'aaa' }) end T['cmd()/cmd()'] = function() child.cmd('hi Comment guifg=#aaaaaa') eq(child.cmd_capture('hi Comment'), 'Comment xxx guifg=#aaaaaa') end T['various redirection tables with methods'] = function() eq(child.fn.fnamemodify('hello_lines.lua', ':t:r'), 'hello_lines') eq(child.loop.hrtime() > 0, true) eq(child.lsp.get_clients(), {}) end T['redirection tables for variables'] = function() child.b.aaa = true eq(child.b.aaa, true) eq(child.b.aaa, child.lua_get('vim.b.aaa')) end T['redirection tables for options'] = function() child.o.lines, child.o.columns = 5, 12 eq(child.o.lines, 5) eq({ child.o.lines, child.o.columns }, child.lua_get('{ vim.o.lines, vim.o.columns }')) end return T

模拟用户按键:type_keys()

child.type_keys()用于模拟用户输入,支持单字符串、多字符串与(可嵌套的)字符串表,还可指定自定义延迟:

local eq = MiniTest.expect.equality local child = MiniTest.new_child_neovim() local T = MiniTest.new_set({ hooks = { pre_case = function() child.restart({ '-u', 'scripts/minimal_init.lua' }) child.bo.readonly = false child.lua([[M = require('hello_lines')]]) end, post_once = child.stop, }, }) local get_lines = function() return child.api.nvim_buf_get_lines(0, 0, -1, true) end T['type_keys()']['works'] = function() -- 单个字符串 child.type_keys('iabcde<Esc>') eq(get_lines(), { 'abcde' }) eq(child.fn.mode(), 'n') -- 多个字符串,提升可读性 child.type_keys('cc', 'fghij', '<Esc>') eq(get_lines(), { 'fghij' }) -- 字符串表(可嵌套) child.type_keys({ 'cc', { 'j', 'k', 'l', 'm', 'n' } }) eq(get_lines(), { 'jklmn' }) end T['type_keys()']['allows custom delay'] = function() -- 每个字符串之后附加 500ms 延迟 child.type_keys(500, 'i', 'abcde', '<Esc>') eq(get_lines(), { 'abcde' }) end return T

屏幕截图断言:验证"长什么样"

验证插件是否以预期方式"显示"(高亮、statusline、tabline、sign column、extmark 等)是 Neovim 插件测试的一大难点。mini.test 的做法是:child.get_screenshot()对每个可见单元格调用screenstring()screenattr():h screenstring()/:h screenattr()),返回包含两层的截图表:

  • text 层:按行列组织的二维数组,记录每个单元格显示的字符;
  • attr 层:按行列组织的二维数组,记录单元格外观(高亮)的编码符号,只在相对意义上有效——两个单元格符号相同/不同,意味着视觉外观相同/不同。

两个重要注意点:

  • 由于依赖screenattr()截图无法告知某单元格具体是什么高亮,只能判断两个单元格是否高亮一致(Neovim 本身的限制,未来可能改善);
  • 当不同属性值超过 94 种时会出现误判(因为输出被编码为单字符符号)。

配合MiniTest.expect.reference_screenshot(screenshot, path, opts)使用:首次运行时自动在指定路径创建参考截图(路径缺省时由用例描述推断,默认落在tests/screenshots/目录);后续运行将其与当前截图比对,不一致则抛出带详细提示的错误。

示例(结合参数化一次生成多份参考截图):

local expect = MiniTest.expect local child = MiniTest.new_child_neovim() local T = MiniTest.new_set({ hooks = { pre_case = function() child.restart({ '-u', 'scripts/minimal_init.lua' }) child.bo.readonly = false child.lua([[M = require('hello_lines')]]) end, post_once = child.stop, }, }) T['set_lines()'] = MiniTest.new_set({ parametrize = { {}, { 0, { 'a' } }, { 0, { 1, 2, 3 } } } }) T['set_lines()']['works'] = function(buf_id, lines) child.o.lines, child.o.columns = 10, 15 child.lua('M.set_lines(...)', { buf_id, lines }) expect.reference_screenshot(child.get_screenshot()) end return T

这会生成三份截图文件(文件名含用例描述与参数),例如参数为{ 0, { 1, 2, 3 } }的参考截图内容如下(带行号与列标尺便于比对):

--|---------|----- 01|Hello 1 02|Hello 2 03|Hello 3 04|~ 05|~ 06|~ 07|~ 08|~ 09|<e] [+] 1,1 All 10| --|---------|----- 01|000001111111111 02|000001111111111 03|000001111111111 04|222222222222222 05|222222222222222 06|222222222222222 07|222222222222222 08|222222222222222 09|333333333333333 10|444444444444444

文本层清晰展示Hello 1/2/3、空行~与状态行;attr 层则用符号编码表示不同外观区域(如Hello前缀的Special高亮、普通文本与状态行各不相同)。需要更新参考截图时,可删除对应截图文件后重跑用例,或临时给reference_screenshot(){ force = true }强制覆盖。

这正是 codecompanion.nvim 测试体系大量采用的断言方式:仓库的tests/screenshots/目录中保存着数十份参考截图,例如tests-interactions-chat-tools-builtin-test_run_command.lua---run_command-tooltests-test_init.lua---cli()---prompt=true-opens-the-input-buffer等,测试文件里则通过h.expect_screenshot(child.get_screenshot())(见tests/expectations.luatests/interactions/chat/tools/builtin/test_run_command.lua)完成"屏幕状态"级别的回归验证。

通用技巧与实战建议

1. 建立共享 helpers 文件

创建tests/helpers.lua,集中存放多文件复用的代码,可对 mini.test 函数做"monkey-patch"式封装。例如统一子进程的启动与插件加载:

local Helpers = {} Helpers.new_child_neovim = function() local child = MiniTest.new_child_neovim() child.setup = function() child.restart({'-u', 'scripts/minimal_init.lua'}) child.bo.readonly = false child.lua([[M = require('hello_lines')]]) end return child end return Helpers

codecompanion.nvim 的tests/helpers.lua是这一实践的完整范例:它把测试配置注入(setup_plugin)、mock HTTP 客户端(mock_http/queue_mock_http_response/get_mock_http_requests)、mock 聊天提交(mock_submit/restore_submit)、搭建聊天 buffer(setup_chat_buffer/teardown_chat_buffer)、模拟工具调用(make_tool_call)等高频操作全部封装为函数,并复用tests/expectations.lua中的自定义断言(通过vim.tbl_extend("error", Helpers, require("tests.expectations"))合并)。值得注意的是其mock_http_client()的实现思路:让所有 HTTP 请求默认"永不回调"、setup_chat_buffer注入tests.config中定义的本地test_adapter(见tests/config.lua)——测试真正聚焦于插件自身的逻辑链路。

2. 在文件顶部为常用函数写别名

提升可读性并减少重复:

-- 某段初始化 child 的代码 local set_lines = function(lines) child.api.nvim_buf_set_lines(0, 0, -1, true, lines) end

3. 注意自动命名截图的平台限制

使用自动命名的截图时注意:部分系统(如 Windows、macOS)文件系统大小写不敏感,两个仅大小写不同的文件名会引发问题;部分系统对完整路径长度(如部分 Git+Windows 组合的 260 字节)或文件名长度有限制(如 ext4 Windows 分区的 255 字节、eCryptfs Linux 分区的 143 字节)。建议通过缩短用例名把文件名控制在 143 字节以内,路径长度则难以完全规避,尽量让目录层级扁平一些。

4. 用 tree-sitter 高亮内嵌 Lua 代码

为便于阅读child.lua/child.lua_get中作为字符串的 Lua 代码,可在个人配置的after/queries/lua/injections.scm中添加如下 capture(文件开头必须有; extends,见:h treesitter-query-modeline-extends):

; extends (function_call name: (dot_index_expression table: (identifier) @_table field: (identifier) @_field) arguments: (arguments (string content: (string_content) @injection.content)) (#eq? @_table child) (#any-of? @_field lua lua_get) (#set! injection.language "lua"))

分层测试思路:单元断言 + 子进程集成 + 真实链路验证

把 mini.test 的各个能力组合起来,可以搭建一套分层测试策略,这也正是 codecompanion.nvim 仓库的实际形态:

  • 纯逻辑断言:不依赖 Neovim 状态的函数(如消息组装、参数转换)直接在当前进程用eq/ 自定义断言验证;
  • 子进程集成测试:涉及 buffer、窗口、extmark、输入模拟的功能,在pre_case重启子进程、加载插件后,配合child.api.*child.type_keys()expect.reference_screenshot做端到端行为验证(参见tests/test_init.luatests/interactions/chat/tools/builtin/test_run_command.lua等大量用例);
  • 真实模型链路测试(可选):仓库还提供了tests/scripts/tool_testing/README.md描述的一套独立于 mini.test 的"工具回归测试"方案——用真实 LLM adapter 驱动 chat buffer、等待工具执行后校验结果,以捕捉单元测试无法覆盖的"模型输出接进完整工具编排器"才出现的回归,并支持--csv跨轮次累计对比、--repeat测量一致性等运维能力。

在编写测试时始终牢记 mini.test 的核心设计哲学:每个用例都应在一个全新、最小化的 Neovim 进程中运行。hooks 负责环境初始化与清理,参数化负责场景覆盖,截图断言负责视觉回归,而n_retryfinally()则帮你驯服偶发的时序问题——这套组合拳足以覆盖 Neovim Lua 插件测试的绝大多数场景。

【免费下载链接】codecompanion.nvim✨ AI Coding, Vim Style项目地址: https://gitcode.com/GitHub_Trending/co/codecompanion.nvim

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

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

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

立即咨询