Neovimプラグイン開発にSnapshot testingを導入してみる
はじめに
Snapshot testing、便利ですよね。
JestやVitestといったフロントエンドテスト基盤でも言及されてる通り、
"望まぬ変更"を検出するのに、結局最終的な出力を担保しておこうというのは自然な発想に思えます。
Neovimプラグイン開発(あるいは設定)のテストの苦悩
さて、懸命なZenn.dev読者諸氏であれば、Neovimのプラグイン開発の10や20、経験のあることと思います。
そして、ある日突然壊れる自作プラグインに苦しめられたことは数え切れぬほど。
しかし、プラグイン開発におけるテストというのはなかなか難しいものです。
Neovimのプラグインというものは、結局のところフロントエンドで何らかの作用を期待することが多いもの。
それをテストで表現しようとすれば
"バッファの内容が文字列としてこうなってるはずだ"
"Statusline(などの画面構成要素)を表現するオプション値がこうなってるはずだ"
といった、テストのために期待する値を組み立てる細かなロジックを書く本末転倒なものになりがちです。
そこで、Snapshot testingを導入することで、これを解決できると考えたのです。
作ったもの
今回作ったのは nvim-snap というCLIツールです。
できること
できることは大きく次の2つです。
- リグレッションテスト
- 同じシナリオを複数コミットで実行し、結果の差分を検出する
- ゴールデンテスト
- golden/targetの2つのシナリオを同一実行で比較し、両者の差分を検出する
それぞれのシナリオをLuaで書いておくと、実行結果の画面全体をスナップショットとして保存します。
シナリオのスクリプトにはNeovimのAPIをそのまま使えるので、普段のプラグイン実装に近い感覚でテストを書けます。
拙作のプラグインqlean.nvimより、各シナリオの例を紹介します。
goldenシナリオの例 snapcase/golden/lmjgzmx0/golden.lua)
vim.fn.setline(1, { "foobar" })
targetシナリオの例 snapcase/golden/lmjgzmx0/target.lua)
local rule = require("qlean.rule")
require("qlean").setup({
keep = rule.any(rule.buftype("", "acwrite", "terminal"), rule.filetype("fern")),
})
vim.go.hidden = true
-- Make a new modified hidden buffer
vim.cmd.new()
vim.fn.setline(1, { "foobar" })
vim.cmd.wincmd("c")
-- Open a quickfix-window as a UI window (it'll not be kept)
vim.cmd.copen()
-- Close last kept window
-- - It closes other windows, hidden buffer is found, Neovim stops quitting because it is not saved (E37)
vim.cmd.wincmd("k")
-- NOTE: use `vim.api.nvim_cmd({cmd="quit"})` instead of `vim.cmd.quit`
-- because `vim.cmd.quit` waits user-input before a commit c2e0fd1c35c22b4c53f903fb46fe9005926b1e16
-- vim-patch:7.4.1886 (#36945)
--
-- Problem: When waiting for a character is interrupted by receiving channel
-- data and the first character of a mapping was typed, the mapping
-- times out. (Ramel Eshed)
-- Solution: When dealing with channel data don't return from mch_inchar().
--
-- https://github.com/vim/vim/commit/cda7764d8e65325d4524e5d6c3174121eeb12cad
pcall(vim.api.nvim_cmd, { cmd = "quit" }, { output = true })
vim.cmd.redraw()
require("nvim_snap").done()
各テストケースはディレクトリ(例)で管理し、snapcase.jsonにメタ情報や実行設定を記述します。
比較結果はテキストの差分に加えて、HTMLの差分ビューも出力できます。必要ならPNGに書き出すこともできます。
CIで使う想定もあったので、結果の保存先やcacheの扱いを含めて運用を整理しています。
リグレッションはスナップショットをコミットせず、CIのキャッシュに置く方針です。
ゴールデンは毎回golden/targetを双方実行して比較します。
nvim-snapで利用しているもの
nvim-snapはNeovimをheadlessで起動し、UIのスナップショットを取得しています。
- Neovimの埋め込みモードとmsgpack RPCでシナリオを実行
-
nvim_ui_attachのext_linegrid/ext_hlstateで画面とハイライトを取得 - 取得したデータを正規化してJSONとして保存
-
difflibとdiffmatchpatchで行差分・文字差分を生成 - HTMLで可視化し、必要ならヘッドレスブラウザでPNG化
Neovim側の機能や一部Chromiumなどのヘッドレスブラウザに強く依存する作りですが、そのぶんNeovimプラグイン開発との親和性やテスト結果の閲覧性は高く仕上がっていると思います。
既存の類似品...?
実は、Neovimには「画面の状態を記録する」機能が同梱されています。
test/functional/ui/screen.lua
ディレクトリ名から察する限り、Neovim本体開発のテスト用ですが、一応「画面の状態を取得してexpectと比較する」機能が備わっています。
実は、このnvim-snapを作る前には一旦これを使ったテスト用の何かを作ろうとしていました。
しかし、このscreen.luaが結構な曲者で、いまいちうまく使いこなせませんでした。
また、あくまでもNeovimの内部テスト用のものを、外部で依存して使うのも互換性破壊の可能性を考えるとあまり懸命とは言えません。
そこで今回のnvim-snap開発に至ったのでした。
おわりに
Neovimの設定やプラグインに凝っていて、テストに困ったことのある方に届けこのおもい。
正直いまだDog foodingのさなかではあるので、もし使ってみた感想や要望などありましたら、Issue等でお声がけください。

Discussion