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

第1回テスト“だけ”使ってコードを再現するのは難しい?

プロローグ

読者の方で、次のように思っていらっしゃる方は、どれくらいいるでしょうか。

  • 「いちばん重要な財産はコードであり、万一コードを失ってしまったら、元と同じ品質のコードをもう一度書くのはとても大変だ」

筆者も長年このように思っていました。では、もし以下のように考えたらどうでしょう。

「いちばん重要な財産はテストであり、万一コードをすべて失ってしまったとしても、テストが無事なら元と同じ品質のコードをもう一度書くことができる」

今までとずいぶん違う考え方ですね。いろんな声が聞こえてきそうです。

  • 「コメントはどうするんだ」
  • 「テストが複雑すぎて保守できなくなったらどうするんだ」

ごもっともです。テスト駆動開発は万能ではありません。うまく適用できない場面もあり、このときは従来どおりのやり方が必要です。たとえば、デバイス制御(あるタイミングでI/Oポートを叩くとか)などは、保守可能なテストを書くのが割に合わない分野です(この疑問こそが、筆者が長年TDDをやってこなかった理由でもあります⁠⁠。

でも、もし「いちばん重要な財産がテストだったら」にちょっとでも興味があるなら、別の考え方に触れてみるのもいい刺激になるかも知れません。本稿では、以前筆者が別の記事で取り上げたコードをテスト駆動ベースに移行していき、どのようなメリット・デメリットがあるのかを見ていこうと思います。よろしくお付き合いください。

ブックレット作成プログラム

では最初に、⁠入力されたPDFのページを、中綴じブックレット用に並び替えて出力する」というPHPプログラムを作ってみましょう。具体的には、図1のようにページ順を編集します。2面づけ・両面で印刷し、2つに折ってホチキスで綴じれば、ブックレットのできあがりです。難しいプログラムではありませんが、正しく並び替えられるかどうかちょっとテストしたい内容ですね。

図1 ブックレットのページ順
図1 ブックレットのページ順

このテストで使うのは、run.phpというスクリプトですリスト1⁠。こんなに短くて役に立つのかと思われるかも知れませんが、これだけです。毎回入力してもいいくらいの長さですね。

リスト1 テスト実行スクリプト(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("target.php");

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";

?>

asserteq()というのは、2つの引数が同じかどうかをテストする関数です。ひとまず、同じでなければエラーを表示して実行が止まるようにしました。

run.phpは、途中でtarget.phpをrequire()しています。これは、run.phpを起動すればテスト実行、target.phpを起動すれば通常の実行と、簡単に切り替えられるようにするためです。私個人は、run.phpの引数にテストするスクリプトのファイル名を渡すようにして使っています。最後に、⁠test」で始まるメソッドを順番に呼び出しています。

一方、テストされるtarget.phpでは、pdfのページをオブジェクトにして配列変数に入れます。これにはZendFramework(後述)を使います。この配列変数を入れ替えることで、ページ順を編集します。つまり、テストのイメージとしては、こんな感じになります。

  • 最初のページ順は "a b c d e f g h"
  • ブックレット順に並び替え
  • ページ順が"h a b g f c d e"になっているかテスト

そうすると、ページオブジェクトの配列を簡単に確認できる必要があります。こういうときは、テスト用のページオブジェクトを用意するのが簡単です。このオブジェクトは、⁠a」「b」といった、ページの名前を保持します。

そして、ページオブジェクトを保持しているpdfオブジェクトにもテスト用オブジェクトを作ります。こちらは、保持しているページの名前を連結して「a b c」のようにして返します。これで、テストが簡単にできるようになります。また、ページ数が4の倍数でないときのために、空白のページを付け加える機能も用意します(空白のページの名前は「@」にしました⁠⁠。こうしてできたのが、リスト2です。

リスト2 テスト用オブジェクト(target.php)
<?php

class   dummy_pdfpage {
        var     $name = "";
        function        dummy_pdfpage($name = "none") {
                $this->name = $name;
        }
}


class   dummy_pdf {
        var     $pages;
        function        dummy_pdf() {
                $this->pages = array();
        }
        function        &newPage() {
                $page =& new dummy_pdfpage("@");
                return $page;
        }
        function        pagelist() {
                $array = array();
                foreach ($this->pages as $key => $dummy)
                        $array[] = $this->pages[$key]->name;
                
                return implode(" ", $array);
        }
}

?>

さっそくページの並べ替えのテストをしたいところですが、このテスト用オブジェクトに不具合があったら、ページ順の並べ替えのテストにも支障が出てしまいます。本当に正しく動くのか、ちょっとテストしたいですよね。

では、簡単にテストしてみましょう。テスト用のpdfクラスの中に、test1()というメソッドを作ります。このメソッドはテスト専用で、通常の動作時には呼び出されません。ここで、以下の処理を行います。

  • pdfオブジェクトを生成。
  • リストが""であることをテスト。
  • 「a」というページを追加。
  • リストが"a"であることをテスト。
  • 「b」というページを追加。
  • リストが"a b"であることをテスト。
  • newPage()を呼び出して、空のページを追加。
  • リストが"a b @"であることをテスト。
  • もう一度newPage()を呼び出して、空のページを追加。
  • リストが"a b @ @"であることをテスト。

これでうまくいけば、ページ順の入れ替えの処理も安心して書けますね。プログラムとしては、リスト3のようになります。ここではnew dummy_pdf()として新しいオブジェクトを作りましたが、今回はコンストラクタに引数もなく、途中で再初期化もしないので、$thisを使っても構いません。

リスト3 テストを行うメソッド(target.php)
class	dummy_pdf {

....

        function        test1() {
                $pdf =& new dummy_pdf();
                asserteq("", $pdf->pagelist());
                $pdf->pages[] =& new dummy_pdfpage("a");
                asserteq("a", $pdf->pagelist());
                $pdf->pages[] =& new dummy_pdfpage("b");
                asserteq("a b", $pdf->pagelist());
                $pdf->pages[] =& $pdf->newPage();
                asserteq("a b @", $pdf->pagelist());
                $pdf->pages[] =& $pdf->newPage();
                asserteq("a b @ @", $pdf->pagelist());
        }
}

では、実行してみましょう。

$ php 
.....
test successed.
$

asserteq()が呼ばれて成功すると「.」が1つ表示されます。表示を見ると、5つのテストか実行されたことがわかります。これで、安心してページ順を並び替えるという仕事に入ることができます。

まずテストを書く

さて、先ほどは「コードを書く⁠⁠→⁠テストを書く⁠⁠→⁠テストを実行する」という順番で進めました。ここで、冒頭の「いちばん重要な財産はテストであり、万一コードをすべて失ってしまったとしても、テストが無事なら元と同じ品質のコードをもう一度書くことができる」を思い出してください。さっきのテストだけで、元のコードを書き直せるでしょうか。筆者なら、ほかにドキュメント類がほしいところです。

そこでテスト駆動開発では、コードを書く前にテストを書き、テストを実行してエラーになるのを確認します。本当にこれで効果があるのか、みなさん疑問だと思いますが、せっかくですからどんな感じになるのか、見ていきましょう。

練習として、さきほどのテスト用のpdfオブジェクトに、指定したページをまとめて追加する機能を用意してみます。pagelist()では"a b c"のような値が得られました。そこで"a b c"の文字列をコンストラクタに渡すと、最初からこれらのページが作られるようにしてみましょう。最初にテストを書きますリスト4⁠。

リスト4 ページの一括生成の機能のテスト(target.php)
class	dummy_pdf {

....

        function        test2() {
                $pdf =& new dummy_pdf("a b c");
                asserteq("a b c", $pdf->pagelist());
                $pdf->pages[] =& new dummy_pdfpage("d");
                asserteq("a b c d", $pdf->pagelist());
                $pdf->pages[] =& $pdf->newPage();
                asserteq("a b c d @", $pdf->pagelist());
        }
}

実行してみましょう。

$ php run.php
.....
Expected: "a b c"
Found: ""
----
test failed.
$

しっかりエラーになりました。期待通り、ページが生成されていないためエラーになっているのがわかります。

あとは、ページを生成する処理を追加するだけですリスト5⁠。

リスト5 ページの一括生成処理(target.php)
class	dummy_pdf {
        var     $pages;
        function        dummy_pdf($s = "") {
                $this->pages = array();
                foreach (explode(" ", $s) as $name)
                        $this->pages[] =& new dummy_pdfpage($name);
        }

....

}

実行すると、さっそくテストに失敗しています。しかも、今回のテストとは別のところです。

$ php run.php
.
Expected: "a"
Found: " a"
----
test failed.
$

"a"と比較しているテストは1ヵ所だけですから、すぐに見つかります。これはどういうことかというと、引数なしのときにはdummy_pdf("")として呼び出されますが、これによってページ名""のページが生成されてしまったのが原因です。筆者の凡ミスなのですが、こういう問題を早期に見つけ出せるのは、テスト駆動の楽なところです。修正しましょうリスト6⁠。

リスト6 ページの一括生成処理の修正(target.php)
class	dummy_pdf {
        var     $pages;
        function        dummy_pdf($s = "") {
                $this->pages = array();
                if ($s != "")
                        foreach (explode(" ", $s) as $name)
                                $this->pages[] =& new dummy_pdfpage($name);
        }

....

}

もう一度実行すると、8つのテストが全部成功したことがわかります。

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

ページ順の編集

では、本番のページ順の編集です。ページを並び替えるのはpdfpagesというクラスです。テストの方法は、コンストラクタにダミーのpdfオブジェクトを渡し、makebooklet()メソッドを呼びます。そして、さきほどと同じ方法で、ページの順番をテストします。では、早速テストを書いてみましょうリスト7⁠。もちろん、みなさんが思いついたテストを追加しても構いません。また、当たり前ですがテスト対象のメソッドがないとエラーになります。そこで、何もしないメソッドを追加してあります。

リスト7 テストを最初に書く(target.php)
class	pdfpages {
        function        pdfpages(&$pdf) {
        }
        function        makebooklet() {
        }
}


class   pdfpages_test {
        function        test1() {
                $pdf =& new dummy_pdf("a b c d");
                $p =& new pdfpages($pdf);
                $p->makebooklet();
                asserteq("d a b c", $pdf->pagelist());
                
                $pdf =& new dummy_pdf("a b c d e");
                $p =& new pdfpages($pdf);
                $p->makebooklet();
                asserteq("@ a b @ @ c d e", $pdf->pagelist());
                
                $pdf =& new dummy_pdf("a b c d e f");
                $p =& new pdfpages($pdf);
                $p->makebooklet();
                asserteq("@ a b @ f c d e", $pdf->pagelist());
                
                $pdf =& new dummy_pdf("a b c d e f g");
                $p =& new pdfpages($pdf);
                $p->makebooklet();
                asserteq("@ a b g f c d e", $pdf->pagelist());
                
                $pdf =& new dummy_pdf("a b c d e f g h");
                $p =& new pdfpages($pdf);
                $p->makebooklet();
                asserteq("h a b g f c d e", $pdf->pagelist());
        }
}

注意点ですが、pdfpagesのコンストラクタは引数としてpdfオブジェクトを受け取ります。しかし、run.phpでは、引数なしでコンストラクタを呼びだしています。このため、pdfpagesの中にテストを書くと、引数なしでコンストラクタが呼ばれてエラーが発生してしまいます。これを避けるため、pdfpagesの中にはテストを置かず、別のクラスにテストを定義しています。

ちなみにPHP5以降では、pdfpages(&$pdf = null)のように、リファレンスの仮引数の省略時に、デフォルト値をnullにするといったことが可能で、この場合はテストをクラスの中に置くことができます。

実行すると、順番が元のままであるため、エラーになっています。makebooklet()の中で何もしていないので、当然ですね。

$ php run.php
........
Expected: "dabc"
Fonud: "abcd"
----
test failed.
$

では、makebooklet()の中を書いてみますリスト8⁠。お時間のある方は、自分で書いてテストを通してみるのもおすすめです。

リスト8 makebookletを書く(target.php)
class	pdfpages {
        var     $pdf = null;
        function        pdfpages(&$pdf) {
                $this->pdf =& $pdf;
        }
        function        &newblankpage() {
                $page =& $this->pdf->newPage();
                return $page;
        }
        function        makebooklet() {
                while ((count($this->pdf->pages) % 4))
                        $this->pdf->pages[] =& $this->newblankpage();
                
                $newpages = array();
                $left = count($this->pdf->pages) - 1;
                $right = 0;
                
                while ($right < $left) {
                        $newpages[] =& $this->pdf->pages[$left--];
                        $newpages[] =& $this->pdf->pages[$right++];
                        $newpages[] =& $this->pdf->pages[$right++];
                        $newpages[] =& $this->pdf->pages[$left--];
                }
                $this->pdf->pages = $newpages;
        }
}

実行すると、今度はテストに成功します。

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

あとは、PDFを扱う部分だけです。これには、冒頭で書いたようにZend Frameworkを使います。ダウンロードして展開したら、ソース中のパスを合わせてください。

Zend Framework
URL:http://framework.zend.com/

run.phpから呼ばれているときは、pdfの処理を飛ばさないといけません。そこで、asserteq()が定義されているかどうかを確認し、定義されていればクラス定義のみを実行し、pdf処理を行わないようにしましたリスト9⁠。

リスト9 pdf処理を含んだコード(target.php)
<?php

class   dummy_pdfpage {
        var     $name = "";
        function        dummy_pdfpage($name = "none") {
                $this->name = $name;
        }
        function        getWidth() {
                return 1;
        }
        function        getHeight() {
                return 1;
        }
        function        drawLine() {
        }
}


class   dummy_pdf {
        var     $pages;
        function        dummy_pdf($s = "") {
                $this->pages = array();
                if ($s != "")
                        foreach (explode(" ", $s) as $name)
                                $this->pages[] =& new dummy_pdfpage($name);
        }
        function        &newPage() {
                $page =& new dummy_pdfpage("@");
                return $page;
        }
        function        pagelist() {
                $array = array();
                foreach ($this->pages as $key => $dummy)
                        $array[] = $this->pages[$key]->name;
                
                return implode(" ", $array);
        }
        function        test1() {
                $pdf =& new dummy_pdf();
                asserteq("", $pdf->pagelist());
                $pdf->pages[] =& new dummy_pdfpage("a");
                asserteq("a", $pdf->pagelist());
                $pdf->pages[] =& new dummy_pdfpage("b");
                asserteq("a b", $pdf->pagelist());
                $pdf->pages[] =& $pdf->newPage();
                asserteq("a b @", $pdf->pagelist());
                $pdf->pages[] =& $pdf->newPage();
                asserteq("a b @ @", $pdf->pagelist());
        }
        function        test2() {
                $pdf =& new dummy_pdf("a b c");
                asserteq("a b c", $pdf->pagelist());
                $pdf->pages[] =& new dummy_pdfpage("d");
                asserteq("a b c d", $pdf->pagelist());
                $pdf->pages[] =& $pdf->newPage();
                asserteq("a b c d @", $pdf->pagelist());
        }
}


class   pdfpages {
        var     $pdf = null;
        function        pdfpages(&$pdf) {
                $this->pdf =& $pdf;
        }
        function        &newblankpage() {
                if (count($this->pdf->pages) <= 0)
                        die("no page.\n");
                $p =& $this->pdf->pages[0];
                $page =& $this->pdf->newPage($p->getWidth().":".$p->getHeight());
                $page->drawLine(0, 0, 0, 0);    # avoid PDF error.
                return $page;
        }
        function        makebooklet() {
                while ((count($this->pdf->pages) % 4))
                        $this->pdf->pages[] =& $this->newblankpage();
                
                $newpages = array();
                $left = count($this->pdf->pages) - 1;
                $right = 0;
                
                while ($right < $left) {
                        $newpages[] =& $this->pdf->pages[$left--];
                        $newpages[] =& $this->pdf->pages[$right++];
                        $newpages[] =& $this->pdf->pages[$right++];
                        $newpages[] =& $this->pdf->pages[$left--];
                }
                $this->pdf->pages = $newpages;
        }
}


class   pdfpages_test {
        function        test1() {
                $pdf =& new dummy_pdf("a b c d");
                $p =& new pdfpages($pdf);
                $p->makebooklet();
                asserteq("d a b c", $pdf->pagelist());
                
                $pdf =& new dummy_pdf("a b c d e");
                $p =& new pdfpages($pdf);
                $p->makebooklet();
                asserteq("@ a b @ @ c d e", $pdf->pagelist());
                
                $pdf =& new dummy_pdf("a b c d e f");
                $p =& new pdfpages($pdf);
                $p->makebooklet();
                asserteq("@ a b @ f c d e", $pdf->pagelist());
                
                $pdf =& new dummy_pdf("a b c d e f g");
                $p =& new pdfpages($pdf);
                $p->makebooklet();
                asserteq("@ a b g f c d e", $pdf->pagelist());
                
                $pdf =& new dummy_pdf("a b c d e f g h");
                $p =& new pdfpages($pdf);
                $p->makebooklet();
                asserteq("h a b g f c d e", $pdf->pagelist());
        }
}


if (!function_exists("asserteq")) {
        set_include_path(get_include_path().PATH_SEPARATOR."/download/ZendFramework-1.11.6/library/");
        require_once('Zend/Loader/Autoloader.php');
        Zend_Loader_Autoloader::getInstance();
        
        ini_set("max_execution_time", "300");
        ini_set("memory_limit", "1024M");
        
        $pdf =& Zend_Pdf::load("input.pdf");
        
        $p =& new pdfpages($pdf);
        $p->makebooklet();
        
        $pdf->save("output.pdf");
}

?>

また、pdf処理とテストの共通化に必要なメソッドをいくつか定義しました。

今回はユーザインターフェースについては相当手を抜いたので、input.pdfのページ順を入れ替えてoutput.pdfに出力するだけですが、うまく動いたでしょうか。コマンドライン引数にファイル名を指定できるようにするなど、いろいろ改良して使ってみてください。

エピローグ

いかがでしたでしょうか。実は、テスト駆動開発では、本当は今回のようにまとめてテストを書くやり方はしません。⁠いちばん重要な財産はテストであり、万一コードをすべて失ってしまったとしても、テストが無事なら元と同じ品質のコードをもう一度書くことができる」ためにはどんなことをやるのか、次回から取り上げていきたいと思います。よろしくお願いします。

おすすめ記事

記事・ニュース一覧