読者です 読者をやめる 読者になる 読者になる

レガシーコード生産ガイド

私に教えられることなら

Common Lispで遊ぶ為にテストフレームワークを作って遊ぶ

JavaScript Ninjaを読んでて感動したのが、最初に簡単なテストフレームワークを書いて、それでテストを書きながら学んでいくという本の構成だった。あれほどわかりやすいやり方は他に知らない。

それまでプログラミングの本を読むとき、気分が向いたらコードを打ち込んで手でテストし、長いコードは面倒で先延ばしにして…と途中で放り出すのが常だった。しかしJavaScript Ninjaのやり方は、すぐに結果が見れて、なんというかテストのおかげで動作のイメージが楽に掴めるのでガンガン進んで一気に終わらせてしまった。(後半のブラウザ互換性は当時興味がなかったのでやらなかったけど)

というわけで、Common Lispや他の言語を学ぶときはまず簡単なテストフレームワークを書いて、それで試しながら遊んでみようと思う。

(というのは建前で、初めての人のためのLISP第10講まで読んでマクロを書いてみたくて仕方が無いのであった。)

どういう結果を見たいか決めてみる

  • 成功したら数だけカウントして説明は表示しない
  • 失敗した場合は、評価したコードそのものを表示して検索しやすくする

assert書いてみる

まずはテストをどう実行するか。

グローバル変数test-listにテストケースを追加していって、それを前から評価していくことにする。単純にリストに追加していくんだけど、最後尾に追加すると追加の度にリストを全て辿ることになって効率が悪いので、先頭に追加していき、テストを走らせるときにreverseする。

個々のテストケースは、(結果 . 説明) のドット対にして、単純に結果がnilなら失敗して説明を表示、それ以外なら成功とする。

まずテストを走らせるrun-test

(defvar *test-list* ())

(defun fold (x acc f)
  (cond 
    ((null x) acc)
    (t (fold (cdr x) (funcall f (car x) acc) f))))

(defun dot+ (n m dot)
  `( ,(+ n (car dot)) . ,(+ m (cdr dot))))

(defun dot->of (dot)
  `( ,(car dot) of ,(cdr dot)))

(defun run-test ()
  (format t "sucess: ~A~%"
          (dot->of
           (fold (reverse *test-list*) '(0 . 0) ; (success . all)
                 (lambda (test acc)
                   (cond
                     ((car test) (dot+ 1 1 acc))
                     (t (progn
                          (format t "fail: ~A~%" (cdr test))
                          (dot+ 0 1 acc)))))))))

foldは腕試しに書いててそのまま使ってる。 最終的に

fail: (EQ GORILLA CHIMPANZEE)
success: (5 OF 6)

という表示にしたいので、(5 OF 6)のところを実現するために、(成功ケース数 . 全ケース数)のドット対に加算していく。

次にassertマクロ

(defmacro asrt (cnd)
  `(setq *test-list*
        (cons 
         (cons ,cnd (quote ,cnd))
         *test-list*)))

既にassertという関数があった(テスト用なんだろうか)ので、asrtにする。

失敗した場合の結果は単純に条件式cndをそのままリストとして渡し、表示させる。

今気づいたけど、consで展開せずにそのままバッククォートでやればquoteいらないんだろうか? (バッククォート連発すると意味不明になりそうで恐ろしいので避けている)

試してみる

(defun last-elm (x)
  (cond
    ((null x) nil)
    ((atom x) x)
    ((null (cdr x)) (car x))
    (t (last-elm (cdr x)))))

(asrt (eq (last-elm '(1 2 3 4)) 4))
(asrt (eq (last-elm '(1)) 1))
(asrt (eq (last-elm '()) nil))
(asrt (eq (last-elm 'a) 'a))
(asrt (eq (last-elm '(1 2)) nil)) ;失敗させてみる

結果

CL-USER> (run-test)
fail: (EQ (LAST-ELM '(1 2)) NIL)
sucess: (4 OF 5)
NIL

できた!

expect書いてみる

assertもいいんだけど、テストケースはコードとはハッキリ違う雰囲気で、かつ書いても見ても楽なカンジにしたい。なのでBDDとかいうのかな、expect funyafunya to be hogehogeみたいに書けるようにしてみる。

(というのは建前で、初めての人のためのLISP第10講まで読んでマクロを書いてみたくて仕方が無いのであった。)

(defun next-is (what that)
  (eq (car what) that))

(defmacro push-list (list item)
  `(setq ,list
         (cons ,item ,list)))

(defun push-desc (desc expected)
  (cons
   (car expected)
   (cons desc (cdr expected))))

(defun push-cond-fn (fname expected)
  (cons
   (cons fname `( ,(car expected)))
   (cdr expected)))

(defun expect-eq (cnd what)
  (let
      ((target (car what)))
    `((eq ,cnd ,target) . (eq ,target))))

(defun expect-to (cnd what)
  (push-desc 'to
             (cond
               ((next-is what 'eq) (expect-eq cnd (cdr what)))
               )))

(defun expect-not (cnd what)
  (push-desc 'not
             (cond
               ((next-is what 'to) 
                (push-cond-fn 'not (expect-to cnd (cdr what))))
               )))

(defmacro expect (cnd &rest what)
  (let*
      ((expected 
        (cond
          ((next-is what 'to)  (expect-to cnd (cdr what)))
          ((next-is what 'not) (expect-not cnd (cdr what)))
          ))
       (asert_cond (car expected))
       (asert_desc
        `(expect ,cnd . ,(cdr expected))))
    `(push-list *test-list*
                (cons
                 ,asert_cond
                 (quote ,asert_desc)))))

この前LL(1)パーサ書いたおかげですんなり構造が思い浮かんだ。

結構重複しそうだから更にマクロ書けそうなんだけど、マクロの中でマクロの中で〜と重ねると暗黒大魔法化するだろうから怖いのでまだやらない。

テストの評価対象cndとwhat(to eq 1 なんかのリスト)を渡していき、終端まで行くとcndを加工して、(評価対象 . 説明)のドット対を返していく。評価対象はtest-listにpushするときに評価されて、テスト成否判定用の結果になる…はず。

例えば(expect 1 not to eq 1)は、次のイメージでテストケースに展開される。

expect (1) (not to eq 1)

    => expect-not (1) (to eq 1)

        => expect-to (1) (eq 1)

            => expect-eq (1) (1)

            <= ((eq 1 1) . (eq 1))

        <= ((eq 1 1) . (to eq 1))

    <= ((not (eq 1 1)) . (not to eq 1))

<= ((not (eq 1 1)) . (expect 1 not to eq 1))

toやeqは、自分が担当する説明を加えていく。 さっきのlast-elmで試してみる。

(expect (last-elm '(3 4)) to eq 4)
(expect (last-elm '(2))   to eq 2)
(expect (last-elm '())    to eq nil)
(expect (last-elm 2)      to eq 2)
(expect (last-elm '(5 6)) not to eq nil)
(expect (last-elm '(5 6)) not to eq 6) ;失敗させてみる

結果

CL-USER> (run-test)
fail: (EXPECT (LAST-ELM '(5 6)) NOT TO EQ 6)
sucess: (5 OF 6)
NIL 

できた!

しかし

※解決済み、追記あり

emacsのslimeでC-c C-kすると、次のようなエラーが出る

test.lisp:96:1:
  error: 
    (during macroexpansion of (EXPECT (LAST-ELM '#) ...))
    The function COMMON-LISP-USER::NEXT-IS is undefined.

sbcl --load test.lispだと普通にテストケース追加&テスト実行できるので、C-c C-kでの動作の違いを調べないといけない。

簡単に遊べる環境が欲しいので、調べてみよう。

  • push-listじゃなくて標準のpush使って、test-listが展開されてることに気付かずに延々と悩んだりした。マクロのデバッグめちゃくちゃ難しく感じる。手法を探したい。
  • expect-notあたりのインデントが気持ち悪いのでどうにかしたい。

感想

  • マクロは楽しい。

(追記) エラー解決

id:g000001さんのコメントにより、C-c C-kでのエラーを解決できた。(わかりやすい説明ありがとうございます。)

次の様に補助関数をeval-whenで囲むことで解決した。

(eval-when (:compile-toplevel :load-toplevel :execute) 

    (defun next-is ...

    (defun push-desc ...

いつ、どの様に評価・展開されるか、の動きに慣れる練習をしたほうが良さそうだ。

広告を非表示にする