18-测试 - Some-soda

18-测试

第 18 章 测试

LazyVim 可以配置 Neotest 插件,这是一个通用的测试运行器,用于在多种语言和测试框架中选择和运行测试。与调试适配器一样,Neotest 默认并未启用。但是,如果启用了该插件,大多数语言的 extras 包(LazyVim 提供的扩展包)都带有预配置的设置,可以使测试自动运行。

当然,除非它不工作。就像调试器一样,我发现编辑器提供的测试扩展(无论哪个编辑器)所提供的编辑器内功能都过于繁琐,不值得花时间去配置它们。我会在一个单独的终端里运行一个处于“监视模式”(watch mode)的测试运行器,这对我来说效果很好。我以前不使用 Neotest,但在写完这一章后,我改变了主意!

18.1 试用 Neotest

如果你还没有这样做(作为启用推荐插件的一部分),请打开 Lazy Extras 界面(:LazyExtras)并启用 test.core extra。这将为你设置好 Neotest 和几个依赖项。

同时,请确保你正在使用的语言的 extras 也已启用,并仔细检查它们是否包含与测试相关的插件。在本例中,我们将使用 neotest-python,它包含在 lang.python extra 中。

启用 extra 后,你会在你的空格模式菜单(space-mode menu)中看到一个新的顶级“test”命令,可以通过 <Space>t 访问:

menu dark

图 98. 测试菜单

译者注: “空格模式菜单” 指按下空格键后弹出的快捷操作菜单。

正如你所预期的,易于输入的 <Space>tt 是菜单中最重要的命令;它运行当前文件并解析测试结果。你也可以在光标位于某个测试内部时使用 <Space>trr 代表 “Run”,运行)来运行该单个测试。

成功运行测试后,Neotest 会在你的界面中放置几个勾选图标,以便你可以看到它们已成功运行:

success dark

图 99. 测试成功运行

这张包含两个测试的截图是在我使用 <Space>tt 运行了文件中的所有测试后截取的。在行号栏(gutter)中有一个复选标记,另一个在测试代码右侧以虚拟文本(virtual text)形式显示。

译者注: “Gutter” 指编辑器左侧显示行号、断点标记等的区域。“Virtual text” 指 Neovim 在代码行旁边或行尾显示的、非实际文件内容的注释性文本。

18.2 错误报告

当我们引入一个测试失败时,事情变得更有趣了。首先,会弹出一个可滚动的窗口,显示测试输出;这与我在终端运行测试命令(本例中是 pytest)时看到的输出相同:

error output dark

图 100. 测试错误输出

当这个窗口在运行测试后弹出时,它默认不会获得焦点,你可以通过移动光标来关闭它(类似于诊断信息窗口)。如果你希望聚焦该窗口(例如,以便你可以使用 <Control-d><Control-u> 来滚动它),你可以使用 <Space>to,其中 o 代表 “output”(输出)。你可以随时使用这个键绑定来显示最近的测试输出。或者你可以使用 <Space>tO(大写的 O)将输出在编辑器下方的一个窗格(pane)中打开,而不是浮动窗口。

如果你同时也启用了 edgy Extra,Neotest 输出窗格的表现会更好。

译者注: edgy 是 LazyVim 的一个 extra,用于改善窗口边缘的布局和显示。

一旦浮动窗口获得焦点,你需要使用 q 快捷键来退出它。

当 Neotest 中的测试失败时,你显然不会在测试旁边看到复选标记。取而代之的是,你会看到一个小的红色 X。此外,在有失败的具体代码行处会有一个圆圈里的 x,并且在该问题行的右侧会有一些(可能是)信息性的虚拟文本:

error dark

图 101. 测试错误虚拟文本

最有帮助的是,会打开一个 Trouble 窗口,列出所有失败的测试。在这张截图中,我在文件中的两个不同测试里添加了 assert False

trouble dark

图 102. Trouble 中的失败测试

译者注: Trouble 是一个 Neovim 插件,用于在一个专门的窗口中聚合显示诊断信息、LSP 引用、测试结果等。

超级方便,因为我现在可以使用 ]q[q 在失败的测试之间导航(可能跨越多个文件),或者通过聚焦 Trouble 窗口并使用基本的光标移动来进行导航。

18.3 测试摘要

带有大写 T 的 <Space>tT 执行一个“更大范围”的操作,运行你项目中的所有测试,而不仅仅是当前文件中的测试。当然,对于任何未打开的文件,你将看不到测试标记。因此,你可能想要使用 <Space>ts 来切换摘要(summary)窗口:

summary dark

图 103. 测试摘要

摘要视图有一些有用的键盘快捷键(JK 最常用),你可以在其获得焦点时输入 ? 来查看:

summary mappings dark

图 104. 测试摘要键盘映射

“标记”(mark)的 m 命令值得一提。它允许你将一个测试标记为“感兴趣的”,这样当你使用 RRun marked(运行已标记)命令时,它将只运行那些被标记的测试。使用 M(大写)清除所有标记,或者在已标记的行上使用 m 来切换单个标记的关闭状态。

18.4 监视模式和调试

你可以使用 <Space>tw 来切换当前文件的“监视”(watch)模式。这将在你的源代码每次更改时自动运行测试命令。摘要窗口和测试文件图标都将实时更新。

如果你已经按照第 18 章(译者注:原文如此,应指前文关于调试的章节,实际可能是第 17 章或相关调试章节)所述启用了调试适配器,你甚至可以通过使用 <Space>td 运行测试,让测试在失败时自动添加断点。这对于快速检查局部变量或添加监视语句(watch statements)很有用,而不是在断言之前添加一堆print语句。

18.5 安装测试运行器

如果你足够幸运,你的语言有一个预配置好的与 Neotest 协同工作的 LazyVim extra。例如,lang.golang.python extras 都包含了为这些框架设置 Neotest 的配置。

然而,并非所有语言都有一个明确的默认测试运行器。例如,如果你使用 Typescript 编码,你可能更喜欢 vitest、jest 或 deno test runner。这三者都有 Neotest 支持,但默认情况下,它们都没有随 Typescript extra 一起启用。

对于这类语言,你需要进行一些手动配置。让我们尝试为 vitest 设置一个作为示例。

我们需要的插件是 neotest-vitest。我们需要将该仓库 README 中的说明与 LazyVim 在 Neotest 页面上的示例结合起来。

我在我的插件目录中创建了一个新的 vitest.lua 文件,并向其中添加了以下配置:

清单 65. Neotest-vitest 配置

return {
  { "marilari88/neotest-vitest" },
  {
    "nvim-neotest/neotest",
    opts = { adapters = { "neotest-vitest" } },
  },
}

然后我重启了 Neovim 并打开了一个包含 vitest 测试的文件。<Space>tt 正常工作了,插件配置完成。

在我测试的仓库中,vitest 是通过 npm 安装的,所以不需要额外的安装。在大多数情况下,我预计 Neotest 插件调用的工具在访问你的项目时就已经安装好了。如果没有,你或许可以从 Mason 菜单安装它,可以通过 <Space>cm 访问。

译者注: Mason 是一个 Neovim 插件,用于管理 LSP 服务器、格式化器、linter 等外部工具。

18.6 编写你自己的测试适配器

这有点超出了本书的范围,但我决定包含它,因为 a) 我无论如何都需要做这件事,b) 这一章出奇地短,c) 这是编写一个简单插件的好例子。

编写自己的 Neotest 适配器只需要实现五个方法来匹配 neotest.Adapter 接口。然而,细节决定成败。

译者注: “适配器”(Adapter)在这里指一个特定的模块或代码片段,它允许 Neotest 与特定的测试框架(如 Jest, Pytest, Bun test)进行交互。老实说,这章可以不看。

在这个例子中,我将为 Bun 测试运行器编写一个测试适配器。我选择 Bun 的部分原因是因为目前还没有针对它的 Neotest 适配器,而且我自己在项目中使用 Bun。但它也是一个很好的演示选择,因为已经有三个 Typescript/Javascript 的 Neotest 适配器可以供我们借鉴和获取灵感:

我们不会实现所有可能的功能(特别是调试器会缺失),但我们将掌握运行 Bun 测试和解析输出的基础知识。

如果你不熟悉 Bun,它是一个 Javascript/Typescript 的运行时和编译器,或多或少是 nodejs 的替代品。内置命令 bun test 运行一个类似 jest 的测试套件。我们将要绑定的就是这个命令。

18.6.1 初始化本地插件

默认情况下,LazyVim 从 GitHub 等提供商处下载插件。但是,你可以给它传递任何 git url 或将其指向一个本地目录。我们将采用后一种方式。

首先,让我们在一个新目录中初始化一个基本的插件结构。你需要创建三个嵌套的目录:

清单 66. Neotest Bun 目录创建命令

$ mkdir -p neotest-bun/lua/neotest-bun

第一个 neotest-bun 实际上可以是任何名称。lua 目录是 LazyVim 能够识别其中任何文件所必需的,最后一个 neotest-bun 是一个我们将在配置中导入的 lua 模块。

在这个目录内部,创建一个名为 init.lua 的文件。文件内容现在可以只是一些简单的 Lua 代码:

清单 67. 简单的 Lua 脚本

print("Hello, Lua!")

下一步是将这个本地插件连接到我们的 LazyVim 配置中。在你的 LazyVim 插件目录中创建一个新文件(我将其命名为 neotest-bun.lua)。我们将使用与上面 vitest 相同的格式,只不过我们使用 dir 键指向我们的本地插件:

清单 68. 本地插件配置

return {
  { dir = "~/Desktop/Code/neotest-bun/" }, -- 请确保路径指向你实际创建的 neotest-bun 目录
  {
    "nvim-neotest/neotest",
    opts = { adapters = { "neotest-bun" } },
  },
}

译者注: 请将上面 dir 的值 ~/Desktop/Code/neotest-bun/ 替换为你自己创建 neotest-bun 插件的实际本地路径。

要查看其目前是否工作,请在 Neovim 中打开任何文件,并运行 <Space>tt 来尝试启动测试运行器。它会失败,因为我们还没有正确实现适配器接口,但它也应该弹出一个通知,显示“Hello, Lua!”。

通知会很快消失,如果它包含你想要检查的回溯(traceback)信息,这会很不方便。你可以随时使用 <Space>sna 在一个窗格中显示所有最近的消息。:messages 命令也可以做到。

译者注: <Space>sna 是 LazyVim 中用于显示通知历史的快捷键。

18.6.2 实现 Neotest 适配器

让我们来充实适配器接口。打开 neotest-bun/lua/neotest-bun/init.lua 文件,并将 print 语句替换为以下内容:

清单 69. Neotest 适配器接口

local BunNeotestAdapter = { name = "neotest-bun" }

-- 查找项目根目录
function BunNeotestAdapter.root(dir) end

-- 过滤不应扫描的目录
function BunNeotestAdapter.filter_dir(name, rel_path, root) end

-- 判断一个文件是否是测试文件
function BunNeotestAdapter.is_test_file(file_path) end

-- 在文件中发现测试的位置(例如,具体的 test 或 describe 块)
function BunNeotestAdapter.discover_positions(file_path) end

-- 构建运行测试所需的具体命令和上下文
function BunNeotestAdapter.build_spec(args) end

-- 解析测试运行的输出结果
function BunNeotestAdapter.results(spec, result, tree) end

return BunNeotestAdapter

这就是我们需要实现的接口。如果你想知道我是从哪里得到这个的,它定义在 Neotest 的源代码 中,并且 Neotest 的 README 中也有链接。我还打开了 neotest-jestneotest-deno 包的 GitHub 仓库以供参考。

你需要退出 Neovim 并重新启动它,才能加载你对 init.lua 文件所做的任何更改。记住,你可以使用 <Space>qq 命令退出 Neovim,然后从仪表盘(dashboard)使用 s 命令来恢复你的设置,过程很方便。

译者注: <Space>qq 是 LazyVim 退出 Neovim 的快捷键。仪表盘上的 s 通常指恢复上一次会话(Session)。

如果你现在尝试在一个 Bun 测试文件上运行测试,它(很可能)会失败,并显示“No Tests Found”(未找到测试)的消息。如果它没有失败,可能是安装的另一个测试运行器认为这是一个合法的测试文件。

我们的适配器目前正确地报告了它是一个 Neotest 适配器,但随后它未能将当前文件夹或文件注册为测试文件。我们可以通过实现文件中的前三个方法来解决这个问题。

root 方法应该根据当前目录找到项目的根目录。在使用 Bun 时,我们可以使用 bun.lockb 文件是否存在来判断当前项目的根目录。这个文件在你运行 bun install 时生成,用于跟踪依赖关系。

所以,让我们像这样实现 BunNeoTestAdapter.root 方法:

清单 70. 根目录实现

local lib = require("neotest.lib") -- 引入 neotest 库

local BunNeotestAdapter = { name = "neotest-bun" }

function BunNeotestAdapter.root(dir)
  -- 使用 neotest 库函数查找包含 bun.lockb 文件的根目录
  return lib.files.match_root_pattern("bun.lockb")(dir)
end

-- ... (其他函数保持不变) ...

return BunNeotestAdapter

这里的关键是 neotest.lib 的函数 match_root_pattern。我们导入该库并将其赋值给一个本地变量,然后我们的 root 函数只需要创建一个回调函数并调用它。

趁热打铁,我们也可以实现 filter_dir 函数。这个方法旨在过滤掉不应该被扫描的目录。在一个 Bun 项目中,这包括 node_modules 文件夹。我们绝对不想浪费时间扫描那个文件夹里的测试!

清单 71. 过滤目录实现

-- ... (root 函数如上) ...

function BunNeotestAdapter.filter_dir(name, rel_path, root)
  -- 如果目录名不是 node_modules,则返回 true(不过滤)
  return name ~= "node_modules"
end

-- ... (其他函数保持不变) ...

return BunNeotestAdapter

现在,如果你重启 Neovim 并尝试用 <Space>tT(第二次是大写 T)运行测试,它将不会显示“No Tests found”的消息了。它不会做任何事情,但至少不会报错。然而,<Space>tt 会报错,因为它不知道我们当前是否在一个测试文件中。我们可以通过 is_test_file 方法来解决这个问题。

在 Bun 中,像大多数 Javascript 运行时一样,测试通常放在 something.test.jssomethingElse.test.ts 文件中。所以我们可以使用以下 Lua 函数来检查我们是否在一个测试文件中:

清单 72. Is Test File Implementation

-- ... (root 和 filter_dir 函数如上) ...

function BunNeotestAdapter.is_test_file(file_path)
  -- 使用 Lua 的字符串匹配检查文件名是否以 .test.js 或 .test.ts 结尾
  return string.match(file_path, ".*.test.[tj]s$") ~= nil
end

-- ... (其他函数保持不变) ...

return BunNeotestAdapter

如果我现在打开我的 cohere.test.ts 文件并运行 <Space>tt,我仍然得到 No Tests found。它识别出该文件是 Bun 测试文件,但它不知道如何查看文件内部以找到任何测试。

18.6.3 发现测试位置

解决这个问题需要实现 discover_positions 函数,而这……有点复杂。通常,你会编写 Treesitter 查询来识别文件中的命名空间(namespaces)和测试。我猜你也可以编写自己的解析器或使用 string.match,但 Treesitter 的解析器可能比我们能写的任何东西都要好。

译者注: Treesitter 是一个增量解析库,Neovim 使用它来提供更准确和快速的语法高亮、代码结构分析等功能。编写 Treesitter 查询可以精确地定位代码中的特定结构,如函数定义、测试块等。

我对编写 Treesitter 查询一无所知,也不特别想学。所以我将依赖于这样一个事实:Bun 使用与 Jest 相同的 describe/test 语法,我将直接从 neotest-jest 插件中完整复制这些查询!

这是一段相当长的代码,在书本中可能很难阅读,但我会为了完整性而包含在这里:

清单 73. 借用的发现位置查询

-- ... (之前的函数实现) ...
-- 确保在文件顶部引入 lib
-- local lib = require("neotest.lib")

function BunNeotestAdapter.discover_positions(file_path)
  -- Treesitter 查询,用于查找 describe (命名空间) 和 test/it (测试) 块
  local query = [[
    ; -- Namespaces --
    ; Matches: `describe('context', () => {})`
    ((call_expression
      function: (identifier) @func_name (
        #eq? @func_name "describe"
      )
      arguments: (arguments (
        string (string_fragment) @namespace.name
      ) (arrow_function))
    )) @namespace.definition
    ; Matches: `describe('context', function() {})`
    ((call_expression
      function: (identifier) @func_name (
        #eq? @func_name "describe"
      )
      arguments: (arguments (
        string (string_fragment) @namespace.name
      ) (function_expression))
    )) @namespace.definition
    ; Matches: `describe.only('context', () => {})`
    ((call_expression
      function: (member_expression
        object: (identifier) @func_name (
          #any-of? @func_name "describe"
        )
      )
      arguments: (
        arguments (string (string_fragment) @namespace.name
      ) (arrow_function))
    )) @namespace.definition
    ; Matches: `describe.only('context', function() {})`
    ((call_expression
      function: (member_expression
        object: (identifier) @func_name (
          #any-of? @func_name "describe"
        )
      )
      arguments: (arguments (
        string (string_fragment) @namespace.name
      ) (function_expression))
    )) @namespace.definition
    ; Matches: `describe.each(['data'])('context', () => {})`
    ((call_expression
      function: (call_expression
        function: (member_expression
          object: (identifier) @func_name (
            #any-of? @func_name "describe"
          )
        )
      )
      arguments: (arguments (
        string (string_fragment) @namespace.name
      ) (arrow_function))
    )) @namespace.definition
    ; Matches: `describe.each(['data'])('context', function() {})`
    ((call_expression
      function: (call_expression
        function: (member_expression
          object: (identifier) @func_name (
            #any-of? @func_name "describe"
          )
        )
      )
      arguments: (arguments (
        string (string_fragment) @namespace.name
      ) (function_expression))
    )) @namespace.definition

    ; -- Tests --
    ; Matches: `test('test') / it('test')`
    ((call_expression
      function: (identifier) @func_name (
        #any-of? @func_name "it" "test"
      )
      arguments: (arguments (
        string (string_fragment) @test.name
      ) [(arrow_function) (function_expression)])
    )) @test.definition
    ; Matches: `test.only('test') / it.only('test')`
    ((call_expression
      function: (member_expression
        object: (identifier) @func_name (
          #any-of? @func_name "test" "it"
        )
      )
      arguments: (arguments (
        string (string_fragment) @test.name
      ) [(arrow_function) (function_expression)])
    )) @test.definition
    ; Matches: `test.each(['data'])('test')
    ((call_expression
      function: (call_expression
        function: (member_expression
          object: (identifier) @func_name (
            #any-of? @func_name "it" "test"
          )
          property: (property_identifier) @each_property (
            #eq? @each_property "each"
          )
        )
      )
      arguments: (arguments (
        string (string_fragment) @test.name
      ) [(arrow_function) (function_expression)])
    )) @test.definition
  ]]

  -- 使用 neotest 库函数和 Treesitter 查询来解析文件,找到测试位置
  local positions = lib.treesitter.parse_positions(
    file_path, query, {
    nested_tests = false, -- Bun/Jest 不支持嵌套测试的标准方式
  })

  return positions
end

-- ... (build_spec 和 results 函数保持不变) ...

return BunNeotestAdapter

现在你可以重启 Neovim 并打开一个 bun 测试文件,会得到一个新的错误!新错误就是进展,对吧?

你现在会注意到 Neotest 正在行号栏(gutter)中标示出 describetest 调用的位置。与我们期望的成功测试运行的绿色勾或红色叉不同,它会是一个带叉的眼睛图标。我猜这意味着测试被跳过或无法运行。好消息是它正在找到测试。坏消息是它没有运行测试。

18.6.4 构建规范 (Building the Spec)

我们可以通过实现 build_spec 函数来运行测试。这个函数接受各种参数,以确定用户是如何启动测试的。如果他们使用 <Space>tr,则处于“单个测试”模式;如果使用 <Space>tt,则处于“文件”模式;而 <Space>tT 将以“所有测试”模式运行。

build_spec 的返回值本质上是一个要运行的命令和一些用于读回结果的上下文信息。

代码实际上并不长,所以这里是它的完整内容,随后进行讨论:

清单 74. Build Spec 实现

-- 确保在文件顶部引入 async
local async = require("neotest.async")
-- 并且确保 lib 已经引入
-- local lib = require("neotest.lib")

-- ... (之前的函数实现) ...

function BunNeotestAdapter.build_spec(args)
  -- 创建一个临时文件路径来存储测试结果
  local results_path = async.fn.tempname()
  -- 获取当前测试节点(测试、命名空间、文件或目录)的数据
  local position = args.tree:data()
  -- 获取项目根目录
  local cwd = assert(
    BunNeotestAdapter.root(
      position.path -- position.path 是文件路径
    ),
    "无法定位根目录 " .. position.path)
  local command = nil

  -- 根据触发测试的类型构建不同的 bun test 命令
  if position.type == "test" or position.type == "namespace" then
    -- 运行单个测试或命名空间下的所有测试
    command = "bun test " ..
      position.path .. -- 文件路径
      " --test-name-pattern " .. -- Bun 用于匹配测试名称的参数
      "\"" .. position.name .. "\"" -- 测试或命名空间的名称 (加引号处理特殊字符)
  elseif position.type == "file" then
    -- 运行文件中的所有测试
    command = "bun test " .. position.path -- 这里用 position.path 更准确
  elseif position.type == "dir" then
    -- 运行目录(通常是项目根目录)下的所有测试
    command = "bun test"
  end

  -- 返回一个包含命令、上下文和工作目录的对象
  return {
    -- 将标准错误重定向到临时文件,因为 bun test 把结果输出到 stderr
    command = command .. " 2>" .. results_path,
    context = {
      results_path = results_path, -- 将临时文件路径传递给 results 函数
    },
    cwd = cwd, -- 指定命令运行的工作目录
  }
end

-- ... (results 函数保持不变) ...

return BunNeotestAdapter

译者注:build_spec 函数中,对 position.name 添加了引号,以更好地处理包含空格或特殊字符的测试名称。确保在文件顶部添加 local async = require("neotest.async")

我们首先使用 neotest.async 库创建了一个临时的 results_path。(你需要在文件顶部用 local async = require("neotest.async") 来导入它)。我们加载 position,这是一个根据我们刚刚编写的 discover_positions 方法的返回值构建的结构。

if..elseif 块基本上是在检查用户是如何启动测试的,并运行相应的 Bun 命令。如果他们提供了一个测试名称,那么我们将 --test-name-pattern 参数传递给 bun test 命令。如果他们以文件形式启动它,我们运行 bun test filename。如果他们想运行所有测试,我们简单地运行 bun test 来运行所有测试。

Bun 测试运行器非常快,所以我预计主要会使用最后一种形式,即 <Space>tT,并在左侧边栏打开摘要视图。

返回的对象包含一些必要的上下文,这些上下文将在我们解析结果时使用。bun test 命令比较特别,它将结果输出到标准错误(standard error),所以我们传递一个 2> 重定向,将结果存储在我们定义的临时文件中。

现在我们只需要从那个文件中提取结果。

18.6.5 解析结果

这部分比我预期的要简单,因为 bun test 聚合名称的方式与 Neotest 期望的形式很容易映射。但我还是花了半天时间折腾,才得到能正常工作的代码!

results 方法主要只需要读取由我们的 build_spec 函数创建的 results_path 文件,并将其转换为一个简单的 Lua 表。结果表的键(key)是相关测试的名称,值(value)只是另一个包含 {status = "passed"}{status = "failed"} 的表。至少,这是我们打算放入的所有内容。Neotest 在这里确实接受一些其他细节,可以在 UI 中呈现,但我将把它留作“读者的练习”。

当阅读其他教学书籍时,“读者的练习”只是作者懒惰的表现,或者(偶尔)是出版商试图削减字数。现在你知道了。

棘手的部分是“键是测试的名称”。我找不到任何关于这方面的文档,经过一些反复试验才发现 Neotest 中嵌套的“命名空间”(在本例中是 describe 调用)是用 :: 分隔的。名称还需要包含测试文件的绝对路径。如果我们以正确的格式返回它,Neotest 将很乐意将我们的结果转换为相应的图标!

这是代码:

清单 75. Neotest 结果解析实现

-- ... (之前的函数实现) ...
-- 确保 io 和 string 在作用域内(Lua 标准库,通常不需要显式 require)

function BunNeotestAdapter.results(spec, result, tree)
  local results = {} -- 初始化结果表
  -- 打开由 build_spec 创建的包含测试输出的临时文件
  local file = io.open(spec.context.results_path, "r")
  if not file then
    print("错误:无法打开结果文件 " .. spec.context.results_path)
    return results -- 如果文件打不开,返回空结果
  end

  local line = file:read("l") -- 读取第一行 ("l" 表示读取一行)
  local current_file_absolute_path = ""

  while line do
    -- 尝试匹配通过的测试行,例如: (pass) Describe block > Test name [0.40ms]
    -- 改进了匹配,允许浮点数时间,并捕获测试名称
    local pass_match = string.match(line, "^%(pass%) (.*) %[%.%d+ms%]$")
    if pass_match then
      -- 将 Bun 输出中的 " > " 替换为 Neotest 使用的 "::"
      local test_name_neotest_format = string.gsub(pass_match, " > ", "::")
      -- 构建 Neotest 需要的完整测试 ID:绝对路径::命名空间::测试名
      results[current_file_absolute_path .. "::" .. test_name_neotest_format] = { status = "passed" }
    end

    -- 尝试匹配失败的测试行,例如: (fail) Describe block > Test name [1.24ms]
    local fail_match = string.match(line, "^%(fail%) (.*) %[%.%d+ms%]$")
    if fail_match then
      local test_name_neotest_format = string.gsub(fail_match, " > ", "::")
      results[current_file_absolute_path .. "::" .. test_name_neotest_format] = { status = "failed" }
    end

    -- 尝试匹配文件名行,例如: src/clients/stability/stability.test.ts:
    -- 改进了匹配,处理更广泛的文件名字符
    local file_match = string.match(line, "^([^:]+%.test%.[tj]s):$")
    if file_match then
      -- 存储当前文件的绝对路径,spec.cwd 是项目根目录
      -- 确保路径分隔符正确 (在 Unix/Linux/Mac 上是 /)
      current_file_absolute_path = spec.cwd .. "/" .. file_match
      -- 如果在 Windows 上运行,可能需要处理路径分隔符
      -- current_file_absolute_path = spec.cwd .. "\\" .. file_match
    end

    line = file:read("l") -- 读取下一行
  end

  file:close() -- 关闭文件

  -- 清理临时文件 (可选但推荐)
  os.remove(spec.context.results_path)

  return results -- 返回解析后的结果表
end

return BunNeotestAdapter

译者注: 对清单 75 中的正则表达式进行了微调以提高健壮性,并添加了错误处理和临时文件清理的建议,可能会不以预期形式运行,(*^_^*)

为了提供上下文,这个函数旨在将如下所示的输出:

清单 76. Bun Test Output

src/clients/stability/stability.test.ts:
(pass) Stability > generates a reference image [0.40ms]

src/clients/passage/passage.test.ts:
(pass) Passage > GetUserTier > Undefined [1.24ms]
(pass) Passage > GetUserTier > with tier free
(fail) Passage > GetUserTier > with tier hobby

转换为类似这样的结构:

清单 77. Translated Bun Test Output

{
  ["/.../project_root/src/clients/stability/stability.test.ts::Stability::generates a reference image"] = {
    status = "passed"
  },
  ["/.../project_root/src/clients/passage/passage.test.ts::Passage::GetUserTier::Undefined"] = {
    status = "passed"
  },
  ["/.../project_root/src/clients/passage/passage.test.ts::Passage::GetUserTier::with tier free"] = {
    status = "passed"
  },
  ["/.../project_root/src/clients/passage/passage.test.ts::Passage::GetUserTier::with tier hobby"] = {
    status = "failed"
  },
}

译者注: 上面 Lua 表中的 /.../project_root/ 代表项目的绝对路径前缀。

该方法首先从上下文(context,由 build_spec 方法返回)中获取文件名。它打开文件并逐行读取文件内容。然后,它使用匹配器来判断该行是否以 (pass)(fail) 开头,这是 Bun 报告测试结果的方式。测试行的其余部分将是正确命名空间的测试名称(减去方括号中的计时信息),只是命名空间由 > 而不是 :: 分隔。所以我们使用 gsub> 替换为 ::。这与文件的绝对路径结合起来,得到正确的测试名称。

如果某一行不是测试名称,那么它可能是测试文件的名称,Bun 会以相对路径后跟冒号的形式友好地指定它。所以我们对该格式进行匹配,并将测试文件名存储为绝对路径(这就是 spec.cwd 的用途),以便预置到后续的测试结果中。

这就是实现我们自己的测试运行器的基础知识!它缺少一些功能,特别是调试测试和捕获输出,但这是一个好的开始。

18.7. 总结

本章介绍了 Neotest 插件,包括调用它的各种方式,以及如何通过简单方式、困难方式和超难方式来设置它。

我们还学到了一点关于如何编写 Neovim 插件的知识。其核心就是一堆 LazyVim 可以导入的 Lua 文件集合。在本例中,这个 Lua 文件是一个 Neotest 适配器,我们配置它将我们的插件加载到 Neotest 中。