rspecのように前処理などを構造化できるEmacs Lisp用テストフレームワーク(el-spec)をリリース

[http://d.hatena.ne.jp/uk-ar/20120720/p1:title]にて機能追加をおこないました。そちらも参照ください。

Emacs Lisprspec*1のように前処理などをネストして定義できるテストフレームワーク(el-spec)が欲しくて作ってみました。
https://github.com/uk-ar/el-spec
実用になりそうなレベルになったのでリリースします。

きっかけ、が欲しいです。

Emacs Lispにもいくつかテストフレームワークがあり、今まではid:rubikitch さんのel-expectation*2を使ってました。使い勝手が良くてテスト数の少ないうちは満足していたのですが、テストが多くなると複数のテストで同じような前処理をしているのが目立ってきました。
このような場合マクロや関数で対応するのが常套手段です。しかし、前処理の内容も全く同じわけではなく少しずつ違っているので、マクロや関数が増えて管理が辛くなります。
そこでEmacs 24から添付されたert*3を利用して、テストフレームワーク(fixture?)を作ってみました。

特徴

テストを書くのに今回リリースするel-specを使用すると

  • 前処理や後処理を簡単に追加できる
  • テストが整理できる(同じようなテストが近い場所に固まる)
  • 個々のテストに対して、名前をつけなくてよい
  • 似たようなテストを増やしやすい(shared-contextやshared-examples)

というメリットがあります
逆にデメリットもありますが、最後に書きます。

準備

el-specはEmacs 24から標準添付されたertに依存しています。Emacs 23以下の場合は

(auto-install-from-url "https://raw.github.com/ohler/ert/c619b56c5bc6a866e33787489545b87d79973205/lisp/emacs-lisp/ert.el")

としておきましょう。

インストール方法

el-spec自体のインストールは

(auto-install-from-url "https://raw.github.com/uk-ar/el-spec/144c053a5303101378d6e3b981d10b715a6c8775/el-spec.el")

を評価するか、
marmaladeから
M-x package-install el-spec
でOKです。
インストール後に

(require 'el-spec)

とすると使えます。

お品書き

使い方をチュートリアル風に解説します。内容はこんな感じです。

  • 基本的な使い方
    • 前処理/後処理の設定
    • 前処理/後処理の階層化(コンテキストの使用)
  • 実用例
    • 変数の使用
    • コンテキストの共有
    • (異なる前提条件での)examplesの共有
    • テスト実行範囲の変更

基本的な構造(前処理/後処理の設定)

基本的にdescriptionブロックの中にテストケース(example)を書いていきます。descriptionの中でitブロックに囲われた部分が独立したexampleになります。
具体的には

(describe "description"
  (it "test 1"
    (should ...)
    )
  (it "test 2"
    (should ...)
    )
  ...
  )

のような形になります。
前処理/後処理を設定するにはbefore/afterマクロを使用します。

(require 'el-spec)

(describe "description"
  (before
    (message "before common")
    )
  (after
    (message "after common\n")
    )
  (it "test 1"
    (message "test 1")
    )
  (it "test 2"
    (message "test 2")
    )
  )

を実行すれば、

before common
test 1
after common

before common
test 2
after common

のように実行されます。
実行するには M-x el-spec:eval-buffer を使用するか、describeのS式を M-C-x とかでevalすればオッケーです(この方法はel-expectationを参考にしました)。実行するとertのテスト結果が出ますが、ここでは気にせずmessageバッファをみてみましょう。

前処理/後処理の階層化(コンテキストの使用)

さらに前処理/後処理はcontextマクロにより階層化することができて

(describe "description"
  (before
    (message "before common"))
  (after
    (message "after common\n"))
  (context "when 1"
    (before
      (message "before 1"))
    (after
      (message "after 1"))
    (it "test 1"
      (message "test 1")))
  (context "when 2"
    (before
      (message "before 2"))
    (after
      (message "after 2"))
    (it "test 2"
      (message "test 2")))
  )

のように書けば、

before common
before 1
test 1
after 1
after common

before common
before 2
test 2
after 2
after common

のように実行されます。
describe,context,itに与える(第一引数の)説明文字列は結合してテスト名となります(例えばtest 1は"description\nwhen 1\ntest 1"のようなテスト名になります)。ですので、説明文字列はコンテキスト内でユニークであることと、改行"\n"を使わないことを気をつけて下さい(重複した場合は、定義時に警告が出ます)。
before/afterの他にaroundマクロも使用することができるので、上のテストは

(describe "description"
  (around
    (message "before common")
    (funcall el-spec:example)
    (message "after common\n")
    )
  (context "when 1"
    (around
      (message "before 1")
      (funcall el-spec:example)
      (message "after 1")
      )
    (it "test 1"
      (message "test 1")))
  (context "when 2"
    (around
      (message "before 2")
      (funcall el-spec:example)
      (message "after 2")
      )
    (it "test 2"
      (message "test 2")))
  )

と書き直すことができます。
aroundを使えば、it節を囲うような使い方(with-temp-bufferやletなど)が可能です。

実用例

上で挙げた機能も含めて、実際の例で説明します。
題材として括弧の自動挿入を取り上げます。
ここでは

  • 開き括弧を入力すると括弧のペアを挿入
  • 閉じ括弧を入力すると閉じ括弧のみ挿入
  • 開き括弧かどうかは文脈やモードに依存

という仕様を確かめるため以下の3つのテストを書きます。

(with-temp-buffer
  (switch-to-buffer (current-buffer))
  (c-mode)
  (execute-kbd-macro "'")
  (should (string= (buffer-string) "''"))
  )

(with-temp-buffer
  (switch-to-buffer (current-buffer))
  (c-mode)
  (insert "'")
  (execute-kbd-macro "'")
  (should (string= (buffer-string) "''"))
  )

(with-temp-buffer
  (switch-to-buffer (current-buffer))
  (emacs-lisp-mode)
  (execute-kbd-macro "'")
  (should (string= (buffer-string) "'"))
  )

明らかにwith-temp-bufferとswitch-to-bufferの部分が冗長なのでaroundで共通化します。

(describe "auto-pair"
  (around
    (with-temp-buffer
      (switch-to-buffer (current-buffer))
      (funcall el-spec:example)))

  (it ()
    (c-mode)
    (execute-kbd-macro "'")
    (should (string= (buffer-string) "''")))
  (it ()
    (c-mode)
    (insert "'")
    (execute-kbd-macro "'")
    (should (string= (buffer-string) "''")))
  (it ()
    (emacs-lisp-mode)
    (execute-kbd-macro "'")
    (should (string= (buffer-string) "'")))
  )

itへの説明文字列はnilを渡すことで省略可能で、省略時にはブロック内容が説明文字列になります。
なおこれ以降は実行時にテストが失敗することがあると思います。その場合、ertバッファ上の失敗したテスト名の場所で「b」を入力すると実行時のバックトレースを表示することができます。また、本来ならテスト名の上で「RET」とすることでテストを定義した場所へ飛ぶことができるはずですが、el-specでは現状使えません。

変数の使用

次はわかりやすいようモードごとにテストをグループ化しましましょう。

(describe "auto-pair"
  (around
    (with-temp-buffer
      (switch-to-buffer (current-buffer))
      (funcall el-spec:example)))

  (context "in c-mode"
    (before
     (c-mode))

    (it ()
      (execute-kbd-macro "'")
      (should (string= (buffer-string) "''")))
    (it ()
      (insert "'")
      (execute-kbd-macro "'")
      (should (string= (buffer-string) "''"))))

  (context "in emacs-lisp-mode"
    (before
      (emacs-lisp-mode))

    (it ()
      (execute-kbd-macro "'")
      (should (string= (buffer-string) "'"))))
  )

モード切り替え部分(before)が冗長ですね。モード名を変数にできたらスッキリします。

(describe ("auto-pair" :vars (mode))
  (around
    (with-temp-buffer
      (switch-to-buffer (current-buffer))
      (funcall mode)
      (funcall el-spec:example)))

  (context ("in c-mode" :vars ((mode 'c-mode)))
    (it ()
      (execute-kbd-macro "'")
      (should (string= (buffer-string) "''")))
    (it ()
      (insert "'")
      (execute-kbd-macro "'")
      (should (string= (buffer-string) "''"))))

  (context "in emacs-lisp-mode"
    (let ((mode 'emacs-lisp-mode))
      (it ()
        (execute-kbd-macro "'")
        (should (string= (buffer-string) "'")))))
  )

定義時の変数はそのままでは実行時に参照できません。なので、自前でlexical-letを使うか、describe,context,itに:varsというキーワード引数で変数宣言をしてあげるとうまいことしてくれます(:varsはletと同じ形式で変数を受け付けます)。
一度:varsで変数宣言をすれば、変数への代入はletやsetq、:varsで可能です。

コンテキストの共有(shared_context)

テスト(example)が増えてくると、beforeやafterをまとめたcontextを複数の場所で使いたいことがあります。
例えば、上記の2つ目のexampleのように文字を挿入するexampleを追加すると次のようになります

(describe ("auto-pair" :vars (mode))
  (around
    ...)

  (context ("in c-mode" :vars ((mode 'c-mode)))
    (it ()
      ...)

    (context ("buffer-string before" :vars (string-of-buffer))
      (before
        (insert string-of-buffer))
      (it (:vars ((string-of-buffer "'")))
        (execute-kbd-macro "'")
        (should (string= (buffer-string) "''")))))

  (context "in emacs-lisp-mode"
    (let ((mode 'emacs-lisp-mode))
      (it ()
        ...)

      (context ("buffer-string before" :vars (string-of-buffer))
        (before
          (insert string-of-buffer))
        (it (:vars ((string-of-buffer "\"")))
          (execute-kbd-macro "\"")
          (should (string= (buffer-string) "\"\"")))
        )))
  )

この例では(before (insert string-of-buffer))の部分を共有したいところですが、c-modeとemacs-lisp-modeでコンテキストを分けているので難しいです。
このような場合はshared-contextとinclude-contextを使います。

(describe ("auto-pair" :vars (mode))
  (around
    (with-temp-buffer
      (switch-to-buffer (current-buffer))
      (funcall mode)
      (funcall el-spec:example)))
  (shared-context ("insert string" :vars (string-of-buffer))
    (before
      (insert string-of-buffer)))

  (context ("in c-mode" :vars ((mode 'c-mode)))
    (it ()
      (execute-kbd-macro "'")
      (should (string= (buffer-string) "''")))

    (context "buffer-string before"
      (include-context "insert string")
      (it (:vars ((string-of-buffer "'")))
        (execute-kbd-macro "'")
        (should (string= (buffer-string) "''")))))

  (context "in emacs-lisp-mode"
    (let ((mode 'emacs-lisp-mode))
      (it ()
        (execute-kbd-macro "'")
        (should (string= (buffer-string) "'")))

      (context "buffer-string before"
        (include-context "insert string")
        (it (:vars ((string-of-buffer "\"")))
          (execute-kbd-macro "\"")
          (should (string= (buffer-string) "\"\"")))
        )))
  )

shared-contextはcontextマクロと同じような使い方で定義します。定義したshared-contextをinclude-contextすれば、その場所で使えるようになります。

exampleの共有(shared_example)

テスト(example)が増えてくると、あるテストを違うコンテキスト(違う前提条件)で実行したいことがあります。
例えば、上記4つ目の「"」を入力するexampleはc-modeでもemacs-lisp-modeでも動作します。
このような場合はshared-examplesとinclude-examplesを使います。

(describe ("auto-pair" :vars (mode))
  (around
    (with-temp-buffer
      (switch-to-buffer (current-buffer))
      (funcall mode)
      (funcall el-spec:example)))
  (shared-context ("insert string" :vars (string-of-buffer))
    (before
      (insert string-of-buffer)))
  (shared-examples "examples for \""
    (it ()
      (execute-kbd-macro "\"")
      (should (string= (buffer-string) "\"\""))))

  (context ("in c-mode" :vars ((mode 'c-mode)))
    (it ()
      (execute-kbd-macro "'")
      (should (string= (buffer-string) "''")))
    (include-examples "examples for \"")
    (context "buffer-string before"
      (include-context "insert string")
      (it (:vars ((string-of-buffer "'")))
        (execute-kbd-macro "'")
        (should (string= (buffer-string) "''")))))

  (context "in emacs-lisp-mode"
    (let ((mode 'emacs-lisp-mode))
      (it ()
        (execute-kbd-macro "'")
        (should (string= (buffer-string) "'")))
      (include-examples "examples for \"")
      ))
  )

簡単にするため、上記の例とは少しテスト内容を変更しています。
最後にテストコードと実装コード一つにまとめるケースも考えて、dont-compileで囲むと

(dont-compile
  (when (fboundp 'describe)
    (describe ("auto-pair" :vars (mode))
      (around
        (with-temp-buffer
          (switch-to-buffer (current-buffer))
          (funcall mode)
          (funcall el-spec:example)))
      (shared-examples "examples for quote"
        (it ()
          (execute-kbd-macro quote)
          (should (string= (buffer-string) (concat quote quote))))
        (context ("buffer-string before" :vars (string-of-buffer))
          (before
            (insert string-of-buffer))
          (it (:vars ((string-of-buffer quote)))
            (execute-kbd-macro quote)
            (should (string= (buffer-string) (concat quote quote))))))

      (context ("in c-mode" :vars ((mode 'c-mode)))
        (context ("examples for \"" :vars ((quote "\"")))
          (include-examples ("examples for quote" )))
        (context ("examples for '" :vars ((quote "'")))
          (include-examples ("examples for quote"))))
      (context ("in emacs-lisp-mode" :vars ((mode 'emacs-lisp-mode)))
        (it ()
          (execute-kbd-macro "'")
          (should (string= (buffer-string) "'")))
        (context ("examples for \"" :vars ((quote "\"")))
          (include-examples ("examples for quote" :vars ((quote "\""))))))
      )))

といった感じで完成です。

テスト実行範囲の変更

テストが増えてくると、すべてのテストを実行した場合に時間がかかるようになります。普段は一部分のテストだけ実行して、区切りがついたら全部をテストできると便利ですね。
M-x el-spec:eval-and-execute-contextでカーソル付近のコンテキストだけを実行することができます。逆にM-x el-spec:eval-and-execute-allでdescribe以下のコンテキストを実行できます。M-C-x でevalした時の挙動も切り替えが可能で、M-x el-spec:toggle-selectionするたびにallとcontextが切り替わります。

課題と対応

現在判明してるデメリットとして、

  1. テストの失敗時にexampleに飛べない
  2. 個々のテスト実行ができない
  3. 変数定義が分かりづらい(:varsを忘れる)
  4. (無名関数を多用してるので)バックトレースが追いづらい

という4つは認識しています。
で、今後の対応予定/回避策は以下の通りです。
1. に関してはかなりだるい&対応策があるような気がするので次回対応します。
2. に関しても浅い階層のexampleを実行する際には欲しくなりますが、実装をがんばらないといけない感じです。とりあえずはcontextを作ることで回避可能なので、気力があれば対応します。
3. に関しては自分で使っていても:varsをつけ忘れてエラーがよく出ます。describe以下のletをlexical-letに置き換える技もありますが副作用が心配です。もう少し使い込んでから対応を決めます。
4. 設計的に対応が厳しいです。私は慣れてきました。アイデアがあれば下さい。

あとがき

最低限の動くものはすぐにできたのですが、使っているうちにいろいろと機能が欲しくなって時間がかかってしまいました。
マクロを使いまくったおかげでマクロに詳しくなった気がします。
実装のコンセプトは単純なので、クロージャが使える言語なら移植は可能です。最低限の実装は
https://github.com/uk-ar/el-spec/tree/a7b548271ae0cb996e5c201712f1e658c075c65c
にあるので興味があればのぞいてみて下さい。
もちろん要望やフィードバック、pull requestもウェルカムです!