テストを“いちばん重要な財産”と考えると見えるもの

第2回本当にコードを失っても大丈夫なのか、確かめてみよう

前回⁠これまでの記事で取り上げたコードを、テスト駆動ベースに移行していく」と書きました。さっそく、取り組んでいきましょう。その中で「コードをすべて失ってしまったとしても、テストが無事なら元と同じ品質のコードをもう一度書くことができる」かどうかを確かめていきましょう。

今回取り上げるプログラム

今回取り上げるのはPHPでNゲージ模型を自由自在に動かそうです。第1回のリスト1は、以下のようになっていました。

リスト1 ⁠Nゲージ」第1回リスト1より
#! /usr/local/bin/php
<?php
$url = "http://192.168.0.100/";

file_get_contents($url."?00=C");
file_get_contents($url."?01=D");
file_get_contents($url."?02=D");
file_get_contents($url."?03=C");

?>

どうでしょう。ちょっとテスト駆動に向かないのではないか、と筆者は感じました。みなさんも同様に思われましたでしょうか。なぜこのソースをテスト駆動にしようと思わないかの分析は後半で行うことにして、次のソースを見ることにしましょう。

こちらは、同じ連載の第2回のソースですリスト2⁠。先頭にクラス定義がありますので、この部分をテスト駆動開発に移行してみることにしましょう。

リスト2 ⁠Nゲージ」第2回リスト2より
#! /usr/local/bin/php
<?php
$url = "http://192.168.0.100/";

class	rail {
    var    $number;
    function    rail($number = "00") {
        $this->number = $number;
    }
    function    drive($speed = 0) {
        global    $url;
        
        if ($speed >= 0)
            $command = substr("ABCDEF", min($speed, 5), 1);
        else
            $command = substr("abcdef", min(-$speed, 5), 1);
        file_get_contents($url."?".$this->number."=".$command);
    }
    function    sense() {
        global    $url;
        
        return file_get_contents($url."?".$this->number."=S") + 0;
    }
}

#
# |---r4---|---r3---|---r2---|---r1---|---r0---|
#

$r0 =& new rail("00");
$r1 =& new rail("01");
$r2 =& new rail("02");
$r3 =& new rail("03");
$r4 =& new rail("04");

for (;;) {
    $r0->drive(2);
    $r1->drive(3);
    $r2->drive(3);
    $r3->drive(2);
    while ($r4->sense() == 0)
        ;
    $r0->drive(0);
    $r1->drive(0);
    $r2->drive(0);
    $r3->drive(0);
    
    $r4->drive(-2);
    $r3->drive(-3);
    $r2->drive(-3);
    $r1->drive(-2);
    while ($r0->sense() == 0)
        ;
    $r4->drive(0);
    $r3->drive(0);
    $r2->drive(0);
    $r1->drive(0);
}

?>

今回使用するテストエンジンは、リスト3です。前回のものと1行だけ違い、読み込むファイル名をコマンドライン引数で指定できるようにしました。

リスト3 run.php
#! /usr/local/bin/php
<?php

function    asserteq($expected, $found) {
    if ($expected === $found) {
        printf(".");
        return;
    }
    printf("\nexpected: \"%s\"\nfound: \"%s\"\n----\ntest failed.\n", $expected, $found);
    die();
}

require(@$argv[1]);

foreach (get_declared_classes() as $classname) {
    foreach (get_class_methods($classname) as $methodname) {
        if (!ereg("^test", $methodname))
            continue;
        $obj =& new $classname();
        $obj->$methodname();
    }
}
print"\ntest successed.\n";

?>

また、PHPのincludeやrequireでは、returnで以降の取り込みを中止することができます。そこで以降のテスト対象のコードでは、asserteq関数が定義されていたら、クラス定義の直後でreturnしてテストエンジンに戻るようにしました。

最初は1つのテストから

まず、コンストラクタのテストです。後半で必要になるので、テスト対象のクラスであるrailを継承して、テスト用のクラスを用意しました。まず、コンストラクタの引数が省略されたときのテストを書きます。オリジナルのコード(リスト2)を見ると省略時は"00"が渡されることになっているので、これをテストします。

前回はテストをまとめて書きましたが、テスト駆動開発では本当は、小さなテストを1つだけ書きます。そして、このテストが失敗するのを確認してから、テストを通すためだけのコードを書きます。まず、テストはリスト4です。

リスト4 テストを1つだけ書く
#! /usr/local/bin/php
<?php
$url = "http://localhost/gh/rail/simrail.php5";

class   rail {
}


class   testrail extends rail {
        function        test1() {
                $obj =& new testrail();
                asserteq("00", $obj->number);
        }
}


if (function_exists("asserteq"))
        return;


#
# |---r4---|---r3---|---r2---|---r1---|---r0---|
#

$r0 =& new rail("00");

〈以下省略〉

実行すると、きちんとテストに失敗します(というか、メンバ変数が未定義なのでPHPの警告が出ます⁠⁠。

では、コードを書きましょう。テストを通すための最低限のコードは、以下のようになりました。

        class   rail {
                var     $number = "00";
        }

「ふざけてるんじゃないか」と思われるかもしれませんが、これがテスト駆動開発のやり方です。少なくとも、こうすることで「コードをすべて失ってしまったとしても、テストが無事なら元と同じ品質のコードをもう一度書くことができる」に一歩近づくというのは、このあとを読めば同意していただけると思います。

ちなみに、実行すると、きちんと成功します。

$ php run.php rail21.php
.
test successed.
$

さすがに、このコードではあんまりですので、テストを追加します。コンストラクタに引数を渡しましょう。これも、一度に1つだけです。テストが失敗することを確認してください。

                $obj =& new testrail("12");
                asserteq("12", $obj->number);

このテストを通すためのコードを書きます。やっと、まともになりました。

class   rail {
        var     $number;
        function        rail($number = "00") {
                $this->number = $number;
        }
}

次はdrive()メソッドですが、これはちょっと工夫が必要です。file_get_contents()という関数を使って、別のサーバにアクセスしているからです。もちろん、テスト用のサーバを用意してもいいのですが、ユニットテストの範囲を外れますし、なるべく身軽にしたいと思いました。

そこでgethttp()というメソッドを用意し、テスト用のクラスではこれを偽物に差し替えます(専門的にはモックといいます)。今回はできるだけ簡単にし、渡されたURLをメンバ変数に格納するだけにします。gethttp()が呼び出されたあと、正しくメンバ変数がセットされているかをテストする、というやり方です。

これも、まず簡単なテストを書いて、失敗させますリスト5⁠。

リスト5 gethttp()のテスト
#! /usr/local/bin/php
<?php
$url = "http://localhost/gh/rail/simrail.php5";

class   rail {
        var     $number;
        function        rail($number = "00") {
                $this->number = $number;
        }
}


class   testrail extends rail {
        function        test1() {
                $obj =& new testrail();
                asserteq("00", $obj->number);
                $obj =& new testrail("12");
                asserteq("12", $obj->number);
                
                $obj->gethttp("http://a.b/");
                asserteq("http://a.b/", $obj->gethttpurl);
                
        }
}


if (function_exists("asserteq"))
	return;


#
# |---r4---|---r3---|---r2---|---r1---|---r0---|
#

$r0 =& new rail("00");

〈以下省略〉

テストを通すためのコードを書きます。

        var     $gethttpurl;
        function        gethttp($url) {
                $this->gethttpurl = $url;
        }

ちなみに、これとは逆に、テスト側が指定した値をモックから返したいときは、テスト側で返す値を変数にセットしておき、モック側ではその変数の値をreturnに指定するのが簡単なやり方です。また、もし1つの関数内で何度もgethttp()が呼ばれる場合、今回のやり方では変数が上書きされてしまいます。そこで、配列変数に格納するとよいでしょう。

本来のrailクラスにもgethttp()メソッドを作り、こちらはオリジナル通りにfile_get_contents()を呼ぶようにしておきます。

        function        gethttp($url) {
                return file_get_contents($url);
        }

では、このgethttp()メソッドを使って、drive()メソッドを書いていきます。例によって、1つだけです。テストが失敗することを確認します(リスト6)。

リスト6 drive()のテスト
#! /usr/local/bin/php
<?php
$url = "http://localhost/gh/rail/simrail.php5";

class   rail {
        var     $number;
        function        rail($number = "00") {
                $this->number = $number;
        }
        function        gethttp($url) {
                return file_get_contents($url);
        }
}


class   testrail extends rail {
        var     $gethttpurl;
        function        gethttp($url) {
                $this->gethttpurl = $url;
        }
        function        test1() {
                $obj =& new testrail();
                asserteq("00", $obj->number);
                $obj =& new testrail("12");
                asserteq("12", $obj->number);
                
                $obj->gethttp("http://a.b/");
                asserteq("http://a.b/", $obj->gethttpurl);
                
                $obj =& new testrail("34");
                $obj->drive();
                asserteq("http://a.b/?34=A", $obj->gethttpurl);
                
        }
}


if (function_exists("asserteq"))
        return;


#
# |---r4---|---r3---|---r2---|---r1---|---r0---|
#

$r0 =& new rail("00");

〈以下省略〉

コード本体も、まずはシンプルな方法でテストを通します。

        function        drive() {
                $this->gethttp("http://a.b/?34=A");
        }

次のテストでは、コンストラクタに渡していたパラメータを変えます。

                $obj =& new testrail("56");
                $obj->drive();
                asserteq("http://a.b/?56=A", $obj->gethttpurl);

そしてコードでは、パラメータを参照するように修正します。

        function        drive($speed = 0) {
                $this->gethttp("http://a.b/?{$this->number}=A");
        }

今度はメソッドのパラメータです。これも、最初は値のチェックをしません。

                $obj->drive(1);
                asserteq("http://a.b/?56=B", $obj->gethttpurl);

コードが追いかけます。

        function        drive($speed = 0) {
                $command = substr("ABCDEF", $speed, 1);
                $this->gethttp("{$this->baseurl}?{$this->number}={$command}");
        }

ところで、オリジナル(リスト2)ではグローバル変数の$urlを参照していたのですが、これは必要に応じて書き変わると考えられるため、テスト用に使うのは好ましくありません。そこで、メンバ変数にURLを保存しておき、コンストラクタ内でグローバル変数からコピーすることにしました。テスト用クラスのコンストラクタでは、これを上書きするわけです。

この作業については、失敗するテストを書けないですが、これまでのテストが今までと変わらずに通ることは自動的にテストされます。というわけでリスト7になりました。ついでに、さきほどのdrive()に、範囲ギリギリのテストも追加してあります。

リスト7 リファクタリングの後
#! /usr/local/bin/php
<?php
$url = "http://localhost/gh/rail/simrail.php5";

class   rail {
        var     $baseurl;
        var     $number;
        function        rail($number = "00") {
                global  $url;
                
                $this->baseurl = $url;
                $this->number = $number;
        }
        function        gethttp($url) {
                return file_get_contents($url);
        }
        function        drive($speed = 0) {
                $command = substr("ABCDEF", $speed, 1);
                $this->gethttp("{$this->baseurl}?{$this->number}={$command}");
        }
}


class   testrail extends rail {
        var     $gethttpurl;
        function        testrail($number = "00") {
                parent::rail($number);
                $this->baseurl = "http://a.b/";
        }
        function        gethttp($url) {
                $this->gethttpurl = $url;
        }
        function        test1() {
                $obj =& new testrail();
                asserteq("00", $obj->number);
                $obj =& new testrail("12");
                asserteq("12", $obj->number);
                
                $obj->gethttp("http://a.b/");
                asserteq("http://a.b/", $obj->gethttpurl);
                
                $obj =& new testrail("34");
                $obj->drive();
                asserteq("http://a.b/?34=A", $obj->gethttpurl);
                
                $obj =& new testrail("56");
                $obj->drive();
                asserteq("http://a.b/?56=A", $obj->gethttpurl);
                
                $obj->drive(0);
                asserteq("http://a.b/?56=A", $obj->gethttpurl);
                
                $obj->drive(1);
                asserteq("http://a.b/?56=B", $obj->gethttpurl);
                
                $obj->drive(5);
                asserteq("http://a.b/?56=F", $obj->gethttpurl);
                
        }
}


if (function_exists("asserteq"))
        return;


#
# |---r4---|---r3---|---r2---|---r1---|---r0---|
#

$r0 =& new rail("00");

〈以下省略〉

続いて、範囲外の値のテストを追加します。

                $obj->drive(6);
                asserteq("http://a.b/?56=F", $obj->gethttpurl);

そして、コードでは範囲チェックを追加します。

        function        drive($speed = 0) {
                $command = substr("ABCDEF", min($speed, 5), 1);
                $this->gethttp("{$this->baseurl}?{$this->number}={$command}");
        }

この続きは宿題にするとして、せっかくですから、ここまでの流れを振り返ってみましょう。

テスト駆動開発はなぜ直感に反することをするのか

ここまでの流れで、テスト駆動開発の感じをつかんでいただけたと思うのですが、いかがでしたでしょうか。明らかに直感に反するところがあると感じられたのではないでしょうか。そこで、直感に反する部分について、分析してみることにしましょう。

まず「最初は、テストを通すだけのシンプルなコードを書く」です。これまで、最初に書いたコードは、どれも役に立たないインチキなコードでした。これには意味があるのでしょうか。

1つ利点として言えるのは、書いたコードは必ずテストされる、ということです。もし、最初から長いコードを書いてしまうと、対応するテストを書き忘れてしまうかもしれません。今回のやり方では、テストを通すのに必要な最低限のコードしか書きませんでした。ですからどのコードも、いずれかのテストに必要だから書かれた、ということが確信できます。

ほかにも、テストは一度に1つだけ書く、というのも納得がいかないかもしれません。同じ種類の仕事をまとめてやった方がはかどる、という方もいらっしゃるでしょう。これも、今の(インチキな)コードを改善するのに必要なテストだけを書くことで、コードをまとめて書かないようにするしかけと捉えることができます。

これらのやり方は、最初に書いた「万一コードをすべて失ったとしても、テストが無事なら元と同じ品質のコードをもう一度書くことができる」にも貢献します。せっかくですから、いま書いたテストを見て確認してみましょう。

                $obj =& new testrail();
                asserteq("00", $obj->number);
                $obj =& new testrail("12");
                asserteq("12", $obj->number);

最初の2つのテストは、コンストラクタをテストしています。渡されたパラメータがnumberというメンバ変数に格納され、省略時は"00"であることが、テストからわかります。

                $obj->gethttp("http://a.b/");
                asserteq("http://a.b/", $obj->gethttpurl);

次はgethttp()ですが、これは以下のモックを見れば、わかります。

        var     $gethttpurl;
        function        testrail($number = "00") {
                parent::rail($number);
                $this->baseurl = "http://a.b/";
        }
        function        gethttp($url) {
                $this->gethttpurl = $url;
        }

テスト用に"http://a.b/"を用意していることがわかりますし、通常実行のときは、ユーザが設定したURLになることが想像できます。

                $obj =& new testrail("34");
                $obj->drive();
                asserteq("http://a.b/?34=A", $obj->gethttpurl);

drive()メソッドも、今までの知識から、簡単にコードを復元することができます。

つまり、小さなテストを書いて、それを通すだけのコードを書く、という繰り返しにより、コードを書くのに必要な情報がすべてテストに書かれている状態になった、というわけです。

テスト駆動開発に向かないもの

冒頭に「なぜリスト1をテスト駆動にしようと思わないかの分析は後半で行う」と書きました。最後にこれをやってみましょう。

筆者は、テストが必要な場面は3つあると思っています。

1つめは「間違えたまま、すぐには見つからない可能性があるもの」です。前回のmakebooklet()などは、書いて一度動かしただけでは心配がありますから、いくつかのケースでテストしたくなります。独自のソートアルゴリズムを書いた場合なども同様ですね。

2つめは「将来、保守されるもの」です。保守の場合、たとえば機能追加されたときに今までの部分の動作が変わっていないかとか、あるいは別のところが変わったために呼び出し方法を変えたいが問題ないか、といった不安が出てきます。テストがあると、こういった不安を減らすことができます。また、テストが通っている限り、テストとコードは一致していると考えられますが、これはコメントにはない長所です。

3つめは「再利用されるもの」です。コードだけがいろいろなものに再利用されると、年月を経たときに何が正しい動作なのがわからなくなることがあります。このときにテストがあると、バージョンごとの意図などを読み取ることができます。

リスト1の場合、あいにくどれにも当てはまりません。やっていることは明確で、間違いがあったら実行したときにすぐに気付くと考えられます。それに、保守することを考えたら、むしろテストは保守を困難にする可能性があります。再利用も、ちょっと考えられません。

テストの保守性については、別の視点もありますので、次回で取り上げたいと思います。ぜひ楽しみにしてください。

おすすめ記事

記事・ニュース一覧