Lispアドベントカレンダーの記事ですよ
この記事はLispアドベントカレンダー2023の19日目の記事です。
はじめに
思うところがいくつかあり、Ethogramというテストフレームワークをつくっています。このEthogramをつくっていくなかで葛藤とか試行錯誤とかがあったのですが、話の種になりそうで読lみ物として楽しめそうな事柄があったために記事に起こしてみるとおもしろいのではないかなあ、じゃあ書いてみるか、というわけです。
ところでこの記事は前後編の後編です。ここまでの流れをおさらいしてみましょう。
ここまでのあらすじ
2023年の5月ごろ、ふと思った。テストをしっかり書けば過去の頓挫リポジトリはもうちょっとマシなところまで進んだのではないか、と。
既存のテストフレームワークを触った経験から、BDDがよいものであると知ったt-sinは、しかしその実装であるRubyのRSpecやGoのGinkgoにおぼえていた若干の不満が解消されたカッコよいテストフレームワークが生えてこないかなあと思うのであった。
t-sin曰く、DSLがうまくつくられているとテストが読みやすくなると。
またt-sin曰く、テストがうまく意味付けされているとテストが読みやすくなると。
そして遥かなる「なんかすっきりしててわかりやすいテストフレームワーク」、つまりすっわかテストフレームワーク実現への壮大なる旅路が幕を開けたのであった(ちいかわおもしろいよね)。
テストフレームワークッ、つくってみる…ってコト!?
そんなわけでもにょもにょと考えて (前編記事参照) いたわけですが。ないならお試しでつくってみるといいのです。実験はしたいし、いいものになったらCommon Lispの世界が豊かになってぼくとしてもうれしい。
そんなわけでつくりはじめたのが、Common Lisp用テストフレームワークEthogramです。
ちなみにいまから述べるのは1番目の設計時の目標です。現在は目標が変わって2番目の設計で実装していますが、その変遷はすぐ後に書きますのでひとまずそのまま読んでください。
1番目の設計のコードはEthogramのfirst-design
タグとして最後のコミットをのこしています。
目標 (1番目)
と、いうわけで以下のような目標を立ててつくっていました。
- 仕様を読みやすく書きたい 〜 ユニットテストでは不足である
- BDDスタイルのフレームワークほしい 〜 高級なフレームワークほしい
ここから整理したやること・やらないことを列挙すると以下のような感じです。
やりたいこと (必須)
- BDDの流儀をCommon Lispに持ち込む
- 構造化されたテストを記述するDSL
- テストの意図や意味を明確にしたDSL
やりたいこと (将来)
- いいかんじのexpectation/actualのdiff表示
- RSpecとかほかのにはいいのがあるので
- Common Lispのdiffライブラリを調べてみつけた気はする
- 拡張可能なDSL
- (テストのクラスを継承できるようにしたらんかいいのでは、しらぬけど)
- REPLでのテストの(再)定義・実行・確認 (introspection)
- まあせっかくREPLがあるのだから、くらいのノリで考えたがいつかどこかで
やらないこと
やらないことについては事前調査をしっかりしており、ここは偉いと思いました。
- 期待値を記述する方法 (RSpecのrspec-expectations)
- cl-hamcrestというライブラリがひとまずその用を為してそうだった
- 関数とかをモックする方法
- Common Lispでは、ユーザがなんでもできすぎていわゆるRSpecのようなモックがしづらいため
- システムの境界のみをモックすべきと思っていたため (Lispアドベントカレンダー2日目でcxxxrさんも書かれています)
- 既存でいくつかライブラリもあるようだった: (cl-mock, mockingbird)
- テストデータのテンプレート化ライブラリ (いわゆるfixtures; RSpecでいうとfactorybot)
- あるとけっこうよいが、使い方はかなりむずかしいと感じた
- factorybotでは複雑なfixtureを単純なfixtureの組み合わせで生成できるが、それが単純なfixtureへの依存を生み単純なfixtureを気軽に変更できなくなってしまうのを見たため
- これどうしたらいいんだろうなあ、とか思った
- アプローチはいいとおもうけど、ある定義にほかの定義が依存しそれはテストから依存されている、という状況がよくなさそう
- あくまでもfixtureは生成時点の固定データとして別に自動生成・保存・利用され、その定義とは切り離すのがいいのかなぁ・…とかおもったりした
- ORMのクラス定義と実際のスキーマファイルのように?
- 上記のように考えてたけど、いまやることじゃないなと思った
- ちなみに既存ライブラリはなさそうだった
どんなDSLを考えていたか
以下のような感じのDSLを考えていました (当時のメモから抜粋)。
テストをするとき、確認したいことはだいたい2つくらいに分類されそうだなと考えていました。純粋な関数の入出力についての確認、それと副作用についての確認です。
1番目のEthogramでは、それらをとりあえず3つの「テスト」としてDSLに表現しました。
すなわち、
-
関数の入出力についてのテスト
-
関数の副作用についてのテスト
-
複数の関数や処理の副作用による状態遷移についてのテスト
です。あと、もっと大きな、アプリケーション全体の振る舞いに関するテストもあるなあ……、とか思いながらも、とりあえずこの3つをDSLに書き下してみたのが以下の利用例 (件仕様) です。
(関数の引数・返り値についての)ユニットテスト
ここではある関数について、引数に対応する返り値を記述します。以下の例では整数かどうかを判定する関数のテストをしています。このときはこの種の確認を「ユニットテストとここでは呼ぶ」という気持ちでつくろうとしてたようです。
(defun my-integer-p (n) ...) ; a test subject
(test :about "A function my-integer-p returns t for integers"
(test :unit #'my-integer-p
:input 0 :expect t)
(test :unit #'my-integer-p
:input "aaa" :expect nil)
関数の副作用についてのテスト
これはある関数の実行前後の状態を記述するテストです。なんとなくRDBMSへのCRUD操作っぽい関数をテストしています。このときはこの種の確認を「アクションのテストとここでは呼ぶ」という気持ちでつくろうとしてました。
(defun all-books () ...)
(defun create-book (title author) ...) ; a test subject
(test :action "A book created"
:subject #'create-book
:input ("The Hitchhiker's Guide to Galaxy" "Daglas Adams")
:expect :before (zero (length (all-books)))
:expect :after (= (length (all-books)) 1))
複数の関数や処理の副作用による状態遷移についてのテスト
これは、書籍管理アプリのシナリオテストっぽいやつです。このあたりで「シナリオと呼ぶか振る舞いと呼ぶか」みたいな悩みを持った記憶があります。
(test :about "The book database application"
(test :behavoir "The user registers them book"
:step "The user gets an empty book list" ; make a label behavior-forms1 below. :step is temporal name...
forms...
:step "The user registers a book"
forms...
:step "The user checks that the book is registered"
forms...)
(test :bahavoir "The user searches a book"
...))
用語をわざわざ定義しているのがなんだかちょっと恥ずかしいですが、そのような方針で実装をしていました。
実装
この実装はわりと早々に、次の節に記す理由で捨ててしまいますが、150行くらいで最初の括弧つきの「ユニットテスト」のようなものを実行できるようなものは作りました。以下がそのときのコードです。
https://github.com/t-sin/ethogram/blob/133410b452431d30416c9b5acbacc178ffce59b4/main.lisp
いきなりクラスをつくっていないあたり、謙虚でいいですね。いま見るとそれくらいしか褒めるところがありません。
テストフレームワーク自体のテスト、どうしよう
ところでここまでは勢いでただガッと作りました。もちろんテストは、いったん「ユニットテスト」をできるようになってから書こうとしました。
「テストを最初からしっかり書けば頓挫しないんじゃ」と思ったんじゃないのかよお前。
理由のひとつはテストフレームワーク自体のテスト方法についてアイデアがなかったから、というものですが、わりと早めに思い至ったのは成長でしょうか。
そんなあたりでテストを書きはじめたものの、なんでお前はテストのためにまたテストフレームワークっぽいものつくってるんだアホか。
そんなとき、Common Lisp製エディタLemの作者のcxxxrさんからこんな話をききます。
曰く、Kent Beckの『テスト駆動開発』にはテストフレームワークのテストのしかたが書いてありますよ、と。
『テスト駆動開発』を読んでみた
もちろんこの本ですよ。
そうするとさっそく買って読みはじめるわけです。即ポチ即読みです。お恥ずかしながらいままで読んでなかったのです。とはいえ最初は「XUnit」(この本で実装するテストフレームワーク。本の第2部での、第1部で解説されたTDDの実践編)をパラ見してただけなのですが、細かすぎるくらいのいわゆるTDDをやっていて、そこだけ読んでもあまりピンときませんでした。
なので最初から全部読んでみたわけです。ええいこの際だ、勉強になるし、と。
Kent Beck『テスト駆動開発』、よい
それで読んでみたこの本、とてもよいわけですね。「テスト駆動開発」、いままで「なんかテストを書いて、落ちるのを見て、実装してテストを通すやつ」だと漠然と思っていました。そこに加えて前編で書いたような思いをもっていたわけです。
しかし実際この本の第一部で通貨の為替プログラムを用いて紹介されXUnitの実装で実践のようすを見せられるのは、「自信を持てるていどの小さなステップで「レッド、グリーン、リファクタリング」のループを繰り返し確実に実装と設計を進めていく、という方法だったのです。
いままで知ってた「テスト駆動開発」となにかが違う、しかも根本的に違う、と読んでいてひしひし感じました。
訳者解説が、よい
最後に置かれた和田卓人さんによる訳者解説がまた、とてもよいのです。
本編で登場する「テスト」、世にいう「テスト」という語とはなんだか違う様相をしております。「テストは仕様を表すもので、仕様なので最初にまとめて書くものではないの?」というような疑問が浮んできたりなどします。ぼくはそうでした。その「テスト」の語が意味するところを、本編が書かれたときからの変化も含めて、TDD周辺の用語や概念を整理して解説したものが訳者解説の内容です。
その内容のうちこの記事に関係するところのみをざっくりと書き表わすと以下のような感じです:
- TDDのTは本来の意味の「テスト」の一部でしかない
- 「ソフトウェアテスト」という語の中のほんの一部である
- TDDのTは実装や設計を補助する道具である
- 実装がこわれてないかを確認する
- 期待する動作を書くことで設計を補助する
- 頻繁に実行と書き換えを行うことで開発を加速する
- BDDは上記「テスト」の語がもたらす混乱を解消するために語彙を改めたもの
- TDDのTはコードの動作の"checking"である
- 「テスト」の語をつかうので「エラーを発見する」ことが期待されたりする
- では「テスト」ではなく「振る舞い (behaviors)」と呼ぶ
- この方針で語彙を整理したのがBDD (振る舞い駆動開発; Behavior-Driven Development)
- TDD/BDDが広まっていくにつれて原義が薄れていき「BDDは(ソフトウェア)テスト技法である」と認識されてしまっている
要約すると、TDDもBDDも目指すところは本来同じであって、ソフトウェアテスト技法ではなく、実装と設計を加速させるための方法なのです。期待する動作をテストとして書き、それを動作させながら実装をすすめてテストを通し、そして最後にリファクタリングをする。の動作を繰り返すことで、書いているソフトウェアは頻繁に実行されるので動作について安心感が得られ、小さなステップで書かれたテストを通すのを目標にすることで着実に実装を進行させ、そして動作を確認するためのテストがあることでリファクタリングも気軽にできる。
これが (これ以外のことも) 訳者解説に書いてあり、ここまでの認識がまさに「原義が薄れ」た知識に基いていたかを知るのです。
最高の本であって、最高の訳者解説でございました。著者のKent Beckさん、そして訳者の和田卓人さん、ほんとうにありがとうございます。みんなも読もうぜ!
Lispとテスト駆動開発、それとREPL駆動開発
『テスト駆動開発』を読んだことによって、前半で述べたEthogramは (用語の独自定義はさておき) 原義のTDD/BDDを実践できないと感じました。
ところで原義のTDD、本と読みすすめているとかなり親近感がありました。ぼくが普段のCommon Lispでの開発をするときのようすをちょっと書き出してみましょう。
- Lemを立ち上げ、
M-x slime
でCommon LispのREPLを立ち上げる - いまからつくりたいプログラムで使う (ただし要らなくなることもある) 小さい操作を妄想する
- その小さい操作がどのような動きをするかイメージする (ある引数に対して返り値がこう、とか)
- エディタでその関数を、すこし実装してみる (まずはベースケースとかから)
- その書いた関数を評価し、REPLで実行して動作をチェックする
- その関数が想定と違ううごきをしていたら、4に戻る
- 想定した用途のほうが間違っていたら、3に戻る
- ソフトウェアが完成するまで2に戻りつづける
Common LispやClojureはREPLがLSPよりも多機能で豪華ですので、立ち上げた処理系のランタイムとやりとりをしてデバッガなどを駆使しつつ、書いては実行、書いては実行をくりかえしてコードを実装していきます。よくRPEL駆動開発と言われてたりします。
ところでこれ、なんか原義のTDDに似ていませんか? というか細部の違いを無視するとほぼそのまんまですよね?
そしてまた、REPL駆動開発ではたまに厄介な状況になることもあります。実装しているコードがある程度複雑になってくると、REPLで動的にあれこれできるとはいえ手動で事前条件を満たすように確認と準備をしたり、複雑な手順で確認を行ったりといった、REPLにはたしかに入力履歴があるので多少楽ではあるものの手動でやるにはちょっとつらい、みたいなことがしばしば起こります。6年以内のいつかのlispmeetupで「REPLで実行した確認をテストコードにしてくれればいいのに……」という言葉を聞いた記憶があります。
ここまで書けばおわかりでしょう。Common Lisp使いやClojurianはREPLでTDDのTを確認しているわけです。
実際、多機能なREPLのあるCommon LispやClojureではREPLで確認するのが手に追えなくなってくるあたりでテストを書く、とか聞く気がします。し、ぼくがテストを書きはじめるのはたいていそういうときです。
「あっれ? REPL駆動開発とTDD、じつはすごく相性のいいものなのでは?」
これが、TDD本によって得られた学びに基いてCommon LispにおけるTDDのTを考えた結果です。
最新版のEtoghram
原義のTDDから見直したREPL駆動開発、という観点で前半で述べたEthogramを見直すと良し悪しが以下のようにありますね。
- TDD/BDDのテストは仕様ではない。けど、コードに期待する動作をテストとして書いておくと自動で動作を確認できてよい
- コードに期待する動作は完全な仕様ではない。けど、仕様の一部としてコードを読む助けにはなる
- REPLで気軽に再定義して実行できることは、TDDをとてもよくサポートしてくれる、なので実装・設計を促進できる
どうでしょう。これが新しいEthogramがベースアイデアです。さあ、既存の仕様とコードを捨て、新たなテストフレームワークをつくる時がやってきました。
思想
ただ、まだレッド・グリーン・リファクタリングループに入るのは早計です。設計思想を定めましょう。
- Ethogramにおいて「仕様 (specification)」とは「コードに期待するもの」のこととする
- 期待するものはたとえば、「実行例 (examples)」「実行前後の副作用 (sideeffects)」「簡単なシナリオによる状態の確認」
- Ethogramにおける「仕様」は、いわゆる「仕様書」ではない
- 「仕様書」や「設計書」は別途ドキュメントを書くこと
- Ethogramの「仕様」は、コードの仕様やコードの挙動を理解するため補助として利用される
- 「仕様書」や「設計書」は別途ドキュメントを書くこと
- Ethogramは「仕様」の意図がわかるようなDSLを提供する
- Eghogramの「仕様」DSLは書くのに時間がかかりすぎるような煩わしい構文としない
- Ethogramの「仕様」はREPLで即座に定義して実行できるし、即座に再定義できる
というかこれ、いままで脳内にはあったものの書き出してないものでした。あとでGitHubのREADME.mdにも書いておきます。
ちなみに「Ethogramにおける『仕様』」なんて誤解を招きそうな言葉を使っているのはdefspec
マクロのせいです。これをいま書き出していてdefspec
は仕様ではないから名前が適切ではないという気持ちになってきたので、あとで変えます。せっかく"ethogram" (動物行動学における研究対象の行動パターンを記録した目録; もちろんBDDをイメージしている) といういい名前をしているので"catalogue"とかにしてもいいんですが、defcatalogue
って長いんですよね。それがいったんdefspec
にしている理由です。defgram
……??
現状
・…と、ここまで述べてきた内容を踏まえているのがこちらです:
以下のようなテストを記述できます:
(defspec "a function to check number's oddness"
:subject #'oddp
(examples :function
:returns t :for 1)
(examples :function
:about "first argument is an odd number"
:returns nil :for 0
:returns nil :for 2
:returns nil :for 10)
(examples :group
:about "first argument is not an odd number"
(examples :function
:about "non-zero numbers"
:returns t
:for 1
:for 3
:for 1001)
(examples :function
:about "a zero"
:returns t
:for 0)))
初期の面影をのこしつつ、もうちょっとTDD寄りの記述になっているのではと思っています。
現状は関数の入出力を確認できるだけの状態で、まだREPLでの実行は想定していないコードです。が、ethogram/test/ethogram.lisp
を見てわかるようにまさしくTDD本そのまんまな方法で実装されていっています。
副作用の記述方法を考えている最中ですが、そこまで到達すると現状のテストを自分自身で実行できるようになると思っているので、既存テストの置き換えを達成した時点でv0.1.0
にしようと思っています。
ここ11月の末ごろにインフルかコロナかで40度の高熱をだしていたのと、その後の後遺症めいた体調不良により実装が停滞していますが (この記事を書くのもね!) 、ぼくはなによりもEthgramがほしいので優先して開発していくつもりです。
今後のこと
現状TODOリストを更新しながら実装していますが、v0.1.0
以降でどう発展させていくかはまだあまり考えていません。まあTDD的にはテスト書いて設計を揉みながら進めればいいのですが。ただ、机上で作るだけでは実際に使ったときの不足するものがわからないので、v0.1.0
になったら何か実際にプロジェクトに使ってみながら設計と実装をしていこうと考えています。何がいいかな……、ゲームボーイエミュレータとかいいんじゃかな……。
いまのところ、前半で書いていた「やらないこと」にあった「期待値を記述する方法」や「モック方法」「fixtureライブラリ」はやらないつもりでしたが、Ethogramの将来の形によってはほしくなってしまうかもしれないです。fixtureライブラリとか、あると便利ですよね。
余談ですが、定義マクロがdefspec
なのはいつかプロパティベーステストのしくみを入れてdefprop
をつくろうかなあと思っているからというのもあります。プロパティベーステストってなんぞや、という方にはラムダノート社の『実践プロパティベーステスト - PropErとErlang/Elixirではじめよう』(Fred Hebert著、山口能迪訳)を紹介しておきます。少しづつ読んでいるのですが、いい本です (これも11月の高熱で止まってます……)。
おわりに
前職や現職で触れたGinkgo、RSpecたちBDDスタイルのテストフレームワークと過去の実体験の中でのテストの有り様、そして『テスト駆動開発』を経由して、「テスト」について考え直すいい機会を得ることができました。おかげでEthogramについてもよい方針に辿りつけたと思っています。
Ethgramをとてもいいものにしたいです。そしてEthogramを踏み台にしてよりよいたのしいプログラムをもりもりと開発したいです。はやく使えるようになってくれ〜Ethogramぅ〜〜。
でも、自信を持てる小さなステップを繰り返していくことはお忘れなきよう。
以上でおわりです。