Skip to content.

Sections
Personal tools
You are here: Home » コミュニティ » masarl memorial » homepage3.nifty.com » masarl » article » junit » JUnit 実践講座 - オブジェクトの文字列表現を活用しよう

JUnit 実践講座 - オブジェクトの文字列表現を活用しよう

Document Actions

JUnit 実践講座 -
オブジェクトの文字列表現を活用しよう

2002/08/12 石井 勝

目次

はじめに

JUnit で一番よく使われるのは assertEquals メソッドではないでしょうか.

assertEquals(期待値, 実測値);

このメソッドを呼ぶと,期待値と実測値が同じときにテストが成功し,異なるときにテストが失敗します.要するに,このメソッドが扱っているのは 「2つのものが同じかどうかを決定する問題」です.

わかりやすいテストケースを書くためには,この assertEquals について注意を払う必要があります.具体的には

  • 比較するオブジェクトの切り出し方
  • 比較するオブジェクトの型の選び方

に気をつけることにより,テストケースを書くコストを減らし,エラー箇所に速くたどりつくことができます.ここでは, assertEquals をどう扱うかについて解説しましょう.

エンティティオブジェクトの assertEquals

業務アプリケーションでは,データの格納を目的としたエンティティオブジェクトを扱うことが多いです.まず,エンティティオブジェクトで assertEquals をどう扱うかについて説明しましょう.

サンプルプログラム - Book クラス

ここに,本を表す Book クラスがあったとします.Book クラスは ISBN コードを引数にとり,データベースからその本のデータを取得します.

Book book = new Book("4-7561-3687-7");

assertEquals("Rubyを256倍使うための本 極道編", book.getTitle());
assertEquals("助ちゃん", book.getAuthor());
assertEquals("アスキー", book.getPublisher());
assertEquals(120, book.getPageCount());
assertEquals(1200, book.getPrice());
assertEquals("B6", book.getSize());

このテストコードにはたくさんの assertEquals が含まれています.一般に,エンティティオブジェクトは属性の getter の数が多く,テストケースも上のようになってしまいがちです.この方法の問題点は,エラーが発生してもどういう種類のエラーかすぐわからない,ということが挙げられます.例えば,JUnit が次のようなエラーメッセージを出力したとしましょう.

There was 1 failure:
1) test(BookTest)junit.framework.AssertionFailedError: expected:<120> but was:<125>
    at BookTest.test(BookTest.java:17)
    at BookTest.main(BookTest.java:35)

確かに BookTest.java の 12行目で失敗したことはわかるので,そこからソースにジャンプして getPageCount メソッドが失敗した,ということはすぐわかります.しかし,次に書かれている getPrice メソッド,getSize メソッドはテストされていないし,getPageCount メソッドを修正しても次の assertEquals でまた失敗してしまうかもしれません.この例だと簡単すぎてわからないかもしれませんが,メソッドの依存関係が複雑で,1つの修正が多くのメソッドに影響するような場合にはエラー箇所の特定は難しくなります.要するに,

細かい単位で assertEquals を使うと「木を見て森を見ず」の状態になり,エラー箇所がわかりにくくなる

ということです.

比較するオブジェクトを大きくとろう

ここでテスト対象となっているのは Book オブジェクトそのものです.もし比較する単位を Book クラスの属性ではなく Book オブジェクト全体にすれば,どの部分が間違っているか一目で判定できるでしょう.そこで, Book クラスのデフォルトコンストラクタと setter がすでに用意されていると仮定し,それらを使ってテストケースを書き直してみることにしましょう.

Book expected = new Book();

expected.setISBN("4-7561-3687-7");
expected.setTitle("Rubyを256倍使うための本 極道編");
expected.setAuthor("助ちゃん");
expected.setPublisher("アスキー");
expected.setPageCount(120);
expected.setPrice(1200);
expected.setSize("B6");

assertEquals(expected, new Book("4-7561-3687-7"));

Book クラスの equals メソッドと toString メソッドが正しく定義されているのなら,エラーメッセージは次のようになるでしょう.

There was 1 failure:
1) testByObject(BookTest)junit.framework.AssertionFailedError: expected:<ISBN=4-7561-3687-7
Title=Rubyを256倍使うための本 極道編
Author=助ちゃん
Publisher=アスキー
PageCount=120
Price=1200
Size=B6
> but was:<ISBN=4-7561-3687-7
Title=Rubyを256倍使うための本 極道編
Author=助ちゃん
Publisher=アスキー
PageCount=125
Price=1200
Size=A5
>
    at BookTest.testByObject(BookTest.java:34)
    at BookTest.main(BookTest.java:51)

余計わかりにくくなりましたか? でも,JUnit の開発環境では, expected: <...> but was: <...> というエラーメッセージに書かれている期待値と実測値の diff を,コマンド一発でとれることが推奨されます( Tips: 期待値と実測値のdiffをとる 参照).この機能を使った diff 表示( Emacs の ediff コマンド )をご覧下さい.

diff 表示

これで全体の中のどの部分でエラーが起こっているか,だいぶわかりやすくなりましたね.

ところが,このように書き直されたテストコードには重大な問題があります.それは,assertEquals が呼ばれるとき,暗に Book クラスの equals メソッドを利用している,ということです.そのため,もしテストが通ったとしても,Book クラスの equals メソッドが正しいかどうかを別に検証しないといけません.また,この方法では仕様変更やリファクタリング等で equals メソッドが修正されたとき,通ってはいけないテストが通ってしまう可能性が十分あります.少なくとも equals メソッドと toString メソッドに相当するコードは,テストコード側で用意した方がいいでしょう.

Adapter パターンで修正してみる

ここでデザインパターンに詳しい方は,Adapter パターンを連想されたかもしれません.すなわち, Book クラスで equals メソッドと toString メソッドを用意するかわりに,テストコード側でインナークラス - BookAdapter を導入し,そこに equals メソッドと toString メソッドを用意するのです.

private static class BookAdapter
{
    private Book book; // 参照される Book オブジェクト

    BookAdapter(Book book)
    {
        this.book = book;
    }
    public boolean equals(Object other)
    {
        // Book オブジェクトが等しいかどうか判定する
    }
    public String toString()
    {
        // エラー箇所がすぐわかるような
        // Book オブジェクトの文字列表現を返す
    }
}

そして,テストコードの assertEquals の部分を次のように書き直します.

Book expected = new Book();

... (中略) ...

assertEquals(new BookAdapter(expected), new BookAdapter(new Book("4-7561-3687-7")));

こうすれば, assertEquals は Book クラスではなく BookAdapter クラスの equals メソッドを利用してくれます.これでテストコードが Book クラスの equals メソッドに依存することはなくなりました.さらに,エラーメッセージで出力される Book オブジェクトのフォーマットもテストコード側で自由に設定できますね.

以上でエンティティオブジェクトのテストコードの書き方は終わり…と行きたいところです.しかし,まだ満足なものとはいえません.というのも

  • Book クラスにデフォルトコンストラクタや setter が必要になる
  • BookAdapter クラスのようなインタークラスを書くとテストコードが複雑になる

からです.もっと簡単な方法はないでしょうか?

オブジェクトの文字列表現を活用しよう

実はここまでの説明は,僕の開発チームがたどってきたスタイルそのままなのです.なまじデザインパターンを知っていると必要以上に複雑なコードを書いてしまうことがよくありますね.そもそも Adapter パターンを持ち出したのが失敗でした. Adapter パターンを使わず,もっと単純な方法にしましょう.

まず,toString メソッドをインナークラスではなく単なるメソッドとして書きます.

/**
 * Book オブジェクトの文字列表現を返す
 */
private String bookToString(Book book)
{
    ...
}

次に,ここがポイントなのですが,assertEquals では Book オブジェクトではなく 文字列で比較する のです.

// 期待値を文字列で用意しておく
String[] expectedLines = {
    "ISBN=4-7561-3687-7",
    "Title=Rubyを256倍使うための本 極道編",
    "Author=助ちゃん",
    "Publisher=アスキー",
    "PageCount=120",
    "Price=1200",
    "Size=B6"
};

String expected = joinByLineSeparator(expectedLines);
// joinByLineSeparator メソッドは String 配列の間に改行を挿入し
// 連結した文字列を返すものとする

assertEquals(expected, bookToString(new Book("4-7561-3687-7")));

すなわち,

オブジェクトの文字列表現を使って assertEquals を利用できないか考える

ということです.比較する型として文字列を選んだ理由は,プログラミング言語の中で文字列が一番汎用性があり,柔軟な表現力を持つ扱いやすい型であるためです.こうすることで次のようなメリットが生まれます.

  • テストコードとエラーメッセージの可読性が増す
  • テストコード側で期待値を簡単に用意できるようになる
  • 一旦文字列に変換するため,期待値と実測値が同じ型である必要がない
  • 同じであることの定義を自由に調整できる

複雑なテストコードを書く場合,上の考え方が非常に有効になる場合があります.そのため,一旦 Book クラスから離れて別の例で解説しましょう.

サンプルプログラム - ディレクトリ階層を処理するプログラム

あるディレクトリ階層以下のファイルに対してなんらかの処理を行なうプログラムを考えてみます.このプログラムはディレクトリの中身を書き換え,ファイルをコピーしたり,削除をしたり,リネームしたりします.このとき,素直にテストケースを書くと次のようになってしまうでしょう.

// シナリオメソッドの実行
scenario();

// testdir/A.txt が存在し,中身は This is A.txt になっている
File fileA = new File("testdir/A.txt");
assertEquals(true, fileA.exists());
assertEquals("This is A.txt", fileRead(fileA));
// fileRead メソッドはファイルの中身を返すヘルパーメソッドとする

// testdir/subdir ディレクトリが存在する
File subdir = new File("testdir/subdir");
assertEquals(true, subdir.exists());
assertEquals(true, subdir.isDirectory());

// testdir/subdir1/B.txt が存在し,中身は This is B.txt になっている
File fileB = new File("testdir/subdir1/B.txt");
assertEquals(true, fileB.exists());
assertEquals("This is B.txt", fileRead(fileB));

// testdir/subdir1/C.txt が存在し,中身は This is C.txt になっている
File fileC = new File("testdir/subdir1/C.txt");
assertEquals(true, fileC.exists());
assertEquals("This is C.txt", fileRead(fileC));

細かい単位で assertEquals が書かれているため,非常に煩雑なテストコードになっています.エラーが起こった場合のデバッグも大変です.expected: <true> but was: <false> というエラーメッセージが出てもどこが問題なのかすぐにはわかりません.また,ディレクトリ内に余分なファイルが存在しないこともテストできていません.プログラムが実行されたとき,余分なファイルやディレクトリが生成されているかもしれません.つまり,このように書かれたテストコードには問題が多いのです.

ここは,考え方を 180 度変えてみましょう.

  • 比較するオブジェクトをもっと大きくとろう → テストするのはディレクトリそのもの
  • 比較するオブジェクトの文字列表現を考えよう → ディレクトリを文字列に変換するメソッドを作ろう

そこで,ディレクトリ名を引数にとりディレクトリの中身を文字列として表すメソッドを用意します.

/**
 * ディレクトリ内すべてのファイルを検索し,中身をごっそり文字列に変換したものを返す.
 * ここでは,各行を
 *
 *   検索されたファイル名=そのファイルの中身
 *
 * という形式で表し,ファイル名でソートしたもので十分.
 */
private String dirToString(String dirname)
{
    ...
}

assertEquals を使うところでは,ディレクトリの期待値と等価な文字列を用意し次のようにします.

scenario();

String[] expectedLines = {
    "testdir/A.txt=This is A.txt",
    "testdir/subdir/B.txt=This is B.txt",
    "testdir/subdir/C.txt=This is C.txt",
};

String expected = joinByLineSeparator(expectedLines);
assertEquals(expected, dirToString("testdir"));

修正前のテストコード と比較すると,ずいぶんすっきりしています.こうすれば,プログラムの処理結果としてそのディレクトリはどんな階層を持ち,各ファイルはどういう内容なのか一目瞭然です.もちろん複雑な処理は dirToString メソッドにあるわけですが,このメソッドをいろんなテストパターンで使うことにより,テストメソッドはテストパターンの特殊解のみに専念することができます.

同じであることの定義を調整する

文字列表現で比較すれば,assertEquals でどのレベルまで比較するかを簡単に調整することができます.

例えば,先ほどのディレクトリを処理するプログラムでファイルの中身だけでなく,ファイル権限もテストしなければならないとしましょう.その場合は dirToString メソッドが返す文字列にファイル権限をつければよいのです.

scenario();

String[] expectedLines = {
    "rw- testdir/A.txt=This is A.txt",
    "r-- testdir/subdir/B.txt=This is B.txt",
    "rw- testdir/subdir/C.txt=This is C.txt",
    "r-x testdir/subdir/D.exe=This is D.exe",
};

String expected = joinByLineSeparator(expectedLines);
assertEquals(expected, dirToString("testdir"));

ここでは各行の最初にファイル権限のフラグをつけ, r があれば読み込み可,w があれば書き込み可,x があれば実行可のファイルであるとしてテストケースが書かれています.

文字列変換メソッドの精度を上げながら開発する

さらに,同じであることの定義を調整しながら文字列変換メソッドの精度を上げていき,test a little, code a little のスタイルで開発を進めていくことができます.

例えば, Excel のワークブックを自動生成するプログラムを開発していくとしましょう( 実際には Ruby でこの手法を使っていましたが,例ということでお許しください).この場合は

  • 比較するオブジェクトの単位 ― Excel ワークブックそのもの
  • 文字列変換メソッド ― Excel ワークブックの文字列表現を求める

とすればいいですね.

まず,開発の第一歩はワークブックが存在するかどうかというレベルで十分です.そこで

private String workbookToString(String filename)
{
    // ファイル名: filename のワークブックが存在すれば "true", 
    // 存在しなければ "false" という文字列を返す
}

という文字列変換メソッドを用意し,テストメソッドを次のようにします.

public void test() throws Exception
{
    scenario();

    String expected = "true";
    assertEquals(expected, workbookToString("foo.xls"));
}

ここで,シナリオメソッドから呼ばれるプログラムは foo.xls というワークブックを作成するものとします.

次に実装コードに移って foo.xls というファイル名のワークブックを作成するようにします.めでたくテストが通るようになれば,今度は workbookToString メソッドの精度を上げてワークブックのシート数を返すようにします.

private String workbookToString(String filename)
{
    // ワークブック filneme が持つワークシートの数を返す
}
  
public void test() throws Exception
{
    scenario();

    // 自動生成されるシートの数は 3つ
    String expected = "3";
    assertEquals(expected, wookbookToString("foo.xls"));
}

再び実装コードに移り,foo.xls が 3つのシートを持つようにします.テストが通れば,今度はシート名を検証するテストにしましょう.

private String workbookToString(String bookname)
{
    // ワークブック filneme が持つシート名を順に CSV 形式で返す
}
  
public void test() throws Exception
{
    scenario();

    // 自動生成されるシート名は SheetA,SheetB,SheetC の順
    String expected = "SheetA,SheetB,SheetC";
    assertEquals(expected, wookbookToString("foo.xls"));
}

実装コードを修正して上のテストが通るようにした後は,今度は各シートの中身を文字列表現として表すテストを書いていけばいいのです(もっとも,シートの内容が複雑なら比較するオブジェクトの単位を小さくし,ワークシート単位でテストしていく方向を考えてもいいでしょう).このようにすれば,test a little, code a little で開発をスムーズに進めることができますね.

コレクションオブジェクトの assertEquals

今度は複数のエンティティオブジェクトからなるコレクションオブジェクトのテストについて解説しましょう.

サンプルプログラム - BookQuery クラス

再び Book クラスのサンプルに戻り, Book オブジェクトを検索するためのクラス - BookQuery クラスを考えてみます.このクラスは,キーワードを指定するとその言葉にマッチする Book オブジェクトを検索し,検索結果を java.util.List オブジェクトとして返すものとします.素直にテストコードを書けば以下のようになるでしょう.

BookQuery query = new BookQuery();

query.setKeyword("Ruby");
List bookList = query.find();
assertEquals(3, bookList.size());

Book book;

book = (Book)bookList.get(0);
assertEquals("0-201-71089-7", book.getISBN());
assertEquals("Programming Ruby", book.getTitle());

book = (Book)bookList.get(1);
assertEquals("4-7561-3687-7", book.getISBN());
assertEquals("Rubyを256倍使うための本 極道編", book.getTitle());

book = (Book)bookList.get(2);
assertEquals("4-7561-3709-1", book.getISBN());
assertEquals("Rubyを256倍使うための本 無道編", book.getTitle());

あるいは,後半の各 Book オブジェクトをテストする部分を換えて

assertEquals(new Book("0-201-71089-7"), (Book)bookList.get(0));
assertEquals(new Book("4-7561-3687-7"), (Book)bookList.get(1));
assertEquals(new Book("4-7561-3709-1"), (Book)bookList.get(2));

とする方法もありますね.しかし,どちらもよくないスタイルだということはこれまでの説明でわかっていただけたと思います.そこで,次のように考えます.

  • 比較するオブジェクトの単位 ― Book オブジェクトを要素にもつ List オブジェクト全体
  • 文字列変換メソッド ― Book オブジェクトを要素にもつ List オブジェクトの文字列表現を求める

今度は,さらにテストコード側に ExpectedBook クラスを導入しましょう.このクラスは,期待値として個々の Book オブジェクトを比較するために用いられます.

private static class ExpectedBook
{
    String isbn;
    String title;

    ExpectedBook(String isbn, String title)
    {
        this.isbn = isbn;
        this.title = title;
    }
}

ここでは, ExcpectedBook クラスの属性を ISBN コードとタイトルのみにしています.これは, BookQuery のテストという観点からは Book のキー項目とタイトルだけで比較すれば十分だろう,ということからきています.もし検索項目として著者名が含まれているなら,タイトルではなく著者名を持ってくる必要があるでしょう.

テストメソッドでは ExpectedBook クラスの配列でテストコードの期待値を用意します.つまり,次のようになります.

BookQuery query = new BookQuery();

query.setKeyword("Ruby");
List bookList = query.find();

ExpectedBook expectedBooks[] = {
    new ExpectedBook("0-201-71089-7", "Programming Ruby"),
    new ExpectedBook("4-7561-3687-7", "Rubyを256倍使うための本 極道編"),   
    new ExpectedBook("4-7561-3709-1", "Rubyを256倍使うための本 無道編"),
};

assertEquals(expectedBooksToString(expectedBooks), bookListToString(bookList));

つまり,テストメソッド内に直接期待値の文字列表現を書くのではなく,テストコードのクラスを対象とする文字列変換メソッドを用意するということです.ここでは expectedBooksToString メソッドがそれに相当します.話が前後しますが,次に expectedBooksToString と bookListToString メソッドの解説をしておきましょう.

まず,ExpectedBook クラスと Book クラスは型が違うので,文字列変換のレベルでは同じにしておく必要があります.そこで,個々の要素に対する文字列変換メソッドは次のようになります.

/**
 * ExpectedBook オブジェクトの文字列表現を求める
 */
private String expectedBookToString(ExpectedBook expectedBook)
{
    return "ISBN=" + expectedBook.isbn + "," + "Title=" + expectedBook.title;
}

/**
 * Book オブジェクトの文字列表現を求める
 */
private String bookToString(Book book)
{
    return "ISBN=" + book.getISBN() + "," + "Title=" + book.getTitle();
}

これら 2つのメソッドを利用して expectedBooksToString と bookListToString メソッドを実装します.diff 表示で見やすくするため,各要素を改行コードを区切った文字列表現にします(Java では改行の扱いが面倒なため,自作の StringPrintWriter クラスを利用しています.この手のクラスは標準で用意して欲しいところですね.)

/**
 * ExpectedBook オブジェクトの配列の文字列表現を求める
 */
private String expectedBooksToString(ExpectedBook expectedBooks[])
{
    StringPrintWriter w = new StringPrintWriter();
    for (int i = 0; i < expectedBooks.length; ++i) {
        w.println(expectedBookToString(expectedBooks[i]));
    }
    return w.toString();
}

/**
 * Book オブジェクトを要素にもつ List の文字列表現を求める
 */
private String bookListToString(List bookList)
{
    StringPrintWriter w = new StringPrintWriter();
    Iterator i = bookList.iterator();
    while (i.hasNext()) {
        w.println(bookToString((Book)i.next()));
    }
    return w.toString();
}

以上が期待値を表すクラスにも文字列変換を行なうタイプのテストコードの書き方でした.テストメソッドに文字列表現を直接書くのが困難な場合は,このスタイルを検討してみてください.

汎用文字列変換クラス - ToString クラス

ToStringクラスより,Converterクラスライブラリを使ってみてください.こちらの方が拡張性が高く便利です.

これまでは,テストに応じて文字列変換メソッドを作っていました.しかし,エンティティクラスなど単純なクラスは Java のリフレクション機能を使って文字列変換を行なうことができます.ここで紹介する ToString クラスは,配列や Collection など基本的な型を文字列に変換したり,指定されたクラスを文字列に変換することができます.ソースコードおよびテストコードは以下の通りですので,自由に使ってください.

クラス名 意味 ソースコード テストケース
junit.extensions.ToString 色々なクラスの文字列変換を行なう ToString.java ToStringTest.java
junit.extensions.StringPrintWriter 改行つきの文字列を作成するために ToString クラスから利用される StringPrintWriter.java StringPrintWriterTest.java

ここでは, ToString クラスの使い方について解説しましょう.

from メソッド

ToString クラスは from メソッドという static メソッドを持っています.基本的にこのメソッドでオブジェクトの文字列変換を行ないます.例えば,いくつかの型について文字列変換してみましょう.

from メソッド呼び出しのサンプル 評価結果
int[] ToString.from(new int[] {1, 2, 3}) [1, 2, 3]
null ToString.from(null) null
Object[] ToString.from(new Object[] {"ABC", null, "あいう"}) [ABC, null, あいう]
java.util.Calendar ToString.from(Calendar.getInstance()) 2002/05/06 18:15:06

また, Vector や ArrayList など Collection インターフェイスをもつクラスも文字列変換することができます.例えば

Vector v = new Vector();
v.add(new Integer(1));
v.add("A");
v.add(Calendar.getInstance());
v.add(new String[] {"X", "Y", "Z"});
System.out.println(ToString.from(v));

というコードを実行すれば,標準出力には次のように表示されます.

[1, A, 2002/05/06 23:18:31, [X, Y, Z]]

このように,配列やコレクションは,両側を大括弧でくくり各要素をカンマ区切りで列挙した文字列として変換されるようになっています.ただ,バイト配列だけは特別で, 16進でダンプ表示された文字列を返すようになっています.例えば

System.out.println(ToString.from("0123456789ABCDEFGHIJKLMN".getBytes()));
System.out.println();
System.out.println(ToString.from("あめんぼあかいなあいうえお\r\n".getBytes()));

というコードを実行すると,標準出力には次のように表示されます.

00000000: 3031 3233 3435 3637 3839 4142 4344 4546  0123456789ABCDEF
00000010: 4748 494a 4b4c 4d4e                      GHIJKLMN

00000000: 82a0 82df 82f1 82da 82a0 82a9 82a2 82c8  ................
00000010: 82a0 82a2 82a4 82a6 82a8 0d0a            ............

このように, printf デバッグとしても ToString クラスを利用することができます.

Rep インターフェイスと addRep メソッド

ここからはユーザ定義クラスの文字列変換について説明しましょう.

ToString クラスは,各クラスごとに文字列変換メソッドの辞書を持っています. from メソッドが呼ばれたとき, from メソッドはまず引数のクラスを調べ,そのクラスをキーとして辞書を検索します.もしそのクラスが登録されていれば,対応する文字列変換メソッドで引数を文字列変換しますが,登録されていなければ,引数の toString メソッドで変換しようとします.

前の節にある int[], Object[], Calendar, Collection, byte[] の各クラスはすでに ToString の辞書に登録されています.だから,ユーザ定義クラスの文字列変換を行ないたい場合は,ToString の辞書にその項目を追加すればよいわけです.この登録のときに使われるのが Rep インターフェイスです.

static public interface Rep
{
    abstract public Class fromClass();
    abstract public String toString(Object anObject);
}

このインターフェイスは 2つのメソッドを持ち,fromClass メソッドは対象となるクラス,toString はそのクラスの文字列変換メソッドを表しています.

例えば, Calendar クラスの文字列表現として yyyy/MM/dd HH:mm:ss ではなく yyyy-MM-dd というフォーマットにしたいとしましょう.そのときは Rep インターフェイスを Calendar 用に実装し, ToString クラスに登録します.そのとき用いるメソッドが addRep という static メソッドです.

ToString.addRep(new ToString.Rep() {
        public Class fromClass() 
        {
            return Calendar.class;
        }
        public String toString(Object anObject) 
        {
            Calendar cal = (Calendar)anObject;
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
            dateFormat.setLenient(false);
            return dateFormat.format(cal.getTime());
        }});

addRep メソッドは後から登録されたものが優先されるので,デフォルトで定義されていた Calendar クラスの文字列表現は上書きされます.ユーザ定義クラスの文字列変換をしたい場合は,同じようにそのクラスの Rep オブジェクトを addRep メソッドで登録すればよいわけです.テストコードでは, setUp メソッド等で登録しておけばよいでしょう.

登録された Rep オブジェクトをリセットして初期状態に戻すには reset メソッドを使います.

ToString.reset();

setUp メソッドで文字列変換メソッドを登録した場合, tearDown メソッドから reset メソッドを呼んで登録を解除しておけばよいでしょう.

ReflectRep クラスと addReflectRep メソッド

ToString クラスには, Rep インターフェースを持つ ReflectRep クラスが用意されています.このクラスを使うと,任意のクラスについてリフレクションを使った文字列変換が可能です.例えば,人を表す Person クラスがあったとしましょう.

class Person
{
    String firstName;
    String lastName;

    Person(String firstName, String lastName)
    {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

ToString の static メソッドである addReflectRep メソッドを使えば,簡単に Person クラス用の ReflectRep オブジェクトを登録することができます.

ToString.addReflectRep(Person.class);
System.out.println(ToString.from(new Person("勝", "石井")));

このコードを実行すると,次のような文字列が出力されます.

[firstName=勝, lastName=石井]

このように,フィールド名と値のペアが文字列として表示されます.表示されるフィールドの順序は,フィールド名の辞書順になることに注意してください(フィールドの定義順にしたいところですが,Java のリフレクションではできないようです).

この ReflectRep クラスはスーパークラスのフィールドも表示します.例えば,Person クラスのサブクラス, Programmer クラスが定義されていたとしましょう.

class Programmer extends Person
{
    String[] languages;

    Programmer(String firstName, String lastName, String[] languages)
    {
        super(firstName, lastName);
        this.languages = languages;
    }
}

この Programer の ReflectRep オブジェクトを登録してみましょう.

ToString.addReflectRep(Programmer.class);
Person ishii = new Programmer("勝", "石井", new String[] { "Java", "Ruby", "EmacsLisp"});
System.out.println(ToString.from(ishii));

このプログラムの出力は,次のようになります.

[firstName=勝, languages=[Java, Ruby, EmacsLisp], lastName=石井]

PropertiesRep クラス

ReflectRep クラスは簡単に利用できるところはいいですが,フィールドすべてを表示してしまいます.BookQuery クラス のところで見たように,部分的なフィールドしか文字列表現に必要ない場合は PropertyRep クラスを利用するとよいでしょう. 例えば, Book クラスに対して PropertiesRep を使ってみましょう.

ToString.addRep(new ToString.PropertiesRep () {
        public Class fromClass()
        {
            return Book.class;
        }
        public Property[] toProperties(Object anObject)
        {
            Book book = (Book)anObject;

            return new Property[] 
                {
                    new Property("isbn", book.getISBN()),
                    new Property("title", book.getTitle()),
                };
        }
    });
System.out.println(ToString.from(new Book("4-7561-3687-7")));

このように,文字列変換メソッド toString ではなく toProperties というオブジェクトを Property クラスの配列に変換するメソッドを実装するようにします.このコードの出力は以下のようになります.

[isbn=4-7561-3687-7, title=Rubyを256倍使うための本 極道編]

こうすれば, BookQuery クラスのテストは次のように書き直すことができます.

BookQuery query = new BookQuery();

query.setKeyword("Ruby");
List bookList = query.find();

ExpectedBook expectedBooks[] = {
    new ExpectedBook("0-201-71089-7", "Programming Ruby"),
    new ExpectedBook("4-7561-3687-7", "Rubyを256倍使うための本 極道編"),   
    new ExpectedBook("4-7561-3709-1", "Rubyを256倍使うための本 無道編"),
};

assertEquals(ToString.from(expectedBooks), ToString.from(bookList));

だだし, ExpectedBook を文字列変換するため

ToString.addReflectRep(ExpectedBook.class);

を前もって呼んでいるものとします.

setExpansionLevel メソッド

複雑なデータ構造をもつオブジェクトの文字列表現について考えましょう. printf デバッグするときや JUnit が出力するエラーメッセージから diff をとるときは,オブジェクトの文字列表現の中に適当に改行が入っていたほうがわかりやすくなります.そのため, ToString クラスには setExpansionLevel メソッドが用意されています.このメソッドを使うと,文字列表現で改行を行なう「展開レベル」の設定が可能です.

例えば,さきほど出てきた

[firstName=勝, languages=[Java, Ruby, EmacsLisp], lastName=石井]

という文字列表現に改行を入れたいとしましょう.この場合はあらかじめ展開レベルを 1 に設定しておきます.

ToString.setExpansionLevel(1);

こうするとトップレベルの[]の各要素はカンマではなく改行区切りで表示されるようになります.

[
  firstName=勝
  languages=[Java, Ruby, EmacsLisp]
  lastName=石井
]

さらに展開レベルを 2 にしてみましょう.

ToString.setExpansionLevel(2);

今度はこうなります.

[
  firstName=勝
  languages=[
    Java
    Ruby
    EmacsLisp
  ]
  lastName=石井
]

このように,2段階深いレベルの [] まで改行区切りで表示されるようになりました. diff でデバッグする際は,この機能を使って ToString クラスを利用してください.

また, setExpansionLevel メソッドではなく, from メソッドの第2引数でその都度展開レベルを指定することもできます.例えば

System.out.println(ToString.from(ishii, 1)); //展開レベル 1
System.out.println(ToString.from(ishii, 2)); //展開レベル 2

のようにすれば, setExpansionLevel を指定しなくても展開レベルに応じた改行が入ります.こちらのほうが便利かもしれません.

更新履歴

  • 2002/08/12 ― ToString.from に展開レベルを指定する引数追加
  • 2002/05/02 ― 公開