Common Lispでマクロ展開時エラーをテスト

前回記事の「Parenscriptで少し遊んで見る (5)defstruct編 - eshamster’s diary」ではdefstruc.psマクロ内で展開時のエラー処理を入れたので、テストも一緒に作っています。このときに、マクロ展開時のエラーをテストする方法が意外と見つからなかったのでメモ。探し方が悪いのか、そんなの当たり前でしょということで誰も書いていないのか。

CL-USER> (defmacro throw-error-macro (x)
           (if (numberp x)
               `(+ ,x 10)
               (error 'type-error)))
THROW-ERROR-MACRO

特に意味のないマクロですが、数値型を受けとると10を足す式を返し、それ以外の型を受けとると(展開時に)type-errorを投げます。この挙動をテストしたい訳ですが…

CL-USER> (ql:quickload :prove :silent t)
(:PROVE)
CL-USER> (prove:is-error (throw-error-macro nil) 'type-error)
;
; compilation unit aborted
;   caught 1 fatal ERROR condition
;   caught 1 ERROR condition
; Evaluation aborted on #<SIMPLE-ERROR "unbound condition slot: ~S" {1005EC7663}>.

というように、prove:is-errorが呼び出される前のコンパイル段階でエラーになるため、prove:is-errorでエラーを捕捉できません。マクロを実行時に評価できれば良いわけですが、実行時評価となればevalの出番です。

CL-USER> (prove:is-error (eval '(throw-error-macro nil)) 'type-error)(EVAL '(THROW-ERROR-MACRO NIL)) is expected to raise a condition TYPE-ERROR (got #<TYPE-ERROR {1003C91FE3}>) 
T
#<PASSED-TEST-REPORT RESULT: T, GOT: #<TYPE-ERROR {1003C91FE3}>, EXPECTED: TYPE-ERROR>

できました。一々evalやクォートを書くのが気持ち悪い場合、以下のようにprove-macro-expand-errorマクロを作成します。

CL-USER> (defmacro prove-macro-expand-error (code expected-error)
           `(prove:is-error (eval ',code) ,expected-error))
PROVE-MACRO-EXPAND-ERROR
CL-USER> (prove-macro-expand-error (throw-error-macro nil) 'type-error)(EVAL '(THROW-ERROR-MACRO NIL)) is expected to raise a condition TYPE-ERROR (got #<TYPE-ERROR {10044945E3}>)
T
#<PASSED-TEST-REPORT RESULT: T, GOT: #<TYPE-ERROR {10044945E3}>, EXPECTED: TYPE-ERROR>

言われてみれば当たり前な気がしますが、個人的にはevalを使う機会がなかったので中々出てこない発想でした。

ちなみに、元々は「リードマクロ入門、の10分の1歩ぐらい後か前 - eshamster’s diary」のコメントで教えて頂いたREPL環境でのリードマクロの試用方法から思い至ったため、(format nil "~S" ...)で文字列化してread-from-stringをしてevalするという無駄なことをしていました。一方で、リードマクロのエラーをテストしたい場合はread-from-stringを使えば良さそうです。

追記:macroexpand

g000001さんからmacroexpand(-1)の方が一般的とのコメントを頂きました。evalは汎用的過ぎるので、可能な限りより用途の限定されたもので代用するのが適切だと思いますが、macroexpand(-1)はまさにぴったりの用途ですね。Lispを勉強し始めてからは前より人のソースを見ることが増えましたが、まだまだ勉強不足でした。

ということで、macroexpand版です。見た目としてはevalmacroexpandに置き代わっただけです。

CL-USER> (prove:is-error (macroexpand-1 '(throw-error-macro nil)) 'type-error)(MACROEXPAND-1 '(THROW-ERROR-MACRO NIL)) is expected to raise a condition TYPE-ERROR (got #<TYPE-ERROR {10060A87D3}>) 
T
#<PASSED-TEST-REPORT RESULT: T, GOT: #<TYPE-ERROR {10060A87D3}>, EXPECTED: TYPE-ERROR>

;; マクロ化
CL-USER> (defmacro prove-macro-expand-error (code expected-error)
           `(prove:is-error (macroexpand ',code) ,expected-error))
PROVE-MACRO-EXPAND-ERROR
CL-USER> (prove-macro-expand-error (throw-error-macro nil) 'type-error)(MACROEXPAND '(THROW-ERROR-MACRO NIL)) is expected to raise a condition TYPE-ERROR (got #<TYPE-ERROR {1003246EA3}>) 
T
#<PASSED-TEST-REPORT RESULT: T, GOT: #<TYPE-ERROR {1003246EA3}>, EXPECTED: TYPE-ERROR>