この記事は、入力や出力といった副作用を伴う関数の単体テストをどのように行うかという問題についての私案である。具体的には、マクロを使い副作用のある関数を単体テスト時にだけ副作用のない純粋関数として扱うというもの。
俺が過去に投稿した二つの記事と内容が被るんだけど、両者を合わせて結構いい感じに進化したと思うので備忘録を兼ねて紹介したい。
まず入力と出力の副作用が両方ある例として、以下の関数を考える。
(define (buhi cnt)
(let loop ((cnt cnt)
(nums '()))
(if (= cnt 0)
(for-each (lambda (n)
(display n)
(if (= (modulo n 3) 0)
(display " buhi"))
(newline))
(reverse nums))
(begin
(display "> ")
(let ((n (read)))
(loop (- cnt 1) (cons n nums)))))))
その内容はcnt回だけ数を入力し、その後それぞれの数を出力しながら3で割り切れる時だけbuhiを添えるという、実に馬鹿馬鹿しいものだ。
> (buhi 4)
> 1
> 2
> 3
> 4
1
2
3 buhi
4
この関数をテストするには、実際に入力を行い出力内容を確認しなければならない。もちろん入力や出力の確認を自動化する方法も考えられるが、もし純粋関数としてテストできるなら戻り値と期待値を比べるだけでいいから楽チンではないか?
そのためこの関数を以下のように書き直す。
; buhi.scm
(define (buhi cnt . rest)
(let loop ((cnt cnt)
(rest rest)
(nums '())
(runs (order)))
(if (= cnt 0)
(order
(turn runs)
(repeat (lambda (n)
(order
(run display n)
(if (= (modulo n 3) 0)
(run display " buhi")
(pass))
(run newline)))
(reverse nums)))
(let ((runs (chain (run display "> ") runs)))
(bind-rest rest ((n (read)))
(loop (- cnt 1) rest (cons n nums) runs))))))
ちょっと長くなってしまったが、基本的な構造は同じ。ここで登場するorderだのrepeatだのといったマクロは実行時とテスト時で実装が異なる。なお以下のソースはs7用に書いたものだが、単純な内容なので読むのに支障はないはず。
; io_exec.scm
(define-macro (order . calls)
`(begin ,@calls))
(define-macro (run f . args)
`(,f ,@args))
(define-macro (pass)
#<unspecified>)
(define-macro (repeat lm . ls)
`(for-each ,lm ,@ls))
(define (chain l r)
#<unspecified>)
(define (turn l)
#<unspecified>)
(define-macro (bind-rest rest binds . body)
(let loop ((binds binds))
(if (null? binds)
`(begin ,@body)
(let ((ret (loop (cdr binds)))
(bind (car binds)))
(let ((var (car bind))
(val (cadr bind)))
`(let ((,var (if (null? ,rest) ,val (car ,rest)))
(,rest (if (null? ,rest) rest (cdr ,rest))))
,ret))))))
これが実行用の実装で、
; io_test.scm
(define-macro (order . calls)
`(list ,@calls))
(define-macro (run f . args)
`(list ,f ,@args))
(define-macro (pass)
'())
(define-macro (repeat lm . ls)
`(map ,lm ,@ls))
(define (chain l r)
(cons l r))
(define (turn l)
(reverse l))
(define-macro (bind-rest rest binds . body)
(let loop ((binds binds))
(if (null? binds)
`(begin ,@body)
(let ((ret (loop (cdr binds)))
(bind (car binds)))
(let ((var (car bind))
(val (cadr bind)))
`(let ((,var (if (null? ,rest) ,val (car ,rest)))
(,rest (if (null? ,rest) rest (cdr ,rest))))
,ret))))))
(define (simplify-order order)
(if (procedure? (car order))
order
(reverse
(let loop ((l order)
(r '()))
(if (null? l) r
(let ((e (car l)))
(loop (cdr l)
(cond
((null? e) r)
((and (pair? e)
(procedure? (car e)))
(cons e r))
(else
(append (loop e '()) r))))))))))
こっちがテスト用の実装。bind-restマクロの実装は両者とも同じだね。
で、あとは実行時とテスト時にそれぞれのファイルを読み込む。
実行用のファイルは、
; exec.scm
(load "io_exec.scm")
(load "buhi.scm")
(buhi 4)
実行結果は、
$ ./s7 exec.scm
load exec.scm
> 1
> 2
> 3
> 4
1
2
3 buhi
4
テスト用のファイルは、
; test.scm
(load "io_test.scm")
(load "buhi.scm")
(display
(equal? (simplify-order (buhi 4 1 2 3 4))
(order
(run display "> ")
(run display "> ")
(run display "> ")
(run display "> ")
(run display 1)
(run newline)
(run display 2)
(run newline)
(run display 3)
(run display " buhi")
(run newline)
(run display 4)
(run newline))))
(newline)
テスト結果は、
$ ./s7 test.scm
load test.scm
#t
なお、このようにテスト時には入力値を可変長引数として渡すので、入力と出力を混在できるのはトップレベルで呼び出す関数だけということになる。複雑な処理を関数分けする場合、下位の関数は入力と出力をきっちり分ける必要があるので注意。
いじょう