プログラミングを頑張る日記

プログラミングを勉強して、ハッカーになろう

Common Lispを頑張る(44)

大炎上しているプロジェクトに放りこまれました。
でも「実践Common Lisp」やります。
第9章、この章では簡単なユニットテストフレームワークを作るようです。

テストフレームワーク設計の目標は、新しいテストの追加、さまざまなテストセットの実行、
テスト失敗の追跡をできるだけ簡単にすることだそうです。
すべてのテストにパスしたかどうかを責任をもって知らせてくれるフレームワークであることが、
自動化されたフレームワークの重要な特徴であり、つまり各テストケースは
ブール値を評価結果にもつ式であって欲しいとのこと。まあそうですね。
副作用をもつ関数の場合は、関数を呼び出したあとに期待した副作用が起きたか確認しなくてはなりません。
でもまあなんにせよすべてのテストケースはブール式に要約されるということです。

  1. 関数のためのテストとなら、妥当なテストケースはこんな感じになるようです。
(= (+ 1 2) 3)
(= (+ 1 2 3) 6)
(= (+ -1 -3) -4)

これらを入力して戻り値がTかどうかをチェックすればいいわけですが、
今欲しいのはいつでも必要なときにテストケースを体系化して実行が簡単になるフレームワークです。
少しずつ進めていくということで、まずは各テストケースを評価して結果の論理和を取るようにします。

CL-USER> (defun test-+ ()
           (and
            (= (+ 1 2) 3)
            (= (+ 1 2 3) 6)
            (= (+ -1 -3) -4)))
TEST-+
CL-USER> (test-+)
T
CL-USER>

とはいえこれだと失敗したときにどのテストケースが失敗したのかわかりません。
ということでこんな感じがよいだろうと。

CL-USER> (defun test-+ ()
           (format t "~:[FAIL~;pass~] ... ~a~%" (= (+ 1 2) 3) '(= (+ 1 2) 3))
           (format t "~:[FAIL~;pass~] ... ~a~%" (= (+ 1 2 3) 6) '(= (+ 1 2 3) 6))
           (format t "~:[FAIL~;pass~] ... ~a~%" (= (+ -1 -3) -4) '(= (+ -1 -3) -4)))
WARNING: redefining COMMON-LISP-USER::TEST-+ in DEFUN
TEST-+
CL-USER> (test-+)
pass ... (= (+ 1 2) 3)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
NIL
CL-USER>

~:[hoge~;huga~]で対応する引数がtならhuga、nilならhogeみたいな感じでしょうか。
まあそれは置いておいて、テストケースごとにtか否かを見れるようになりましたが、
これでOKだという人はいないでしょう。
いわゆるDRYにまっこうから反していますね。それにテストケースが何個もあったら、
全部passしているかどうかみるのも手間です。出力してgrepとかいうアホなことはしたくないですもの。

リファクタリング

本当に欲しいもの:1つ目に書いたものぐらい直感的かつ、2つ目みたいにテストケースごとの結果を
みせてくれるもの。というわけで機能的に要件を満たしている2つ目を綺麗にしていきます。
まずはあからさまに無駄な、formatの繰り返しをどうにかします。

CL-USER> (defun report-result (result form)
           (format t "~:[FAIL~;pass~] ... ~a~%" result form))
REPORT-RESULT
CL-USER> (defun test-+ ()
           (report-result (= (+ 1 2) 3) '(= (+ 1 2) 3))
           (report-result (= (+ 1 2 3) 6) '(= (+ 1 2 3) 6))
           (report-result (= (+ -1 -3) -4) '(= (+ -1 -3) -4)))
WARNING: redefining COMMON-LISP-USER::TEST-+ in DEFUN
TEST-+
CL-USER> (test-+)
pass ... (= (+ 1 2) 3)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
NIL
CL-USER>

まあいくぶんかましになりました。次は、report-resultの引数でいう
resultとformを二重で書いている点をどうにかします。マクロでさっくり。

CL-USER> (defmacro check (form)
           `(report-result ,form ',form))
CHECK
CL-USER> (defun test-+ ()
           (check (= (+ 1 2) 3))
           (check (= (+ 1 2 3) 6))
           (check (= (+ -1 -3) -4)))
WARNING: redefining COMMON-LISP-USER::TEST-+ in DEFUN
TEST-+
CL-USER>

checkマクロは、formを評価したものと評価しないもの両方をreport-resultに渡すだけのものですね。
アンクォート→クォートという一見意味不明なことをしているのは、
一回は評価しないと引数を受け取ることもなくただのformというシンボルになってしまうからですね。

さて、いい感じになってきましたが、こうなるとcheckが繰り返し現れているのが気になるところです。
まとめて包むようにしてしまうとさらにいい感じになります。

CL-USER> (defmacro check (&body forms)
           `(progn
              ,@(loop for f in forms collect `(report-result ,f ',f))))
WARNING: redefining COMMON-LISP-USER::CHECK in DEFMACRO
CHECK
CL-USER> (defmacro test-+ ()
           (check
             (= (+ 1 2) 3)
             (= (+ 1 2 3) 6)
             (= (+ -1 -3) -4)))
WARNING: redefining COMMON-LISP-USER::TEST-+ in DEFMACRO
TEST-+
CL-USER>

prognを使うのは、フォームの並びを単一のフォームにするためだそうで、
これはマクロでよく使われるイディオムだそうです。

戻り値を手直しする

とりあえずまずはreport-resultにテストケースの結果を返してもらうようにします。

CL-USER> (defun report-result (result form)
           (format t "~:[FAIL~;pass~] ... ~a~%" result form)
           result)
WARNING: redefining COMMON-LISP-USER::REPORT-RESULT in DEFUN
REPORT-RESULT
CL-USER>

さて、全テストケースがパスしたかどうかを知りたいです。
そのためにはprognの代わりにandを使えばいいのではと一瞬考えてしまいますが、だめです。
andは一個でも偽を見つけたらそこで評価をやめてしまいますので。
全部評価してその上でandと同じように真偽値を返してくれる関数があればいいのですが、ないそうです。
まあ無いならマクロを書けばよいだけなのです。
欲しいマクロは、これが

(combine-results
 (foo)
 (bar)
 (bazz))

こうなってくれるものですね。

(let ((result t))
  (unless (foo) (setf result nil))
  (unless (bar) (setf result nil))
  (unless (bazz) (setf result nil))
  result)

どれか一つでもnilになるテストケースがあったら、resultはnilになりますが、
そこでテストケースの評価が終わるわけではないと。
このマクロを書く際の注意としては、結果のために変数が絶対に必要となることであり、
つまり、gensymの出番です。

CL-USER> (defmacro combine-results (&body forms)
           (with-gensyms (result)
             `(let ((result t))
                ,@(loop for f in forms collect `(unless ,f (setf ,result nil)))
                ,result)))
COMBINE-RESULTS
CL-USER>

いい加減自力で書けると思ったのですが、だめでした...。
checkマクロを修正します。

CL-USER> (defmacro check (&body forms)
           `(combine-results
              ,@(loop for f in forms collect `(report-result ,f ',f))))
WARNING: redefining COMMON-LISP-USER::CHECK in DEFMACRO
CHECK
CL-USER> (test-+)
pass ... (= (+ 1 2) 3)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
T
CL-USER> 

ちゃんと戻り値がでました。一個でも失敗していればnilになるはずです。
ちょっとtest-+のテストケースを変更して。

CL-USER> (test-+)
FAIL ... (= (+ 1 2) 4)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
NIL
CL-USER>

よさそうですね。

さらに良い結果レポート

今のところ、テストする関数は一つだけなのでこのままで問題ありませんが、
大量のテストを同時に行うときに問題が出るといいます。
例として、*のテスト関数を作って追加してみます。

CL-USER> (defun test-* ()
           (check
             (= (* 3 4) 12)
             (= (* 5 5) 25)))
; in: DEFUN TEST-*
;     (CHECK
;       (= (* 3 4) 12)
;       (= (* 5 5) 25))
; --> COMBINE-RESULTS 
; ==>
;   (LET ((RESULT T))
;     (UNLESS (REPORT-RESULT (= (* 3 4) 12) '(= (* 3 4) 12)) (SETF #:G0 NIL))
;     (UNLESS (REPORT-RESULT (= (* 5 5) 25) '(= (* 5 5) 25)) (SETF #:G0 NIL))
;     #:G0)
; 
; caught STYLE-WARNING:
;   The variable RESULT is defined but never used.
; in: DEFUN TEST-*
;     (CHECK
;       (= (* 3 4) 12)
;       (= (* 5 5) 25))
; --> COMBINE-RESULTS LET UNLESS IF SETF 
; ==>
;   (SETQ #:G0 NIL)
; 
; caught WARNING:
;   undefined variable: #:G0
; 
; compilation unit finished
;   Undefined variable:
;     #:G0
;   caught 1 WARNING condition
;   caught 1 STYLE-WARNING condition
TEST-*
CL-USER>

なんだこりゃ、と思ったらcombine-resultsの定義を間違っていました。修正。

CL-USER> (defmacro combine-results (&body forms)
           (with-gensyms (result)
             `(let ((,result t))
                ,@(loop for f in forms collect `(unless ,f (setf ,result nil)))
                ,result)))
WARNING: redefining COMMON-LISP-USER::COMBINE-RESULTS in DEFMACRO
COMBINE-RESULTS
CL-USER> (defun test-arithmetic ()
           (combine-results
             (test-+)
             (test-*)))
FAIL ... (= (+ 1 2) 4)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
WARNING: redefining COMMON-LISP-USER::TEST-ARITHMETIC in DEFUN
TEST-ARITHMETIC
CL-USER> (test-arithmetic)
pass ... (= (* 3 4) 12)
pass ... (= (* 5 5) 25)
; Evaluation aborted on #<UNBOUND-VARIABLE G0 {1004407E33}>.
CL-USER>

むむむ。そういう問題じゃなかったのかな。goとはなんだ。
そして定義の時から怪しいのですが、+のテストが含まれていません。
調査。疑うべきはマクロですね。

CL-USER> (macroexpand-1
          '(check
            (test-+)
            (test-*)))
(COMBINE-RESULTS
  (REPORT-RESULT (TEST-+) '(TEST-+))
  (REPORT-RESULT (TEST-*) '(TEST-*)))
T
CL-USER> (macroexpand-1 '(COMBINE-RESULTS
  (REPORT-RESULT (TEST-+) '(TEST-+))
  (REPORT-RESULT (TEST-*) '(TEST-*))))
(LET ((#:G612 T))
  (UNLESS (REPORT-RESULT (TEST-+) '(TEST-+)) (SETF #:G612 NIL))
  (UNLESS (REPORT-RESULT (TEST-*) '(TEST-*)) (SETF #:G612 NIL))
  #:G612)
T
CL-USER>

う~ん。まっとうに展開できているような気がします。
...test-+、マクロで定義していました。はぁ...まぬけ。

CL-USER> (test-arithmetic)
FAIL ... (= (+ 1 2) 4)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
pass ... (= (* 3 4) 12)
pass ... (= (* 5 5) 25)
; Evaluation aborted on #<UNBOUND-VARIABLE G0 {10049C3D23}>.
CL-USER>

修正してもこの様です。最後の結果表示でコケているのですね。わからん。
うん?test-*単体でコケます。間違っている気がしないので再定義します。

CL-USER> (test-arithmetic)
FAIL ... (= (+ 1 2) 4)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
pass ... (= (* 3 4) 12)
pass ... (= (* 5 5) 25)
NIL
CL-USER>

いけました。checkマクロが間違っているときに定義したからそのままだめだったのですね。
関数と違ってマクロは修正したら使っている関数を再定義しないといけない。学びました。

本題じゃないところで苦戦してしまいましたが、テスト関数を増やして何がいいたいかというと、
大量のテスト関数、大量のテストケースがあったら、失敗したテストがあったとき
それが何の関数なのか今のままだとわからないよね、という話がしたいのです。

結果の出力は全てreport-resultが司っているので、彼に今どのテスト関数を実行しているのか
教えてあげてその情報も出力してもらうようにしてもらえばよいのですね。
ただ、そのためにはreport-resultを呼んでいるcheckマクロも変更しなくてはいけないです。

まさにこういう時に使うべきなのがダイナミック変数だそうです。
checkを呼ぶ前にテスト関数の名前をダイナミック変数に束縛すれば、checkをいじらずとも
report-resultで関数の名前がわかるようになるって寸法です。

CL-USER> (defvar *test-name* nil)
*TEST-NAME*
CL-USER> (defun report-result (result form)
           (format t "~:[FAIL~;pass~] ... ~a: ~a~%" result *test-name* form)
           result)
WARNING: redefining COMMON-LISP-USER::REPORT-RESULT in DEFUN
REPORT-RESULT
CL-USER>

あとは*test-name*にテスト関数の名前を設定するようにすればOKですね。

CL-USER> (defun test-+ ()
           (let ((*test-name* 'test-+))
             (check
               (= (+ 1 2) 3)
               (= (+ 1 2 3) 6)
               (= (+ -1 -3) -4))))
WARNING: redefining COMMON-LISP-USER::TEST-+ in DEFUN
TEST-+
CL-USER> (defun test-* ()
           (let ((*test-name* 'test-*))
             (check
               (= (* 2 2) 4)
               (= (* 3 5) 15))))
WARNING: redefining COMMON-LISP-USER::TEST-* in DEFUN
TEST-*
CL-USER> (test-arithmetic)
pass ... TEST-+: (= (+ 1 2) 3)
pass ... TEST-+: (= (+ 1 2 3) 6)
pass ... TEST-+: (= (+ -1 -3) -4)
pass ... TEST-*: (= (* 2 2) 4)
pass ... TEST-*: (= (* 3 5) 15)
T
CL-USER>

結果レポートがきちんとラベリングされました。

抽象化の余地

色々機能を追加した結果、醜い繰り返しがまた現れました。
自分だったら満足してしまいそうだと思いましたが、「重複は悪だ」という言葉に打ちのめされました。
各関数の中で*test-name*に関数名を設定するところ、(= hoge fuga)みたいな繰り返し。
修正にとりかかる前に、重複の原因を詳しく見ていくようです。

これらの関数が同じような形で始まっているのは、どちらもテスト関数だから。
そしてテスト関数の抽象化はまだ中途半端であるそう。
部分的な抽象化はソフトウェアを組み立てる部品としてたちが悪いらしいです。
中途半端な抽象化はコードの中で一定のパターンとして現れてくるので、重複が大量に生まれると。
すると下記の問題があります。
●保守性が悪くなる。
●抽象化の概念がプログラマの頭の中にしかないから、他人がその抽象化を理解するための仕組みがない。

2つ目も保守性が悪いということのような気がしますが。
まあつまり、「これはテスト関数です」と表現しつつそのパターンで
必要になるコードをすべて生成する方法が必要になる。つまりマクロが必要ってことだそう。

攻略すべきパターンはdefunに定型文がいくつか付いているものなので、
1つのdefunに展開されるマクロを書けばいいと。
そして素のdefunを使うかわりにそのマクロを使うようにすればいいわけですね。ふむふむ。
欲しい展開形は先程のテスト関数の定義です。

CL-USER> (defmacro deftest (name parameters &body body)
           `(defun ,name ,parameters
              (let ((*test-name* ',name))
                ,@body)))
DEFTEST
CL-USER> (deftest test-+ ()
           (check
             (= (+ 1 2) 3)
             (= (+ 1 2 3) 6)
             (= (+ -1 -3) -4)))
WARNING: redefining COMMON-LISP-USER::TEST-+ in DEFUN
TEST-+
CL-USER> (test-+)
pass ... TEST-+: (= (+ 1 2) 3)
pass ... TEST-+: (= (+ 1 2 3) 6)
pass ... TEST-+: (= (+ -1 -3) -4)
T
CL-USER>

テストケースが繰り返し現れるのはもうしょうがないのですかね。

テストの階層

これでテスト関数はファーストクラスの仲間入りとなったそうです。
ファーストクラスってなんだよ、と思いましたが下記の記事がわかりやすかったです。
言語処理における「ファーストクラス」「第一級」とは - ctrlshiftの日記
でもなんでファーストクラスになったのかはよくわかっていません。
マクロで定義されるからなのかな。

test-arithmeticもテスト関数にすべきかどうかについては、今のところどっちでもいいそう。
deftestで定義しなおしたとして、それで束縛される*test-name*は結果がリポートされる前に
test-+たちによる束縛に隠されてしまうからだそうです。

しかし数千のテストケースを体系化するとなったら話が変わるようです。
体系化の最初の階層は、checkを直接呼び出すtest-+等のテスト関数です。
しかし数千ものテストケースを扱うにはさらに別の階層が必要になるそうです。
関連するテスト関数をグループ化し、テストスイーツを作りあげていくことになると。
そのようなテストを行うとき、下位層のテスト関数が複数のテストスイーツから呼ばれることもありそうで、
そうなるとテスト関数だけでなくテストスイーツの名前も知りたいです。

テスト関数を定義するプロセスは抽象化済なので、テスト関数のコードには手をつけず変更できるそう。

が最新のテスト関数の名前だけじゃなく、それまでに呼び出されたテスト関数の

名前のリストを保持するようにするには下記のようにすればいいようです。

CL-USER> (defmacro deftest (name parameters &body body)
           `(defun ,name ,parameters
              (let ((*test-name* (append *test-name* (list ',name))))
                ,@body)))
WARNING: redefining COMMON-LISP-USER::DEFTEST in DEFMACRO
DEFTEST
CL-USER> (deftest test-arithmetic ()
           (combine-results
             (test-+)
             (test-*)))
WARNING: redefining COMMON-LISP-USER::TEST-ARITHMETIC in DEFUN
TEST-ARITHMETIC
CL-USER>

テストスイーツもファーストクラスにすることで、下位層のテスト関数を呼ぶ前に

にテストスイーツ名を設定しテスト関数が*test-name*を設定するときに

appendで呼び出しの履歴を設定できるようにしているということですね。
マクロを変更したのでそれを使用しているテスト関数を再定義したら、ちゃんと動くか確認します。

CL-USER> (test-arithmetic)
pass ... (TEST-ARITHMETIC TEST-+): (= (+ 1 2) 3)
pass ... (TEST-ARITHMETIC TEST-+): (= (+ 1 2 3) 6)
pass ... (TEST-ARITHMETIC TEST-+): (= (+ -1 -3) -4)
pass ... (TEST-ARITHMETIC): (= (* 2 2) 4)
pass ... (TEST-ARITHMETIC): (= (* 3 5) 15)
T
CL-USER>

まとめ

まだまだ機能を追加することも可能ですが、とりあえずはこれで完成だということです。
どういう変遷をたどったのかを振り返ります。

まずは問題の簡単なバージョンを定義しました。
真偽値を持つたくさんの式を評価してそれらが全て真であることを確認するための方法を考えたことですね。
単純にandでまとめるだけでは物足りず、とりあえず素直に期待した形式のレポート結果が
得られるようなコードを書きました。

次に、それでできたごちゃごちゃしたコードをリファクタリングしました。
関数report-resultを定義し、そこに引数を渡す部分でテストの式を2回渡していたので
それは面倒な上にミスの温床だということでマクロcheckを定義しました。

checkを書いてみると、コードを生成しているだけならば1回のcheckの呼び出しで
対応する複数のreport-resultの呼び出しを生成できるということで修正。
この時点でかなりシンプルに書けるようになっていました。

checkのAPIが確定したことで、内部の動作に専念できるようになりました。
checkを少し直して全テストケースが成功したかどうか示す真偽値を返すようにしましたね。
この時、いきなりcheckをいじるまえに短絡動作をしないandについて想像し、
combine-resultsを定義し、簡単にcheckを手直しすることができました。

最後はテスト結果のレポート出力を改善しました。
すると抽象化できるところがいっぱい出てきたので、すかさず抽象化。deftestを定義しました。
テストの定義とその下にある機構との間に抽象化の壁ができたことで、
テスト関数に手を加えず結果のレポート出力を改善できました。

これで終わりですが、長くなりすぎました。反省。
残業続きなので3日ほどかけて書きましたが、長すぎると見返すのも大変になるのでよくないですね。
そしてまだまだ基礎が身についていない。もう少し一気に集中して勉強できるように時間をとらないと。
まあ今週は多分休日出勤になるのでそれもいつになることやら。
せちがらい世の中ですが頑張っていきましょう。おやすみなさい。