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

第3回テスト駆動は“オブジェクト指向にあとから変更”にも対応できるのか

大変お待たせしました。遅ればせながら本年もよろしくお願いします。さっそく、テスト駆動開発の続きに取り組んでいきましょう。

筆者はテスト駆動開発に興味を持ってはいたのですが、ずっと取り込んできませんでした。コンセプトは魅力的だと思うのですが、半信半疑なところもあったためです。

  • 複雑なものも扱えるのか
  • 生産性が本当に上がるのか
  • 部分をテストしても全体のテストのかわりにはならないのでは?
  • テスト可能にすると、ソースが読みにくくなるのでは?
  • 最初に構造をしっかり設計しないと、あとから変更できないのでは?

特に最後の「最初に構造をしっかり設計しないといけない」は、筆者がオブジェクト指向言語を使うようになって感じた点です。オブジェクト指向を使わないよりも大きなものが作れるのですが、そのかわり当初の仮定が間違っていたときの軌道修正が難しいと感じました。

そこで、今回はこういった疑問について検証していこうと思います。どうぞよろしくお付き合いください。

わざと設計なしでプログラムを書く

使用するのはPHPでアセンブラを作ってみたという記事です。このコードはオブジェクト指向を使っていないため、完全に書き直す必要があります。しかも今回は、初期の設計を重視せず、わざと思いつきでコードを書いていきました。あとからの大変更も何度も発生しています。このため、先述の疑問に答えるにはぴったり、というわけです。

さて、オブジェクト指向化するといっても、何かゴールを設定しないと方針が決められません。今回はアセンブラですので、以下の問題の解消をゴールにしてみました。

  • エラー発生時の行番号表示
  • 前方参照をきれいに実装できる

「前方参照」「後方参照」とも言い、まだ出現していないラベルを参照する、という意味です。つまり、ソースの上の方に「goto main」とあり、ソースの下の方に「main:」とあるようなケースです。この場合、上から1行ずつ見ていくと、⁠goto main」の段階では「main」のラベルは解決できないことになります。これをエレガントに解決したい、というわけです。

また、今回はテストエンジンのrun.phpも機能を追加しました。

  • テストに失敗した場合でも、次のテストメソッドを実行する(Exceptionを使用しないバージョン)
  • クラスでない、普通の関数もテスト可能
  • オブジェクトのメンバを少ない記述でテストできる機能(php5のマジックメソッドを使用)

オブジェクトのテストだけ、簡単に説明します。assertisa("class1", $obj)は、$objがclass1(またはそのサブクラス)であることをテストします。このとき、テスト用のオブジェクトが得られ、メンバ変数や、引数を持たないメソッドの返り値のテストができるようになっています。

 $obj =& new class1();
 assertisa("class1", $obj)
  ->var1_eq(1)
  ->method1_eqf(2);

var1_eq(1)のように_eqがついていると、$obj->var1が1であるかどうかテストします。つまり、asserteq(1, $obj->var1)と同じということです。そして、$thisを返しますので、続けて別のテストを行うことができます。method1_eqf(2)のように_eqfがついていると、$obj->method1()が2であるかどうかテストします。これを利用して、いくつものメンバ変数やメソッドを見通しよくテストすることができます。

紙面の都合上、テストエンジンの解説は次回を予定しています。複雑なものではありませんので、興味のある方はソースをご覧ください。

行番号のためのクラスを作る

さっそくですが、ここからはどのようにソースの構造(アーキテクチャ)が変化していったかにご注目ください。前回のソースはこちらです。今回は、何度も変更が行われたinputlineとinputbufferまわりに注目します。これは、行番号を扱うために用意したクラスです。

おさらいになりますが、テスト駆動開発では、最初にテストを1つ書きます。そして、そのテストをクリアするための、最低限のコードを書くのでしたね。したがって、最初の状態のソースはこうなります。

リスト1
class   inputbuffer {
        function        test1() {
                $obj =& new inputbuffer();
                assertisa("inputbuffer", $obj)
                        ->iseof_eqf(1);
        }
}

空のinputbufferを作り、iseof()をテストしています。ソースコードの処理では、inputbufferから行を読み取ることで、行番号の情報も得られるようにしよう、という魂胆です。iseof()がなくてエラーになるのでコードを書きます。

リスト2
class   inputbuffer {
        function        iseof() {
                return 1;
        }
        function        test1() {
                $obj =& new inputbuffer();
                assertisa("inputbuffer", $obj)
                        ->iseof_eqf(1);
        }
}

しかし、ここまで書いたところで、必要なのは行番号だと気づきました。includeなどを考えると、ファイル名も必要になる可能性があります。行をオブジェクトにした方がいいかも知れません。そこでinputbufferは書きかけのままにして、inputlineの作業に取りかかりました。

以下のように、テストを書いていきます。

リスト3
class   inputline {
        var     $line = "";
        function        inputline($line = "") {
                $this->line = $line;
        }
        function        gets() {
                return $this->line;
        }
        function        test1() {
                $obj =& new inputline("test");  /* (1) */
                assertisa("inputline", $obj)
                        ->gets_eqf("test");
                $obj =& new inputline("test2"); /* (2) */
                assertisa("inputline", $obj)
                        ->gets_eqf("test2");
        }
}

(1)で、コンストラクタに渡した文字列が、gets()で取得できることを確認します。もちろんエラーになるので、インチキコードを書いてテストを通します。(2)で別の文字列を使ってテストし、インチキコードを直します。

リスト4
class   inputline {
        var     $line = "";
        var     $locate = "";
        function        inputline($line = "", $locate = "(unknown)") {
                $this->line = $line;
                $this->locate = $locate;
        }
        function        gets() {
                return $this->line;
        }
        function        getlocate() {
                return $this->locate;
        }
        function        test1() {
                $obj =& new inputline("test");
                assertisa("inputline", $obj)
                        ->gets_eqf("test");
                $obj =& new inputline("test2");
                assertisa("inputline", $obj)
                        ->gets_eqf("test2");
                
                $obj =& new inputline("test3", "line#1"); /* (3) */
                assertisa("inputline", $obj)
                        ->gets_eqf("test3")
                        ->getlocate_eqf("line#1");
                $obj =& new inputline("Hello TDD", "line#2"); /* (4) */
                assertisa("inputline", $obj)
                        ->gets_eqf("Hello TDD")
                        ->getlocate_eqf("line#2");
                
                $obj =& new inputline("test");   /* (5) */
                assertisa("inputline", $obj)
                        ->gets_eqf("test")
                        ->getlocate_eqf("(unknown)");
        }
}

(3)で行番号の情報をテストします。今回は、好きな文字列を渡せるようにしました。(4)で別の行番号をテストします。(5)では、行番号を指定しなかった場合のディフォールト値をテストしています。

トークンへの分解の方法

さて、ここで問題があります。行をラベル・オペコード・オペランドなどに分けるのは、どこでやるのが良いでしょう。inputlineの中に機能を追加するべきでしょうか。それとも、読みとったあとでやるのがいいでしょうか。

これもいくつか考えられますが、⁠指定した区切り文字まで読み取る」という機能をinputlineに用意することにしました。このために、まずgets()で文字列を取りだすと、文字バッファをクリアするように変更しました。

リスト5
class   inputline {
        var     $line = "";
        var     $locate = "";
        function        inputline($line = "", $locate = "(unknown)") {
                $this->line = $line;
                $this->locate = $locate;
        }
        function        gets() {
                $ret = $this->line;
                $this->line = "";
                return $ret;
        }
        function        ungets($s = "") {
                $this->line = $s.$this->line;
        }
        function        getlocate() {
                return $this->locate;
        }
        function        test1() {

/* .... */

                $obj =& new inputline("this is a test.");  /* (1) */
                assertisa("inputline", $obj)
                        ->gets_eqf("this is a test.")
                        ->gets_eqf("");
                
                $obj->ungets("abc");    /* (2) */
                assertisa("inputline", $obj)
                        ->gets_eqf("abc");
                
        }
}

(1)で、2回目のgets()が""になることをテストします。テストに失敗しますので、コードを追加します。(2)で、ungets()をテストしています。gets()を呼ぶと文字列が失われてしまうようになりましたので、もう一度読めるようにするには、ungets()を利用します。

リスト6
class   inputline {
        var     $line = null;
        var     $locate = "";
        function        inputline($line = "", $locate = "(unknown)") {
                $this->line = $line;
                $this->locate = $locate;
        }
        function        gets() {
                $ret = $this->line;
                $this->line = null;
                return $ret;
        }
        function        ungets($s = "") {
                $this->line = $s.$this->line;
        }
        function        getlocate() {
                return $this->locate;
        }
        function        test1() {

/* .... */

                $obj =& new inputline("this is a test.");
                assertisa("inputline", $obj)
                        ->gets_eqf("this is a test.")
                        ->gets_eqf(null);  /* (4) */
                
                $obj->ungets("abc");
                assertisa("inputline", $obj)
                        ->gets_eqf("abc");
                
                $obj->ungets("def");  /* (3) */
                $obj->ungets("ghi");
                assertisa("inputline", $obj)
                        ->gets_eqf("ghidef")
                        ->gets_eqf(null);  /* (4) */
                
        }
}

(3)では、2回ungetした場合の動作をテストしています。(4)は、空行と、gets()後を区別するため、gets()の返り値をnullに変更したときのテストです。(1)のテストにも変更が発生しました。

続いて、getchunk()を作ります。getchunk()は、どう処理するか決められなくて、何度も変更を行った箇所です。テストが増えるようすを見ていきましょう。

リスト7
class	inputline {

/* .... */

        function        getchunk($terminator = "", $terminator2 = "") {
                $line = $this->gets();
                $ret = "";
                for (;;) {
                        if ($line == "")
                                return null;
                        $c = substr($line, 0, 1);
                        $line = substr($line, 1);
                        if (strpos($terminator, $c) !== FALSE)
                                break;
                        $ret .= $c;
                }
                while ($line != "") {
                        $c = substr($line, 0, 1);
                        if (strpos($terminator, $c) === FALSE)
                                break;
                        $line = substr($line, 1);
                }
                $this->ungets($line);
                return $ret;
        }

/* .... */

        function        test1() {

/* .... */

                $obj =& new inputline("label: decfsz INDF, 1 ; comment");  /* (1) */
                asserteq("label", $obj->getchunk(": "));
                asserteq("decfsz INDF, 1 ; comment", $obj->gets());  /* (2) */
                
        }
}

(1)では、典型的なアセンブラの1行を入力して、先頭のラベルが取得できることをテストしています。もちろんエラーになりますので、コードを書いてテストを通します。(2)で、続くオペコードが取得できるかどうかテストしています。

この段階のgetchunk()では、2つの引数を受け付けます。1つ目は区切り文字で、2つ目は区切り文字のあとの文字を読み飛ばすための指定です。これはアセンブラの構文によるものです。ラベルは必ず先頭に書き、ラベルがない場合はタブを入れる、ラベルの末尾に「:」があってもよい、という処理のため、⁠label-タブ-opcode」の場合は"label"が、⁠タブ-opcode」の場合は""が読みとられるようにしたかったためです。

リスト8
class	inputline {

/* .... */

        function        getchunk($terminator = "", $eolenable = 0) {  /* (4) */
                $line = $this->gets();
                $ret = "";
                for (;;) {
                        if ($line == "") {
                                if (($eolenable))
                                        return $ret;
                                $this->ungets($ret);
                                return null;
                        }
                        $c = substr($line, 0, 1);
                        $line = substr($line, 1);
                        if (strpos($terminator, $c) !== FALSE)
                                break;
                        $ret .= $c;
                }
                while ($line != "") {
                        $c = substr($line, 0, 1);
                        if (strpos(" \t", $c) === FALSE)
                                break;
                        $line = substr($line, 1);
                }
                $this->ungets($line);
                return $ret;
        }

/* .... */

        function        test1() {

/* .... */

                $obj =& new inputline("label: decfsz INDF, 1 ; comment");
                asserteq("label", $obj->getchunk(": \t"));  /* (5) */
                asserteq("decfsz INDF, 1 ; comment", $obj->gets());
                
                $obj =& new inputline("label decfsz INDF, 1 ; comment");  /* (3) */
                asserteq("label", $obj->getchunk(": \t")); /* (5) */
                asserteq("decfsz INDF, 1 ; comment", $obj->gets());
                
                $obj =& new inputline(" decfsz INDF, 1 ; comment");
                asserteq("", $obj->getchunk(": \t"));   /* (5) */
                asserteq("decfsz INDF, 1 ; comment", $obj->gets());
                
                $obj =& new inputline("label:");
                asserteq("label", $obj->getchunk(": \t"));  /* (5) */
                asserteq("", $obj->gets());
                
                $obj =& new inputline("label");
                asserteq("label", $obj->getchunk(": \t", 1));  /* (5) */
                asserteq(null, $obj->gets());
                
        }
}

(3)では、ラベルに「:」がない場合、ラベルそのものがない場合、ラベルだけでコード部がない場合などをテストしています。コードを書き、区切り文字なしに行末に到達しても正常と見なすかを指定しないといけないことに気づきます。(4)でメソッドの引数を変更して対応し、それに合わせて(5)で今までのテストを修正しています。

リスト9
class	inputline {

/* .... */

        function        getchunk($terminator = null, $eolenable = 0) {
                if ($terminator === null) {
                        $terminator = " \t";
                        $eolenable = 1;
                }
                
                $line = $this->gets();
                $ret = "";
                for (;;) {
                        if ($line == "") {
                                if (($eolenable))
                                        return $ret;
                                $this->ungets($ret);
                                return null;
                        }
                        $c = substr($line, 0, 1);
                        $line = substr($line, 1);
                        if (strpos($terminator, $c) !== FALSE)
                                break;
                        $ret .= $c;
                }
                while ($line != "") {
                        $c = substr($line, 0, 1);
                        if (strpos(" \t", $c) === FALSE)
                                break;
                        $line = substr($line, 1);
                }
                $this->ungets($line);
                return trim($ret);
        }

/* .... */

        function        test1() {

/* .... */

                $obj =& new inputline("label: decfsz INDF, 1 ; comment");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("decfsz", $obj->getchunk()); /* (6) */
                asserteq("INDF, 1 ; comment", $obj->gets());
                
                $obj =& new inputline("label: clrwdt");  /* (7) */
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("clrwdt", $obj->getchunk());
                asserteq(null, $obj->gets());
                
                $obj =& new inputline("label: decfsz INDF, 1 ; comment");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("decfsz", $obj->getchunk());
                asserteq("INDF", $obj->getchunk(","));
                asserteq("1 ; comment", $obj->gets());
                
                $obj =& new inputline("label: decfsz INDF"); /* (8) */
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("decfsz", $obj->getchunk());
                asserteq(null, $obj->getchunk(","));
                asserteq("INDF", $obj->gets());
                
                $obj =& new inputline("label\tdecfsz\tINDF\t,\t1\t;\tcomment");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("decfsz", $obj->getchunk());
                asserteq("INDF", $obj->getchunk(","));
                asserteq("1", $obj->getchunk());
                asserteq(";\tcomment", $obj->gets());
                
        }
}

(6)で、区切り文字を省略した場合の動作をテストしています。(7)は、引数がないアセンブラ命令をテストしています。

(8)で、コメントがない場合、空白でなくてタブの場合などをテストしています。このテスト、区切り文字の前に空白などがあった場合に取り除く処理を追加しています。

実は、入力まわりの変更はここで終わりません。変更を続けても、テスト駆動開発が機能するのかどうか、見てみましょう。

繰り返されるinput系の修正

やりかけだった、inputbufferの続きです。

リスト10
class   inputbuffer {
        var     $linelist;
        function        inputbuffer() {
                $this->linelist = array();
        }
        function        iseof() {
                if (count($this->linelist) == 0)
                        return 1;
                return 0;
        }
        function        addcontents($contents, $name = "(unknown)", $startnumber = 1) {
                $a = array();
                foreach (split("\r|\n|\r\n", $contents) as $s)
                        $a[] =& new inputline($s, $name." #".($startnumber++));
                array_splice($this->linelist, 0, 0, $a);
        }
        function        &getline() {
                $obj =& $this->linelist[0];
                array_shift($this->linelist);
                return $obj;
        }
        function        test1() {
                $obj =& new inputbuffer();
                assertisa("inputbuffer", $obj)
                        ->iseof_eqf(1);
                
                $obj->addcontents("test1\ntest2\n"); /* (1) */
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("test1");
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("test2");
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("");
                
                $obj->addcontents("test3\ntest4\n");
                asserteq(0, $obj->iseof());
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("test3");
                asserteq(0, $obj->iseof());
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("test4");
                asserteq(0, $obj->iseof());
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("");
                asserteq(1, $obj->iseof());
                
                $obj->addcontents("test5\ntest6\n", "testfile.txt");
                assertisa("inputline", $obj->getline())
                        ->getlocate_eqf("testfile.txt #1");
                assertisa("inputline", $obj->getline())
                        ->getlocate_eqf("testfile.txt #2");
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("");
                
                $obj->addcontents("test7\ntest8");
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("test7");
                $obj->addcontents("test9\ntest10");  /* (2) */
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("test9");
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("test10");
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("test8");
                asserteq(1, $obj->iseof());
                
        }
}

(1)以降で、ファイル名と行番号を、先に作ったinputlineのlocateに設定しています。

いったんaddcontents()のコードを書きますが、順番が逆になるのに気づいて、(2)で修正しました。これは、読み込んだ行がincludeなどであった場合、現在行の直後に読み込んだファイルが入るというのを想定したものです。

さて、コメントの処理は、inputlineの後で行っていました。しかし、コメントの除去はあちこちで行われるため、commentinputlineで一括して行う方式に変更しました。

リスト11
class   commentlessinputline extends inputline {
        function        commentlessinputline($line = "", $locate = "(unknown)") {
                if (($pos = strpos($line, ";")) !== FALSE)
                        $line = substr($line, 0, $pos);
                parent::inputline($line, $locate);
        }
        function        test1() {
                $obj =& new commentlessinputline("test");
                assertisa("inputline", $obj)
                        ->gets_eqf("test");
                $obj =& new commentlessinputline("test2");
                assertisa("inputline", $obj)
                        ->gets_eqf("test2");
                
                $obj =& new commentlessinputline("test3", "line#1");
                assertisa("inputline", $obj)
                        ->gets_eqf("test3")
                        ->getlocate_eqf("line#1");
                $obj =& new commentlessinputline("Hello TDD", "line#2");
                assertisa("inputline", $obj)
                        ->gets_eqf("Hello TDD")
                        ->getlocate_eqf("line#2");
                
                $obj =& new commentlessinputline("test");
                assertisa("inputline", $obj)
                        ->gets_eqf("test")
                        ->getlocate_eqf("(unknown)");
                
                $obj =& new commentlessinputline("this is a test.");
                assertisa("inputline", $obj)
                        ->gets_eqf("this is a test.")
                        ->gets_eqf(null);
                
                $obj->ungets("abc");
                assertisa("inputline", $obj)
                        ->gets_eqf("abc");
                
                $obj->ungets("def");
                $obj->ungets("ghi");
                assertisa("inputline", $obj)
                        ->gets_eqf("ghidef")
                        ->gets_eqf(null);
                
                $obj =& new commentlessinputline("label: decfsz INDF, 1 ; comment");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("decfsz INDF, 1 ", $obj->gets());
                
                $obj =& new commentlessinputline("label decfsz INDF, 1 ; comment");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("decfsz INDF, 1 ", $obj->gets());
                
                $obj =& new commentlessinputline(" decfsz INDF, 1 ; comment");
                asserteq("", $obj->getchunk(": \t"));
                asserteq("decfsz INDF, 1 ", $obj->gets());
                
                $obj =& new commentlessinputline("label:");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq(null, $obj->gets());
                
                $obj =& new commentlessinputline("label");
                asserteq("label", $obj->getchunk(": \t", 1));
                asserteq(null, $obj->gets());
                
                $obj =& new commentlessinputline("label: decfsz INDF, 1 ; comment");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("decfsz", $obj->getchunk());
                asserteq("INDF, 1 ", $obj->gets());
                
                $obj =& new commentlessinputline("label: clrwdt");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("clrwdt", $obj->getchunk());
                asserteq(null, $obj->gets());
                
                $obj =& new commentlessinputline("label: decfsz INDF, 1 ; comment");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("decfsz", $obj->getchunk());
                asserteq("INDF", $obj->getchunk(","));
                asserteq("1 ", $obj->gets());
                
                $obj =& new commentlessinputline("label: decfsz INDF");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("decfsz", $obj->getchunk());
                asserteq(null, $obj->getchunk(","));
                asserteq("INDF", $obj->gets());
                
                $obj =& new commentlessinputline("label\tdecfsz\tINDF\t,\t1\t;\tcomment");
                asserteq("label", $obj->getchunk(": \t"));
                asserteq("decfsz", $obj->getchunk());
                asserteq("INDF", $obj->getchunk(","));
                asserteq("1", $obj->getchunk());
                asserteq(null, $obj->gets());
                
        }
}


class   inputbuffer {

/* .... */

        function        addcontents($contents, $name = "(unknown)", $startnumber = 1) {
                $a = array();
                foreach (split("\r|\n|\r\n", $contents) as $s)
                        $a[] =& new commentlessinputline($s, $name." #".($startnumber++));
                array_splice($this->linelist, 0, 0, $a);
        }

/* .... */

}

commentlessinputlineを作ったあと、inputlineのテストをすべてcommentlessinputlineに修正しています。

その後、いろいろ迷ったのですが、commentlessinputlineをやめて、commentlessinputbufferを採用しました。これは、もしcommentlessでないinputlineが必要になったときに、inputbufferの中を書き換えないといけないのを嫌ったためです。

リスト12
class   commentlessinputbuffer extends inputbuffer {
        function        addcontents($contents, $name = "(unknown)", $startnumber = 1) {
                $a = array();
                foreach (split("\r|\n|\r\n", $contents) as $s) {
                        if (($pos = strpos($s, ";")) !== FALSE)
                                $s = substr($s, 0, $pos);
                        $a[] =& new inputline($s, $name." #".($startnumber++));
                }
                array_splice($this->linelist, 0, 0, $a);
        }
        function        test1() {
                $obj =& new commentlessinputbuffer();
                $obj->addcontents("line;comment");
                assertisa("inputline", $obj->getline())
                        ->gets_eqf("line");
        }
}

コメントが取り除かれることを検証するテストを書いたあと、先ほどのcommentlessinputlineの修正を元に戻します。これまでのテストも、コメント処理まわりを取り除き、クラス名の変更に対応しています。

このあとアセンブラのメインの処理を書いていくのですが、そのときにまた修正が必要になりました。

入力にエラーがあってアセンブル処理を継続できない場合は、エラーを表示する必要があります。しかし、エラーを画面に表示してdie()などで終了してしまうと、テストが継続できなくなります。テストでは「きちんとエラーになって処理が止まるか」のようなことを検証しないといけません。画面に表示されたり、ましてや終了されてしまうとテストに差し障りがあります。

リスト13
class	inputbuffer {
        var     $linelist;
        var     $message = null;

/* .... */

        function        getmessage() {
                if (count($this->message) /* (1) */
                asserteq(null, $obj->getmessage());
                $obj->addmessage("error1");
                asserteq("error1", $obj->getmessage());
                $obj->addmessage("error2"); /* (2) */
                asserteq("error1\nerror2", $obj->getmessage());
                
        }
}

そこで、(1)でinputbufferにaddmessage()というメソッドを用意します。エラーが発生したら、ここにメッセージを書き込みます。(2)で、エラーが2回以上発生した場合は改行で連結されるようにしています。

アセンブル処理本体に渡るのは、inputbufferではなくinputlineです。

リスト14
class   inputline {
        var     $line = null;
        var     $locate = "";
        var     $parent = null;

/* .... */

        function        setparent(&$parent) {
                $this->parent =& $parent;
        }
        function        addmessage($message = "") {
                if ($this->parent === null)
                        return;
                $this->parent->addmessage($message." in ".$this->locate);
        }
        function        test1() {

/* .... */

                $obj =& new inputline(); /* (1) */
                asserteq(null, $obj->parent);
                $obj2 =& new inputline();
                $obj->setparent($obj2);
                asserteq($obj2, $obj->parent);
                
                $obj =& new inputline();
                $obj->addmessage("dummy");
                
        }
}


class   inputbuffer {

/* .... */

        function        test1() {

/* .... */

                $obj =& new inputbuffer();
                asserteq(null, $obj->getmessage());
                $obj->addmessage("error1");
                asserteq("error1", $obj->getmessage());
                $obj->addmessage("error2");
                asserteq("error1\nerror2", $obj->getmessage());
                
                $obj->addcontents("test9", "testfile2.txt");  /* (2) */
                $line =& $obj->getline();
                asserteq($obj, $line->parent);
                $line->addmessage("error3");
                asserteq("error1\nerror2\nerror3 in testfile2.txt #1", $obj->getmessage());
                
                $obj->addcontents("test10\ntest11", "testfile3.txt");
                $line =& $obj->getline();
                $line =& $obj->getline();
                $line->addmessage("error4");
                asserteq("error1\nerror2\nerror3 in testfile2.txt #1\nerror4 in testfile3.txt #2", $obj->getmessage());
                
        }
}

(1)で、inputline->parentにinputbufferがセットされるようにします。(2)で、inputlineにもaddmessage()を用意し、ここでファイル名や行番号を付加して、inputbuffer->addmessage()を呼ぶようにします。

さて、こうして不十分な設計による典型的な混乱のサンプルが得られました。これを元に、テスト駆動開発の疑問点を見ていくことにしましょう。

テスト駆動開発は、設計変更に耐えられるのか

ここまでの流れから、最初に書いたポイントを検証してみることにしましょう。

複雑なものも扱えるのか?

→ 言語の構文解析は複雑。

inputまわりを中心に見ていきましたので、とりわけ複雑というわけではありません。しかし、getchunk()などは、ある要求を満たすために処理を変更すると、他の要求がうまく処理できなくなったりする典型だと考えられます。こういうものは、テストがあることで安心して変更することができます。

より複雑なものについては、次回で取り上げようと思います。

生産性が本当に上がるのか?

→ (これは未検証)

生産性については、数字はないのですが、凡ミスがすぐに見つかるのは助かります。その一方で、原因究明に時間がかかるものもありました(恥ずかしい話ですが、$objを使うべきところでうっかり$thisを使ってしまい、どちらも同じクラスだったので他にエラーが出なかったのです)。

また、インチキコードを書いてテストをいったん通すプロセスは、無駄なので書かずに済ませたいという気持ちが払拭しきれませんでした。

部分でテストしても全体の代わりになるのか?

→ アセンブラとして機能するのかをチェック。

inputbufferからinputlineを含んだテストをするなど、ある程度はユニットをまたいだテストを行っています(inputlineの変更に制約が出るなど、このやり方には悪い点もあります)。また解説はしていませんが、ソースからバイナリが正しく作られることも、同じ方法で自動的に確認しています。

全体のテストが不要になるわけではありませんが、うまく適用できる分野においては、全体のテストが楽になると思います。

テスト可能にするとソースが読みにくくなるのでは?

→ 前回のソースと比較。

テストできるようにしたため、以前と違う書き方になった、という部分は、たしかに存在します。

たとえば、今回はsetparent()を使用しました。inputline()に$parentを渡すように変更すると、今までのテストを多数変更しないといけなくなり、この記事のリストがとても長くなってしまうからです。もしテストの変更がなければ、コンストラクタに$parentを渡していたと思います。これが、テスト駆動開発で、後付けで機能追加したことによる違いの1つということになります。

また、従来は直接エラーメッセージを表示していた部分を、inputlineやinputbufferを通して出力することで、エラーメッセージをテストすることができるようにしています。これはテストに必要だから、というのもありますが、メッセージに行番号などを付加する処理を1箇所にまとめられるという利点もあります。

ですので、少なくとも今回の範囲では、テスト可能にしたためソースが読みにくくなったとは言えないように思います。

最初に構造をしっかり設計しないと、あとから変更できないのでは?

→ 何度も大変更を行った。

これが本題です。今回は、設計をせずに、行きあたりばったりでコードを書いていきました。もちろん、phpでアセンブラというのは、以前に扱った題材ですから、難易度は低くなっていると考えられます。

先ほどのsetparent()のように、変更にあたって気を遣った部分もあります。一方で、コメントを取り除く処理を別のところに移したり、エラーメッセージに行番号を表示する仕組みを何度も変更したりもしています。感覚的には、変更によってソースが管理不能になったり、再利用不能になるということは起きていません。

問題は、テストを書くコストに対して、変更のしやすさという見返りが十分かどうかですが、これについては明確な結論は出せていません。

もっと複雑なプログラム

というわけで、テスト駆動開発の疑問をすべて解消するというわけにはいきませんでした。そこで、このテーマは次回も引き続き取り上げようと思います。ご期待ください。

なお、今回作成したプログラム全体については、以下からダウンロードいただけます。オペランドに式が使えない、includeをサポートしていない、出力がダンプなど、picasm.phpと完全に同じではありませんが、実際に動かしたりテストを実行することができます。

おすすめ記事

記事・ニュース一覧