新訳版『テスト駆動開発』の第32章 TDDを身につける(pp. 268-270)に Smalltalk のコード例が載っているので、今最も活発に開発が進められている Smalltalk処理系のひとつである Pharo を使ってケント・ベックの TDD を追体験してみましょう。
どのくらいのフィードバックが必要か
どのくらいテストを書くべきだろうか。簡単な問題で考えてみよう。与えられた3つの整数が三角形の辺として成り立つかを調べ、次のような値を返すとする。
- 正三角形のときは 1
- 二等辺三角形のときは 2
- 不等辺三角形のときは 3
なお、三角形でないときは例外を投げるものとする。
さて、読者の皆さんも実際に解いてみてほしい(私が Smalltalk で解いた答案は節の最後に載せてある(次ページ))。
なお、本来の出題の意図である“あなたなら何個のテストを書く?”からは逸れてしまいますが、ここでは簡単のため同書で提示されているテストコードをそのまま使うことにします。
Pharo の入手とインストール、起動
Pharo のセットアップは、Pharo を使った MOOC(オンライン公開講座)の Download Pharo の項を参考にしてください。
現時点で体験可能な MOOC では Pharo5.0 を使用するようですのでここではそれと同じものを使います。余力があればそのまま MOOC の講座のチャレンジも是非(ただし英語字幕。日本語字幕も準備中とのこと)。
-
FUN - Programmation objet immersive en Pharo / Live Object Programming in Pharo(英語音声版あり、要登録)
-
Pharo MOOC - WebPortal(英語字幕付き仏語音声版のみ、登録不要)
Linux は依存関係がちょっとやっかいかも知れませんが、Windows と macOS については、.zip を展開してほぼ終了のお手軽処理系です。
システムブラウザ(クラスブラウザ)を開く
通常の言語と違い、Smalltalk にはクラスやメソッドを定義するための特別な構文がない代わりに「クラスブラウザ」と呼ばれるGUIツールを使ってそれらの定義や追加、編集等の操作を行ないます。クラスブラウザを開くには、デスクトップをクリックしてポップアップするメニューから「System Browser」を選択します。
余談ですが、Smalltalk はホストOS 上で GUI 付きの仮想OS のように振る舞う IDE それ自体が Smalltalk で記述されている(つまり Smalltalk のクラスとそのインスタンスにより構成されている)ため、クラスブラウザのことを伝統的に「システムブラウザ」(システムそれ自体の定義をブラウズするためのツール)とも呼びます。
今回は使いませんが、クラスブラウザは環境内で同時に複数開くことができますので、テストコードとプロダクトコードを行き来するときなどにはそれぞれをブラウズするためのウインドウを開いておくと便利です。
TriangleTest クラスを定義する
クラスブラウザを開いたときに最も領域の広いコード入力枠に表示されたクラス定義のテンプレートを使ってクラスを定義します。
Object subclass: #NameOfSubclass
instanceVariableNames: ''
classVariableNames: ''
package: ''
このテンプレートを次のように書き換えてコンパイルすると TriangleTestクラスを新たに定義してシステムに追加できます(コンパイルはコード入力枠内で右クリック → Accept、もしくは Ctrl + S です)。
TestCase subclass: #TriangleTest
instanceVariableNames: ''
classVariableNames: ''
package: 'TDD-Example'
問題なくコンパイルが通ると、システム内に TriangleTestクラスが生成されます。同時に定義時に指定したパッケージ(クラスカテゴリーとも言う)である TDD-Example が上段の左から1カラム目に、さらにそこに新たに定義した TriangleTestクラスが同上段2カラム目に追加され、選択された状態で表示されます。
Smalltalk でのプログラミングはこの調子で、インタラクティブかつインクリメンタルに、そしてランタイムのみ(Smalltalk の処理系や環境は Smallltalk自身で記述・構築されているのでその動作時という意味)で行なわれるのが特徴です。
正三角形のときは 1 を返す - testEquilateral
3番目のカラムにある「no messages」をクリックすると下のコード入力枠にメソッド定義のためのテンプレートが現れます。
messageSelectorAndArgumentNames
"comment stating purpose of message"
| temporary variable names |
statements
このテンプレートは、Smalltalk のメソッド定義が
- メッセージセレクター(メソッド名のこと)と宣言すべき仮引数をメッセージ式の記法を模して記述したもの(= メッセージパターン)
- ダブルクオーテーションで括ったコメント(必要なら)
- | | で括ってスペースで区切って列挙した一時変数(必要なら)
- メソッド本体(式。複数の式からなる場合はピリオドで区切る)
という要素で構成されることを教えてくれています。
Smalltalk の式は
receiver message
のように(通常の言語のようにピリオドではなく)スペースで区切って記述します。メッセージには、
- self fail の fail のように引数をとらない単項メッセージ
- 3 + 4 の + 4 のように他言語の二項式を模した記号(列)とひとつの引数からなる二項メッセージ
- self assert: true の assert: true や self evaluate: 1 side: 2 side: 3 の evaluate: 1 side: 2 side: 3 のように、文字列とコロンからなるキーワードと対応する引数の組の1つ以上の組からなるキーワードメッセージ
の3種類があります。
また、メッセージは通常の言語で言うところのメソッド名を表すメッセージセレクター(あるいは短くセレクター)と0個以上の引数との混成とも考えることができて、単項メッセージではメッセージがそのまま(例:fail)、二項メッセージでは記号(列)が(例:+)、キーワードメッセージではキーワードを連結したものが(例:evaluate:side:side:)がセレクター、すなわちメソッド名になります。
キーワードメッセージなどは、かなり特殊な書式のように見えますが、たとえば
self evaluate: 1 side: 2 side: 3
という式は、通常の言語風に書けば(ただし通常は許されない「コロンをメソッド名に含めることが可能」というちょっと変わった前提が必要ですが…)
self.evaluate:side:side:(1,2,3)
と単純なルールで相互に変換できることを知っておけば、Smalltalk のコードを読み下すのはさほど特殊な能力は必要ないことがわかります。
閑話休題。話を TDD に戻しましょう。
テンプレートを全選択して『テスト駆動開発』電子本からコピーした testEquilateral メソッド定義をペーストするか、タイプして写経した後、コンパイルします。コンパイルはクラス定義時と同じくコード入力枠内で 右クリック → Accept、もしくは Ctrl + S です。
初回のメソッドコンパイル時にはシステムがユーザー名を訊ねてくるので適当な名前を教えてやってください。
コンパイルが通ると上段右端の枠に今定義した testEquilateral が現れます。
メソッドのないできたてのクラスに一つ目のメソッドを定義すると、すぐ左隣の枠は「no message」から「as yet classified」に変わります。この枠はメッセージカテゴリーと呼ばれるメソッドの属性を整理、表示するのに使われます。テストコードは tests というカテゴリーに分類することが多いのでそのようにしましょう。
「as yet classified」を 右クリック → Rename... → testsと入力 → OK すると表示を tests に変えられます。
改めて右隣の枠の testEquilateral をクリックすると、同テストメソッドの定義が下のコード入力枠に再表示されます。
コピペだと2行目のインデントが消えてしまうので、右クリック→Formatで体裁を整えて再びコンパイル(Ctrl-S)しておきくのもよいでしょう。
下の欄を見ると、プロダクションコードである evaluate:side:side: が定義されていないぞ!と警告されていますが、無視してテストを走らせてみます。
上段左から2番目の枠の TrinangleTest を 右クリック → Run tests(あるいは Ctrl + J → C )でテストを走らせると右下にレッドバーが表示されます。
これだけだとテストがエラーを起こしたことしかわからないので、改めて上段右端枠から testEquilateral をクリックして選択した後、右クリック → Run tests(もしくは Ctrl + J → M )します。
すると今度は、MessageNotUnderstood: TriangleTest>>evaluate:side:side: と書かれたノーティファイアが現れます。これは「TriangleTest のインスタンスが evalueate:side:side: というセレクター(メソッド名のこと)を含むメッセージを理解できなかった(MessageNotUnderstood)ので実行を中断した」ということ伝えています。
このノーティファイアの表示枠の2行目の「TriangleTest testEquilateral」をクリックするとデバッガーが起動し、どのようなコンテキストでエラーが起こったのかを見ることもできます。
ちなみに今回は違いますが、呼び出し側のコードの誤りによるエラーならここで修正してコンパイルしなおし(Ctrl + S)、Proceed すれば続行が可能です。
今は evaluate:side:side: が定義されていないことが問題なので、ウインドウタイトルバー直下の +Createボタンをクリックして定義してやります。
当該ボタンをクリックするとまず、どのクラスに定義するかを訊ねてくるので、
プロダクションコードを置くクラスを別に定義していない同書に従い※ TriangleTest をクリックして選択します(※とはいえ、エラーを出した式が self をレシーバーにしている時点で、ノーティファイアーやデバッガーが提示できるクラスは TriangleTest かそのスーパークラスのどれかに限られるわけですが…)。
続けてメッセージカテゴリーをどこに分類するか訊ねてくるので、evaluating という新しいカテゴリー名をタイプして入力後 → OK します。
すると本体が「self shouldBeImplemented」というスタブメソッドが作られるので、とりあえず self を返す(Smalltalk では何を返してもよいときは self を返すようにコードします。ちなみにリターンは ^ です)失敗するコードに置き換えてやります。1行目のメッセージパターン中の仮引数宣言も anIntegerN から aNumberN に変えておきましょう。
evaluate: aNumber1 side: aNumber2 side: aNumber3
^ self
変更が終わったらコンパイル(コード入力枠内で右クリック → Accept、もしくは Ctrl + S)していったんデバッガーウインドウを閉じてしまい実行を抜けます。
改めて Ctrl + J → C でテストを走らせると、今度はエラーでは無く失敗を示すイエローバーが現れるはずです。
引き続き、テストを通す最低限の仮実装、つまり 1 を返すように evaluate:side:side: を変更してコンパイル(Ctrl + S)します。
evaluate: aNumber1 side: aNumber2 side: aNumber3
^ 1
改めて Ctrl + J → C でテストを走らせると、ようやくグリーンバーが現れるはずです。
リファクタリング後のコードは、そのまま「三辺長さが等しいときは 1 を返す(それ以外は適当に self を返しておく)」というのではどうでしょうか?
evaluate: aNumber1 side: aNumber2 side: aNumber3
(aNumber1 = aNumber2 and: [ aNumber2 = aNumber3 ])
ifTrue: [ ^ 1 ].
^ self
二等辺三角形のときは 2 を返す - testIsosceles
tests カテゴリーをクリックして選択し、下のコード枠にメソッドテンプレートが表示されたのを確認して、testIsosceles をコードします。例によって同書からコピペ、あるいはタイプして写経後コンパイル(Ctrl + S)後、Format (Ctrl + Shift + F)して再コンパイル(Ctrl + S)もしておきましょう。
testIsosceles
self assert: (self evaluate: 1 side: 2 side: 2) = 2
テストを走らせる(Ctrl + J → C)と失敗します。
-- all -- もしくは evaluating カテゴリーをクリックして選択して evaluate:side:side: メソッドを右隣に呼び出し、改めてクリックして選択して次のように仮実装をします。
evaluate: aNumber1 side: aNumber2 side: aNumber3
(aNumber1 = aNumber2 and: [ aNumber2 = aNumber3 ])
ifTrue: [ ^ 1 ].
^ 2
変更後のコンパイル(Ctrl + S)を忘れずに。
テストが通ったらリファクタリングです。
evaluate: aNumber1 side: aNumber2 side: aNumber3
(aNumber1 = aNumber2 and: [ aNumber2 = aNumber3 ])
ifTrue: [ ^ 1 ].
(aNumber1 = aNumber2 or: [ aNumber1 = aNumber3 or: [ aNumber2 = aNumber3 ]])
ifTrue: [ ^ 2 ].
^ self
不等辺三角形のときは 3 を返す - testScalene
二等辺三角形と同様に tests をクリックしてテンプレートを呼び出し、testScalene を定義(コピペ and/or 写経 → Ctrl + S でコンパイル → Ctrl + Shift + F でフォーマット → Ctrl + S で再コンパイル)後、Ctrl + J → M でテストを走らせ、失敗(イエローバー)を確認します。
testScalene
self assert: (self evaluate: 2 side: 3 side: 4) = 3
仮実装も同様です。
evaluate: aNumber1 side: aNumber2 side: aNumber3
(aNumber1 = aNumber2 and: [ aNumber2 = aNumber3 ])
ifTrue: [ ^ 1 ].
(aNumber1 = aNumber2 or: [ aNumber1 = aNumber3 or: [ aNumber2 = aNumber3 ]])
ifTrue: [ ^ 2 ].
^ 3
リファクタリングはちょっと工夫をしましょう。
evaluate: aNumber1 side: aNumber2 side: aNumber3
^ {aNumber1. aNumber2. aNumber3} asSet size
{ 式. 式. 式 } は Squeak Smalltalk 以降の多くの実装で導入された配列(Array)の動的定義式で、それに対して asSet というメッセージを送って重複を排除し、残った要素数をそのまま返しています。正三角形なら三辺が等しいので 1 、二等辺三角形なら等しい二辺と残りの一辺が要素に残るので 2 が返るカラクリです。
テストも通ります。
三角形でないときは例外を投げる(三辺の長さ)- testIrrational
最長の辺の長さに残りの辺の長さの和が満たなければ三角形にはならないというチェック。
testIrrational
[ self evaluate: 1 side: 2 side: 3 ]
on: Exception
do: [ :ex | ^ self ].
self fail
仮実装はちょっとトリッキーですがこんなふうにしてみました。
evaluate: aNumber1 side: aNumber2 side: aNumber3
| sides |
sides := {aNumber1. aNumber2. aNumber3}.
sides max * 2 >= sides sum ifTrue: [ ^ self fail ].
^ sides asSet size
三辺を sides に代入し、その合計値(sides sum)が最大値(sides max)の二倍に満たない場合はエラー(self fail)。
三角形でないときは例外を投げる(負の入力)- testNegative
testNegative
[ self evaluate: -1 side: 2 side: 2 ]
on: Exception
do: [ :ex | ^ self ].
self fail
負数が入ると三辺の長さの関係(testIrrational でテスト済み)が必ず破綻するのでこのテストは無用なように思います。
三角形でないときは例外を投げる(数値以外の入力)- testStrings
testStrings
[ self evaluate: 'a' side: 'b' side: 'c' ]
on: Exception
do: [ :ex | ^ self ].
self fail
文字列と数値との演算や比較を許さないのでこのテストも必ず通ってしまうようです。エラーを拾うテストは考えものですね。
ケント・ベックの evaluate:side:side: 実装
evaluate: aNumber1 side: aNumber2 side: aNumber3
| sides |
sides := SortedCollection with: aNumber1 with: aNumber2 with: aNumber3.
sides first <= 0
ifTrue: [ self fail ].
(sides at: 1) + (sides at: 2) <= (sides at: 3)
ifTrue: [ self fail ].
^ sides asSet size
イメージの保存と終了
Smalltalk 処理系は、自身が自身で定義された Smalltalk オブジェクトのみで構成されている性格から、その全体をオンメモリの簡易なオブジェクトストアと考えることもできます。実行中のデータ(オブジェクト)はもちろん、そのクラスやメソッド、IDE や処理系を構成するオブジェクト群(システムブラウザ、デバッガー、インスペクター、コンパイラ、構文解析器、字句解析器等々)、プロセスや実行中のコンテキスト、GUIウィジェットなど、あらゆるオブジェクトがごった煮の状態で収められています。
こうしたオブジェクトストア(メモリ)の状態は、ファイルにダンプ出力が可能で、それを再びメモリに読み込んで再展開することでまったく同じ状態で作業を再開する事が可能です。PharoWeb.image を仮想マシンの役割を果たすホストOS 向けの実行ファイル(.app、.exe 等)にドロップインして Pharo を起動したのはそうしたしくみを利用しています。
このような Smalltalk 処理系の動作中(ランタイム)のオブジェクトメモリの状態をそのままダンプしたファイルを「仮想イメージ」あるいは短く「イメージ」と呼びます。オブジェクトメモリをダンプ(永続化)したければ、デスクトップのクリックでポップアップするメニューから Save 等を選びます。
もし、今回の作業内容は残しておきたいがイメージを汚したくないと思うなら(起動時と同じ状態で繰り返し利用したいときは)、変更部分をテキストファイルに出力(ファイル・アウト)しておき、次回必要になったら改めて読み込む(ファイル・イン)ことも可能です。
- ファイル・アウト …… パッケージ枠でパッケージを右クリック → File Out → .st ファイルの作成
- ファイル・イン …… Pharo を起動後、.st ファイルをデスクトップにドロップイン → FileIn entire file 等
ただしこの簡易なコードのやりとりの方法は、TDD-Example のようにひとつのパッケージにコードが収まっている場合に限ります。通常の開発では、Monticello などバージョン管理ツールを使用する必要があるので興味があれば適宜使い方を修得してください。
Pharo を終了するには デスクトップメニューから Quit → イメージを保存するなら Yes しないなら No を選びます。