STGつくろー!
その2 「テスト主導型開発(TDD:Test Driven Development)とは?」
@ TDD?
STGつくろー!という無謀な計画は、色々な事を導入して試す場とも考えています。「その1 YAGNI(ヤグニ)の原則でで行こう!」の最後で出てきたテスト主導型開発(TDD:Test Driven Development)(もしくはテスト駆動型開発)もその1つです。これはいったい何なのでしょう?
テスト主導型開発とは、テストプログラムを先に作りながら、テストを主体に開発を進めていく方法論です。この開発手法はテストの自動化を実現し、YAGNIの原則と合わさることにより、シンプルかつ高品質でバグの少ないソフトウェア作りが可能になるといわれています。
この開発方法については参照文献「バグがないプログラムのつくり方〜JavaとEclipseで学ぶTDDテスト駆動開発(川端光義・倉貫義人・兒玉督司著、長瀬嘉秀監修)」に大変詳しく掲載されております。ここでは、それを踏まえて、TDDの流れをVisual C++V6版で例を用いて行ってみます。
A TDDの流れ
TDDの開発の流れは、とてもユニークです。ユニークすぎて始めはとっつきにくいかもしれません。でも、慣れてくるとその革新性の虜になります。
TDDの最初の工程は「すべきことを」を考えます。これは、非常に小さい単位で必要最小限でしかも具体的に考えます。例えば、「Player1の位置を取得する」「Player1の位置を変更する」くらいの小さなことです。これらは「ToDoリスト」と呼ばれ、それをどこかに書き留めます。
ToDoリスト ・ Player1の位置の取得は(x, y, z)となる。 ・ Player1の位置の変更はPlayer1の新しい位置になる
これを見ただけでどういうプログラムを書けばいいか分かる人には、このリストは実につまらない内容なのです。しかし、これはとても大切な作業なのです。目の前にあるリストを達成するプログラムを作る。これは、「明確な目標」です。目標が明確だとまず意欲が沸きます。しかもこれを達成するテストプログラムを次に作るので、何か変更があった時のテストリストが、すでにここにできているのです。これは、後のデバッグ作業を劇的に変えます。
ToDoリストを作ったら、次にテストコードを作成します。テストコードとは、ToDoリストの内容を検証するテストプログラムのことです。
Visual C++V6の場合は「Win32 Application」を新たに作成し、例えば「PlayerTestProgram」のような名前を付け、「単純なWin32アプリケーション」を選択してプログラム作成状態にします。
ここからの手順に注目です。テストを実施するために、テスト専門のクラスを作ってしまいます。今回の例ではPlayerに関する事なのでクラス名を「CPlayerTest」としましょう。クラスの作り方は皆さんご存知だと思いますので割愛しますが、「クラスの新規作成」の結果こういうクラスができるはずです。
↑必要部分だけを抜き出しています
class CPlayerTest
{
public:
CPlayerTest();
virtual ~CPlayerTest();
};
次に、今回のToDoリストのテスト内容を「メソッド」として作成します。名前は「testGetPlayerPosition」にしましょう(文頭にtestと付けるのが慣習のようです)。後で使いまわすようなクラスではないので、ヘッダーファイルに実装部も直接書き込んでしまいましょう。
class CPlayerTest
{
public:
CPlayerTest();
virtual ~CPlayerTest();
void testGetPlayerPosition(){
}
};
この段階でもちろんコンパイラは通ります。
さてここからです。このテストメソッドの中に、ToDoリストの1番目「Player1の位置の取得は(x,
y, z)となる」を達成するプログラムを書きます。「んじゃまずプレイヤークラスを作って・・・」と言いたくなりますが、そうではありません。次のようにいきなり書き込みます。
class CPlayerTest
{
public:
CPlayerTest();
virtual ~CPlayerTest();
void testGetPlayerPosition(){
assertEquals(100, Player1.GetPosition().x);
}
};
「???」
そして、さらにこれをコンパイルします!(何してんだ?とか思って頂くと先が楽しくなりますよ)
「????????????」
当然エラーが出ますよね。当たり前です。エラーの内容を見ます。
エラー内容 |
error C2065: 'assertEquals' : 定義されていない識別子です。 error C2065: 'Player1' : 定義されていない識別子です。 error C2228: '.GetPosition' : 左側がクラス、構造体、共用体ではありません。 error C2228: '.x' : 左側がクラス、構造体、共用体ではありません。 |
これが大切なんです!自分がしたいことをいきなりプログラムに書いて、コンパイラにエラーを出してもらう。このエラーがなくなる様にプログラムを組んでいけば、それが最短最小の筋道になるのです。
ここでは、「プレーヤー1のx座標を取り出して、それが100じゃなかったら警告を出してください」というプログラムにしたいんです。assertEqualsというのは勝手に作った関数で、1番目の引数に期待値を、2番目の引数に比較する数値を入れると、両者を比較し、等しくなかったら警告を出す機能を持たせたいと、ここでは考えています。警告はどこに出すかというとデバッガウィンドウです。
しかし、最初のエラーにあるように「assertEquals」は定義していません。よって、まず、この関数を定義するところから始めます。クラスの中で使っていますから、クラスのメンバ関数として定義しましょう(え、いや、そうじゃないだろ・・・と思った方。鋭いですがこの遠回りが大切なんです。違和感を取り除く答えはリファクタリングにあります)。
PlayerTest.h #include <stdio.h>
class CPlayerTest
{
public:
CPlayerTest();
virtual ~CPlayerTest();
void testGetPlayerPosition(){
assertEquals(100, Player1.GetPosition().x);
}
// 等比較関数
void assertEquals(int expect, int val){
if(expect != val){
char c[100];
sprintf(c, "■■ 期待値は %d でしたが、引数は %d となり異なっています。\n"
, expect, val);
OutputDebugString(c);
}
}
};
関数内のOutputDebugString関数はデバッガウィンドウに文字列を出力するWin32 APIです。どこでも使えて便利です。これで再度コンパイルします。
エラー内容 error C2065: 'Player1' : 定義されていない識別子です。
error C2228: '.GetPosition' : 左側がクラス、構造体、共用体ではありません。
error C2228: '.x' : 左側がクラス、構造体、共用体ではありません。
エラーが1つ減りました。次のエラーを対処します。Player1が定義されていないとありますから、testGetPlayerPosition関数内にオブジェクトを定義しましょう。Player1というのは具体的なオブジェクトの名前です。このオブジェクトの所属するクラスはCPlayerと名付けることにします。
void testGetPlayerPosition(){
CPlayer Player1;
assertEquals(100, Player1.GetPosition().x);
}
これでコンパイルすると、次のようなエラーが出ます。
エラー内容 error C2065: 'CPlayer' : 定義されていない識別子です。
error C2146: 構文エラー : ';' が、識別子 'Player1' の前に必要です。
error C2065: 'Player1' : 定義されていない識別子です。
error C2228: '.GetPosition' : 左側がクラス、構造体、共用体ではありません。
error C2228: '.x' : 左側がクラス、構造体、共用体ではありません。
最初のエラーはCPlayerクラスが定義されていないというコンパイラエラーです。これを解決するため、新しくCPlayerクラスを追加します。
Player.h class CPlayer
{
public:
CPlayer();
virtual ~CPlayer();
};
このヘッダファイルをPlayerTestクラスにインクルードしてコンパイルします。すると次のエラーが返ります。
エラー内容 error C2039: 'GetPosition' : 'CPlayer' のメンバではありません。...'CPlayer' の宣言を確認してください。
error C2228: '.x' : 左側がクラス、構造体、共用体ではありません。
少なくとも、Player1というオブジェクトは認識されました!残りのエラーの最初は、GetPositionという関数がCPlayerクラスの中に無いというエラーです。これを見て、この関数を次にCPlayerクラスに加えます。
Player.h class CPlayer
{
private:
POSITION m_Position;
public:
CPlayer();
virtual ~CPlayer();
// 位置を取得
POSITION GetPosition(){
return m_Position;
}
};
「POSITIONて何だ?」と考えずに、もやっと「位置を返してくれるオブジェクトがあるといいな」位の気持ちでプログラムを書くのが大切なんです。関数自体はとても簡単ですよね。さて、またまたここでコンパイルします。すると、エラーが13個出ました。多すぎるので掲載は割愛しますが、要は「POSITIONなんて知らないよ」というエラーです。これを見て、さらにPOSITIONクラスを追加します。
POSITION.h class POSITION
{
public:
POSITION();
virtual ~POSITION();
};
注意して欲しいのは、先読みした実装をしないということです。頭の中では「int xを入れて」とどうしても考えてしまいますが、それはまだ考えないんです。ここでは、13個出たエラーの主要因である「POSITIONクラスを設ける」という事に徹します。PlayerクラスにPOSITIONクラスをインクルードして、コンパイルします。エラーは一気に1個に減りました。
エラー内容 PlayerTest.cpp
error C2039: 'x' : 'POSITION' のメンバではありません。...'POSITION' の宣言を確認してください。
どうです?これを見て初めて「あ、それじゃint xをPOSITIONクラスに入れよう」と決めるわけです。実際にPOSITIONクラスにメンバ変数xを追加して、コンパイルします。
ノーエラーです!やりました。これで、コンパイルエラーが無い状態にはなりました。ここまで、まず先にやりたい事をいきなり書き出し、コンパイルエラーをわざと出し、それを1つ1つ見ながら最小のプログラムで解決しました。コンパイラに従っていたら、PlayerクラスとかPOISITONクラスとかを自然に作らされていたことに気が付きましたでしょうか?
さて、これで終わりではありません。次に、このクラスの動作が正しく行われるかをチェックしなければなりません。
WinMain関数にPlaterTestテストクラスをインクルードし、オブジェクトを生成して、testGetPlayerPosition関数を実行します。
PlayerTestProgram.cpp #include "PlayerTest.h"
int APIENTRY WinMain(HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPSTR lpCmdLine,
int nCmdShow )
{
CPlayerTest Obj;
Obj.testGetPlayerPosition(); // プレーヤーの位置が正しく取得できるか?
return 0;
}
これを実行すると、次のようなデバッグ情報が出力されます。
デバッグ内容 ■■ 期待値は 100 でしたが、引数は -858993460 となり異なっています。
スレッド 0xE64 終了、終了コード 0 (0x0)。
assrtEquals関数がここで働いてくれています。テストの結果、期待した数値(適当に決めたものですが)と取得した値が異なっていたため、実行時エラーが返されました。実は、この実行時エラーを出させるというのが、次にのステップとして非常に大切なプロセスになります。こうすることで、実行時にエラーが発生する状況があることを認識し、これを解決すれば期待通りの結果になると明確な目標が立つのです。
このエラーから何を読み取るか?それは2つあります。まず、CPlayerクラスでGetPosition().xとした時のデフォルト値を決めていなかったことを認識できます。さらに、戻り値が異常値を指していることからメンバ変数の初期化をしていなかったことが読み取れます。これを解決しましょう。
プレイヤーのx座標のデフォルト位置は0にしましょう。この段階で、実はToDoリストを変更します。
ToDoリスト ・ Player1の位置の取得は(x, y, z)となる。・ Player1のデフォルト位置の座標の取得値は(x=0, y=0, z=0)となる。 ・ Player1の位置の変更はPlayer1の新しい位置になる
ToDoリストはより具体的になるなら変更してかまいません。上の例でもさらに具体的になりました。ではCPlayerクラスのコンストラクタで位置を初期化しましょう。
Player.cpp CPlayer::CPlayer()
{
m_Position.x = 0;
}
そして「実行」です。
デバッグ内容 ■■ 期待値は 100 でしたが、引数は 0 となり異なっています。
スレッド 0x828 終了、終了コード 0 (0x0)。
先ほどよりも発展しました。初期化が正しく行われています。さぁ、もう少し!
最後の詰めとして、CPlayerTest::testGetPlayerPosition関数内のassertEqualsの第1引数(期待値)を、自分が期待している0にセットします。
↑注目部分だけ抜き出しています
PlayerTest.h class CPlayerTest
{
void testGetPlayerPosition(){
assertEquals(0, Player1.GetPosition().x);
}
};
これで実行すると・・・。やりました!デバッガウィンドウには何もエラー表示されません!これで、ようやくテストも通るクラスを組むことができたのです。
B なぜこんな面倒なことをするのか?
ここまでのプロセスは、プログラムを組める人に「めんどくさ過ぎる!」と批判されそうです。でも、良く考えてみてください。こういうテストってテストフェーズで結局やることですよね。つまり、後でやるか先にやるかの違いだけで、手間は大して変わらないのです。ただ、大きく違うところがあります。それは、テストするクラスを作っている点です。普通テストは、テスト設計にあわせてプログラムを書いたりしますが、非常に煩雑になりがちです。しかし、TDDだと目標を達成するためのテストを先に作ってしまい、それをクラスにまとめてしまうので、必要最小限かつ最速で目標に向かっている事になります。もちろん、プログラムが進んでいくと追加・変更が出てくるでしょう。その時、すでにテストプログラムがあれば、変更後それを通してレッドシグナルになるかグリーンシグナルになるかを判定し、変更の影響を目で見るわけです。もっと良いことには、テストが実行形式になっているので、「テストの自動化」が簡単に達成できます。
「でも、適当に作っていったら、後で大変なことになる!」
それは正しいと思います。そのために、TDDには仕上げ作業があります。それがリファクタリングです。
C リファクタリングで最終仕上げ
ここまでの過程でテストに通るプログラムを作ったわけですが、ここで初めてプログラムをもう一度見直します。すると、「改善した方がいいな」と思う点がいくつか見えてきます。結果に影響することなく、プログラムをより綺麗に整理する。それがリファクタリングで、TDDの仕上げ作業になります。
Playerの位置を取得するクラスには、まだリファクタリングするところはなさそう(見えて来ないというのが正しい表現かもしれません)ですが、CTestPlayerクラスで使用したassertEquals関数について、変更したいことがあります。それは、この関数が色々な場面で使用可能であるので、CTestPlayerクラスから分離させたいということ、またエラーが無かった時に何も表示されないのは一抹の不安があるので、グリーンシグナルの文字列をデバッガウィンドウに表示させることです。
リファクタリングのポイントは「結果に影響を及ぼさないこと」です。よって、ちょっと慎重に考えます。assertEquals関数の分離については「グローバル関数化する」もしくは「クラスにまとめてしまう」の選択肢があります。オブジェクト指向なのですから、当然後者です。そこで、この関数はCTestCaseというクラスに分離し、CPlayerTestクラスはこのクラスからの派生クラスにするリファクタリングを行いましょう。
TestCase.h #include <stdio.h>
class TestCase
{
// 等比較関数
void assertEquals(int expect, int val){
if(expect != val){
char c[100];
sprintf(c, "■■ 期待値は %d でしたが、引数は %d となり異なっています。\n"
, expect, val);
OutputDebugString(c);
}
else
{
OutputDebugString("□□ Good !\n");
}
}
};
↑注目部分だけ抜き出しています
PlayerTest.h #include "TestCase.h"
class CPlayerTest : public CTestCase
{
void testGetPlayerPosition(){
assertEquals(0, Player1.GetPosition().x);
}
};
さぁ、実行してみましょう。問題なく動きますし、テスト結果も「Good !」になるはずです(ちなみに■とか□は、他のデバッグ情報と区別して結果を見やすくするために付けています)。
現段階で考えられるリファクタリングはこれで終了です。さて、ToDoリストを見るとこれで終わりではなくて、実際には「xyz座標を取得する」のが目的です。CPlayerTestクラスのtestGetPlayerPosition関数内にy座標およびz座標も取得するテストコードを追加して、コンパイルエラーを出してから、それに対処するコードを追加して行けば、きっとみんな同じコードに行き着くと思います。
TDDは結局、テストコードをきっちり書けることが必要になります。慣れれば楽しくなってくる。それがTDDだと私は思います。YAGNIの原則、そしてTDDで、STG製作に挑戦です!!