💡

neotestを拡張してApex言語の関数テストを実行できるようにする

に公開

はじめに

neotestでは、neovimから関数単位でテストを実行するのに使えます。
私はF4でカーソル位置関数のテスト実行するようにしています。

設定例

最小ではないですが、こんな感じで設定しています。

  • @isTestの下の行の関数名を取得している
  • 関数ブロックの検出は実装できておらず、関数定義のところにカーソルを合わせないといけない。
local Tree = require("neotest.types").Tree
local function make_test_id(path, method)
  return path .. "::" .. method
end

local function discover_positions(path)
  local lines = vim.fn.readfile(path)
  if #lines == 0 then
    return
  end

  local class_name
  local tests = {}
  for i, line in ipairs(lines) do
    if not class_name then
      class_name = line:match("class%s+([%w_]+)")
    end
    -- @isTestの直後のstatic void
    if line:match("^%s*@isTest") and lines[i + 1] then
      local method = lines[i + 1]:match("^%s*static%s+void%s+([%w_]+)%s*%(")
      if method then
        table.insert(tests, {
          id = make_test_id(path, method),
          type = "test",
          name = method,
          path = path,
          range = { i, 0, i + 1, 0 },
          class = class_name,
          method = method,
        })
      end
    end
    -- 1行で@isTest static void <method>()
    local method_inline = line:match("^%s*@isTest%s+static%s+void%s+([%w_]+)%s*%(")
    if method_inline then
      table.insert(tests, {
        id = make_test_id(path or "", method_inline),
        type = "test",
        name = method_inline,
        path = path,
        range = { i - 1, 0, i - 1, 0 },
        class = class_name,
        method = method_inline,
      })
    end
  end

  local nodes = {
    {
      id = path,
      type = "file",
      path = path,
      name = class_name or path,
      range = { 0, 0, #lines, 0 },
    },
  }
  for _, test in ipairs(tests) do
    table.insert(nodes, test)
  end

  return Tree.from_list(nodes, function(pos)
    return pos.id
  end)
end

local fixed_dir = "/tmp/sf-test-result"

local apex_adapter = {
  name = "apex",
  root = function(dir)
    -- 例: .sfdxやsfdx-project.jsonがあるディレクトリをルートとみなす
    if vim.fn.glob(dir .. "/sfdx-project.json") ~= "" then
      return dir
    end
    return nil
  end,
  is_test_file = function(file_path)
    return file_path:match("%.cls$")
  end,
  -- discover_positions = function(path)
  --   return discover_positions(path)
  -- end,
  -- テストメソッドの位置を特定する関数

  discover_positions = discover_positions,
  build_spec = function(args)
    local data = args.tree:data()
    local class = data.class or data.name
    local method = data.method
    local test_arg = class .. "." .. method
    local command = {
      "sf",
      "apex",
      "run",
      "test",
      "--tests",
      test_arg,
      "--result-format",
      "json",
      "--synchronous",
      "-d",
      fixed_dir,
    }

    return {
      command = table.concat(command, " "),
      cwd = vim.fn.getcwd(),
    }
  end,
  results = function(spec, result, tree)
    local result_path = fixed_dir .. "/test-result.json"
    local output

    if vim.fn.filereadable(result_path) == 1 then
      output = table.concat(vim.fn.readfile(result_path), "\n")
    end
    local ok, parsed = pcall(vim.fn.json_decode, output)
    if not ok then
      return {}
    end

    local results = {}
    for _, test in ipairs(parsed.tests) do
      for _, node in tree:iter_nodes() do
        local data = node:data()

        -- tree:iter_nodes()でidが一致するノードを探す
        local test_id = make_test_id(data.path, test.MethodName)
        if data.id == test_id then
          results[data.id] = {
            status = (string.lower(test.Outcome) == "pass") and "passed" or "failed",
            short = test.Message and test.StackTrace and (test.Message .. "\n" .. test.StackTrace)
              or test.Message
              or test.StackTrace
              or "",
          }
        end
      end
    end

    return results
  end,
}

require("neotest").setup({
  adapters = {
    apex_adapter,
  },
})

vim.api.nvim_set_keymap("n", "<F4>", ':lua require("neotest").run.run()<CR>', { noremap = true, silent = true })

Discussion