Vimプラグイン開発でもユニットテストがしたい! (vim-vspec 編)


2013年 02月 08日

問題

Vim プラグイン開発でも継続的インテグレーションがしたい! (Travis CI 編)
では Vim プラグイン開発でも簡単に継続的インテグレーション(CI)を導入できることを解説しました。
ところで CI はユニットテストを書くことが前提です。
上記の記事では「どうユニットテストを書くか」についてはほとんど触れていませんでした。
実際にテストを書こうとすると、

  • テスト結果が実行環境に左右されないよう、無設定の状態の Vim を起動し、さらに関係のないプラグインや設定は読み込まれないよう細工をした上でテストを実行する必要がある。
  • テスト対象のプラグインが他のプラグインに依存している場合、適切なバージョンのものを取得した上でそれも利用可能な状態でテストを実行する必要がある。

という割と面倒臭い問題があります。
しかしもう2013年です。
どうにかして簡単にユニットテストを書けないものでしょうか。

解答

Vim プラグインのユニットテストを行うフレームワークは実装が乱立していますが、基本的にどれも

  • 先述の実行環境の問題を完璧にケアできていない
  • CI での利用のように自動化を考慮していない
  • 肝心のテストが書き易くない

のいずれかの難点を抱えています。

という訳でこれらの難点を全てクリアできるものを実装しました(→ vim-vspec)。
これを使ってユニットテストを始めてみましょう。

前提

テストスクリプトの作成

  • t という名前のディレクトリを作成します。
  • テストスクリプトは拡張子を .vim にして t の中に保存します。

テストスクリプトの構成

こと Vim プラグインのテストとなると Vim に関する細かい操作を記述する必要があります。
そして Vim を扱うには Vim が一番向いています。
なのでテストスクリプトは Vim script で記述します。

テストスクリプトの構成はおおよそ RSpec と同じです。
つまり、

  • テストしたい対象毎に describe ブロックを書き、
  • describe ブロックの中でテストしたい振る舞い毎に it ブロックを書き、
  • it ブロックの中でその振る舞いに関する動作確認のコードを書く

という構成です。具体的には以下のようなイメージです:

describe 'math#round_to_zero'
  it 'returns 0 as is'
    Expect math#round_to_zero(0) == 0
  end

  it 'returns a floor of a positive number'
    Expect math#round_to_zero(0.1) == 0
    Expect math#round_to_zero(1) == 1
    Expect math#round_to_zero(1.23) == 1
    Expect math#round_to_zero(123.456) == 123
  end

  it 'returns a ceiling of a negative number'
    Expect math#round_to_zero(-0.1) == 0
    Expect math#round_to_zero(-1) == -1
    Expect math#round_to_zero(-1.23) == -1
    Expect math#round_to_zero(-123.456) == -123
  end
end

テストの書き方

結果の比較には Expect を使います。
以下のような感じで書きます:

Expect foo#bar#baz() ==# 'qux'

テストの実行

先述の「前提」通りに設定を行っていれば rake test でテストが実行できます。
実行例は以下のような感じです:

$ rake test
bundle exec vim-flavor test
-------- Preparing dependencies
Checking versions...
  Use kana/vim-textobj-user ... 0.3.12
  Use kana/vim-vspec ... 1.1.0
Deploying plugins...
  kana/vim-textobj-user 0.3.12 ... skipped (already deployed)
  kana/vim-vspec 1.1.0 ... skipped (already deployed)
Completed.
-------- Testing a Vim plugin
Files=0, Tests=0,  0 wallclock secs ( 0.00 usr +  0.11 sys =  0.11 CPU)
Result: NOTESTS
t/c.vim .... ok
t/vim.vim .. ok
All tests successful.
Files=2, Tests=12,  2 wallclock secs ( 0.08 usr  0.72 sys +  0.21 cusr  0.97 csys =  1.98 CPU)
Result: PASS

もしテストが失敗した場合は以下のような表示になります:

$ rake test
bundle exec vim-flavor test
-------- Preparing dependencies
Checking versions...
  Use kana/vim-textobj-user ... 0.3.12
  Use kana/vim-vspec ... 1.1.0
Deploying plugins...
  kana/vim-textobj-user 0.3.12 ... skipped (already deployed)
  kana/vim-vspec 1.1.0 ... skipped (already deployed)
Completed.
-------- Testing a Vim plugin
Files=0, Tests=0,  0 wallclock secs ( 0.00 usr +  0.00 sys =  0.00 CPU)
Result: NOTESTS
t/c.vim .... 1/?
not ok 1 - <Plug>(textobj-function-a) selects the next function if there is no function under the cursor
# Expected line("'<") == 3
#       Actual value: 2
#     Expected value: 3
t/c.vim .... Failed 1/6 subtests
t/vim.vim .. ok

Test Summary Report
-------------------
t/c.vim  (Wstat: 0 Tests: 6 Failed: 1)
  Failed test:  1
Files=2, Tests=12,  1 wallclock secs ( 0.03 usr  0.02 sys +  0.11 cusr  0.06 csys =  0.22 CPU)
Result: FAIL
rake aborted!
Command failed with status (1): [bundle exec vim-flavor test...]

Tasks: TOP => test
(See full trace by running task with --trace)

定型の初期化や後始末を簡略化する

テストによっては定型の初期化や後始末が必要になることがあるでしょう。
その場合は before/after
を使って簡略化できます。

例えば独自のオペレーターを定義した場合、
動作確認のためには適当なテキストで埋められたバッファが利用ケース毎に必要です。
これは以下のような感じで記述できます:

describe '...'
  before
    new
    put =[
    \   'foo',
    \   'bar',
    \   'baz',
    \   '...',
    \ ]
  end

  after
    close!
  end

  it '...'
    ...
  end

  it '...'
    ...
  end
end

未実装のテストを表す

実際にテストを書く場合、
「一先ずテストすべき項目を列挙しておき、テストの内容の記述は後回しにする」
ということはよくやります。
このような「未実装のテスト」は TODO を使って表現します:

it '...'
  TODO
end

TODO のテストは常に失敗扱いになり、実行結果でも良い感じに表示されます:

$ rake test
...
-------- Testing a Vim plugin
Files=0, Tests=0,  0 wallclock secs ( 0.00 usr +  0.04 sys =  0.04 CPU)
Result: NOTESTS
t/c.vim .... 1/?
not ok 1 - # TODO <Plug>(textobj-function-a) selects the next function if there is no function under the cursor
t/c.vim .... ok
t/vim.vim .. ok
All tests successful.
Files=2, Tests=12,  1 wallclock secs ( 0.05 usr  0.46 sys +  0.13 cusr  0.40 csys =  1.04 CPU)
Result: PASS

環境固有のテストを飛ばす

ものによっては特定の環境でしか意味を成さないテストもあります。
そういうテストは SKIP を使って表現します:

it '...'
  if executable('git') < 1
    SKIP 'Git is not available.'
  endif

  ...
end

SKIP されたテストは常に成功扱いになり、実行結果でも良い感じに表示されます:

$ rake test
...
-------- Testing a Vim plugin
Files=0, Tests=0,  0 wallclock secs ( 0.00 usr +  0.00 sys =  0.00 CPU)
Result: NOTESTS
t/c.vim .... 1/?
ok 1 - # SKIP <Plug>(textobj-function-a) selects the next function if there is no function under the cursor - 'Git is not available.'
t/c.vim .... ok
t/vim.vim .. ok
All tests successful.
Files=2, Tests=12,  0 wallclock secs ( 0.03 usr  0.01 sys +  0.08 cusr  0.02 csys =  0.14 CPU)
Result: PASS

その他

やろうと思えば

ということもできます。
これだけ道具が揃っていれば割と快適にテストが書けるはずです。やりましたね。