XPlorations


XPにおけるテスト/コーディングサイクル: 第二部  GUI January, 2000

ユニットテストをする人々は、Extream Programmingでのユニット テストをする人たちの多くさえも、ユーザインターフェイスのテストは必要ないと考えている。 しかし、それはできるのだ。JUnitをこの種のテストに使うことができるのである。 この論文では、小さいが納得のいく例を通してJUnitを使ったテストとプログラミングの雰囲気 を示そうと思う。この論文は第二部だが、それだけでも読むことができる。 第一部ではモデルを開発 している。 (原論文はThe Test/Code Cycle in XP: Part 1, Model および The Test/Code Cycle in XP: Part 2, GUI を参照。 この原論文は、"Extreme Programming Explored" by William C. Wake, Addison Wesley 2001 に収録されています。日本語版は「XPエクストリーム・プログラミング アドベンチャー」長瀬 嘉秀 (翻訳), 今野 睦 (翻訳), 飯塚 麻理香 (翻訳), 畑田 成広 (翻訳) 発行 ピアソン・エデュケーション 2002年です)

単純な検索エンジンを開発しているとしよう。 ユーザインターフェイスはこういう感じにしたいと思っている:

開発はXPのスタイルですすめる。つまり、テストとコーディングの間をいったりきたり するわけだ。 以下でコードの引用をするときはこれを反映して、テストコードは左側に寄せ、 アプリケーションコードは右側に寄せて表示する
 
 

まずモデルから

GUI (graphical user interface)を作っていくとき、まずモデルの開発とテストを先に 行わなければならない。これはすでに終ったものとしよう。そして、次のインターフェイス ができているものとする。

 
public class SearcherFactory {
    public static Searcher get(String s) throws IOException {...}
}

public interface Searcher {
    public Result find(Query q);
}

public class Query {
    public Query(String s) {...}
    public String getValue() {...}
}

public interface Result {
    public int getCount();
    public Document getItem(int i);
}

public interface Document {
    public String getAuthor();
    public String getTitle();
    public String getYear();
}

GUIのテストと開発では、モデルの interfaces に依存するのはかまわない。 具象クラスに依存しなくてはならなくなると幸せは減ってしまうからね。

GUIのConnection

起きてほしいことは次の通り。: これを起こせて、結果をユニットテストできるようにしたい。
 

キーとなるWidghetをテストする

画面のデザインを早いうちに進めるつもりでいる。 最初にテストできることは、キーとなるWidgetが表示されているということ。 それは、ラベル、query用のフィールド、ボタン、それからテーブルである。 パネルには他のコンポーネントがあってもよいが(たとえば、組立てのために 使うサブパネルなど)、それらについてはテストしない。

というわけで、testWidgetsPresent()を作る。 これを動くようにするために、全体のスクリーンに対するpanel("SearchPanel")と ラベル("searchLabel")とqueryのためのテキストフィールド("queryField")、 ボタン("findButton")、それから結果を表示するためのテーブル("resultTable") が必要である。 これらのwidgetはパッケージ内でアクセスできるようにし、テストがそれを 見えるようにする。
public void testWidgetsPresent() { 
    SearchPanel panel = new SearchPanel(); 
    assertNotNull(panel.searchLabel); 
    assertNotNull(panel.queryField); 
    assertNotNull(panel.findButton); 
    assertNotNull(panel.resultTable); 
} 

テストのコンパイルは失敗する。(勿論だ。まだSearchPanelは作っていないのだから) そこで、SearchPanelクラスを作る。それにはフィールドのwidgetを持たせ、コンパイル できるようにする。 まだwidgetをinitializeしてはいけない。その前に、テストを走らせ、それが失敗することを 確認するのだ。(テストが一回は失敗するのを確認しておくのはいい習慣だ。 これで間違っているときにテストがそれを判定できるということの確認になる。 それによって、テストがコーディングをドライブしていることの保証がえられる) 次に、テストが通るのに必要なだけのコードを書く。

注意すべき点:

他のテストとして、widgetが正しくセットアップされたかどうかを検証することもできる。
public void testInitialContents() {
    SearchPanel sp = new SearchPanel();
    assertEquals("Search:", sp.searchLabel.getText());
    assertEquals("", sp.queryField.getText());
    assertEquals("Find", sp.findButton.getText());
    assert("Table starts empty", sp.resultTable.getRowCount() == 0);
}

テストを走らせ、うまく行くのを確認する。

この段階で、SearchPanelのコードはこんな感じになっている:

 
public class SearchPanel extends JPanel {
    JLabel searchLabel = new JLabel("Search:");
    JTextField queryField = new JTextField();
    JButton findButton = new JButton("Find");
    JTable resultTable = new JTable();

    public SearchPanel() {}
}

ここで二つの道のどちらに進んでもよい。つまり、ユーザインターフェイス の開発か、検索機能との連携か。 インターフェイスを目に見たいという衝動は強いが、それに逆らって連携機能の ほうに軍配をあげることにしよう。

連携機能のテスト

なんらかの方法で、SearcherとGUIを関連付け、その結果が表示されることを 検証する必要がある。

パネルには二つのメソッドgetSearcher()setSearcher()を作る。 これはSearcherとパネルを結び付けるものだ。 この決断の結果、新たに次のテストを書くことになる。
public void testSearcherSetup() { 
    Searcher s = new Searcher() { 
        public Result search(Query q) {return null;} 
    }; 

    SearchPanel panel = new SearchPanel(); 
    assert ("Searcher not set", panel.getSearcher() != s); 
    panel.setSearcher(s); 
    assert("Searcher now set", panel.getSearcher() == s); 
}

コンパイルは失敗し、SearchPanelを修正することになり、メソッドを追加し、 テストをもう一度実行し、また失敗する。そこで、set/getメソッドを作り、 そしてテストをパスする。

このバネルはまだほとんどなにもできないが、いまや、Searcherとそれを つなぐことができたのである。

Fake Searcherを用いたテスト

検索は結果の集合を返す。 何かが値のリストを返すとき、いつも見ておきたいと思うのは 復帰値が0、1、それからなにか任意の個数のときの振舞いである。

これはユニットテストなので、実際のSearcherの実装に依存したテストは したくない。テスト専用のものを作っておきたい。 こうすれば、粒度のそろった方法で、振舞いをコントロールすることができるように なる。 ここでは、TestSearcherという名前の新しいSearcherを作る。 query文字列は一つのintegerとし、それが検索結果の個数を示す。 itemには、最初の著者に"a0", 二番目の標題に"t1"、という具合に名前をつけていく。

しかし、最初は....テストだ。(これは、GUIのテストでなく、テストをするクラスの テストだということをお忘れなく)
public void testTestSearcher() { 
    assertEquals(new Query("1").getValue(), "1"); 
  
    Document d = new TestDocument(1);
    assertEquals("y1", d.getYear());

    Result tr = new TestResult(2); 
    assert(tr.getCount() == 2); 
    assertEquals("a0", tr.getItem(0).getAuthor());

    TestSearcher ts = new TestSearcher(); 
    tr = ts.find(ts.makeQuery("2"));
    assert("Result has 2 items", tr.getCount() == 2); 
    assertEquals("y1", tr.getItem(1).getYear());
} 

普通のコンパイル/失敗のサイクルを行い、テストクラスを作る。 TestDocumentから始めよう:
public class TestDocument implements Document { 
    int count; 
    public TestDocument(int n) {count = n;} 
    public String getAuthor() {return "a" + count;}
    public String getTitle() {return "t" + count;} 
    public String getYear() {return "y" + count;} 
} 

TestResultクラスはintegerをパラメタにとるコンストラクタを持つ。 そのintegerで何件の結果を返すかを指定する。
public class TestResult implements Result { 
    int count; 
    public TestResult(int n) {count = n;} 
    public int getCount() {return count;} 
    public Document getItem(int i) {return new TestDocument(i);}
} 

TestSearcherはquery文字列の数値としての値を用いてresultを作る。
public class TestSearcher implements Searcher { 
    public Result find(Query q) { 
        int count = 0; 
        try {count = Integer.parseInt(q.getValue());} 
        catch (Exception ignored) {} 
        
        return new TestResult(count); 
    } 
} 

テストをまた実行し、今度はパスする。

0, 1, たくさん

0個と1個とたくさんの場合のテストを作る:
public void test0() { 
    SearchPanel sp = new SearchPanel(); 
    sp.setSearcher (new TestSearcher()); 
    sp.queryField.setText("0"); 
    sp.findButton.doClick(); 
    assert("Empty result", sp.resultTable.getRowCount() == 0); 
}

ついに、GUIを使えるようになった。 つまり、テキストフィールドに設定し、ボタンを押し、といったことができる。

テストを走らせ--そしてパスした! これは、今や我々は機能する解を持っているということを意味する。 ただし、使っているsearcherが常に0itemを返す場合だけだが。

前進しよう:
public void test1() { 
    SearchPanel sp = new SearchPanel(); 
    sp.setSearcher (new TestSearcher()); 
    sp.queryField.setText("1"); 
    sp.findButton.doClick(); 

    assert("1-row result", sp.resultTable.getRowCount() == 1); 
    assertEquals(
	"a0", 
	sp.resultTable.getValueAt(0,0).toString());
} 


ここで、失敗する。 理由は、ボタンに対するイベントハンドリングをまだつくっていないからだ。

ボタンがクリックされたら、テキストフィールドの文字列からqueryを生成し それからsearcherにそれを検索させ、その結果をテーブルに表示するということを したい。 ところが、型が合わないのだ: SearcherはResultを返してくるが、GUIのテーブルは TableModelを要求している。 そのため、インターフェイスの変換を行うアダプターが必要になる。
 

Record our Mental Stack

同時にやらなくてはならないことが たくさんでてきたので、ここらへんでレビューしておこう。そして、記録しておこう。 そうすれば、やることを忘れないですむ。

アダプタの実装

ボタンのコードをResultTableAdapterクラスが あたかも存在するもののように書こう:

 
findButton.addActionListener(new ActionListener() { 
    public void actionPerformed(ActionEvent e) { 
        Query q = new Query(queryField.getText()); 
        
	resultTable.setModel(
	    new ResultTableAdapter(getSearcher().find(q)));
    } 
}); 

コンパイルに失敗したら、ダミーの実装でスタブを作る:

 
public class ResultTableAdapter extends DefaultTableModel {
    public ResultTableAdapter(Result r) {}
}

Test0()はここでも通る。そして test1()はまだ失敗する。

アダプタを書くのは特に難しいことはない。しかし、テストを書くことから始める。
public void testResultTableAdapter() { 
    Result result = new TestResult(2); 
    ResultTableAdapter rta = new ResultTableAdapter(result); 
    assertEquals("Author", rta.getColumnName(0)); 
    assertEquals("Title", rta.getColumnName(1)); 
    assertEquals("Year", rta.getColumnName(2)); 
    assert("3 columns", rta.getColumnCount() == 3);

    assert("Row count=2", rta.getRowCount() == 2); 
    assertEquals("a0", rta.getValueAt(0,0).toString()); 
    assertEquals("y1", rta.getValueAt(1,2).toString());
} 

テストは失敗する。理由は、ダミーの実装では何もしていないからだ。

戻って、ResultTableAdapterの実装をする。 (DefaultTableModelから)AbstractTableModelのサブクラスに変更する。 そして、column namesとcolumn、row countsそしてついに getValueAt() を実装する

 
public class ResultTableAdapter 
	extends AbstractTableModel implements TableModel { 
    final static String columnNames[] = {"Author", "Title", "Year"};
    Result myResult;

    public ResultTableAdapter(Result r) {myResult = r;} 

    public String getColumnName(int i) {return columnNames[i];}

    public int getColumnCount() {return columnNames.length;}

    public int getRowCount() {return myResult.getItemCount();}

    public Object getValueAt(int r, int c) {
	Document doc = myResult.getItem(r);

	switch(c) {
	case 0: return doc.getAuthor();
	case 1: return doc.getTitle();
	case 2: return doc.getYear();
	default: return "?";
        }
    }
}

このテスト(testResultTableAdapter) は通るはずで、 test1()も通らないといけない。
 

TestN とそれ以上

testN()を書き、たとえば5で実行する。 これもパスするはずだ。

他になにか問題になりそうなことがあるだろうか? ひとつ起りそうな問題は、queryを続けて実行したとき、"leftover"になるか どうかというのがある。つまり、結果が5件のqueryの後に結果が3件の queryをすると、テーブルには3件だけがなくてはいけない。(もしテーブルの 作り方が間違っていたら、先に実行した結果の末尾に2件が残って見える かもしれない。)

連続するqueryのテストもできる。
public void testQuerySequenceForLeftovers() { 
    SearchPanel sp = new SearchPanel(); 
    sp.setSearcher (new TestSearcher()); 

    sp.queryField.setText("5"); 
    sp.findButton.doClick(); 
    assert(sp.resultTable.getRowCount() == 5); 
 
    sp.queryField.setText("3"); 
    sp.findButton.doClick(); 
    assert(sp.resultTable.getRowCount() == 3); 
} 

このテストはとおる。
 

ルックスのテスト

パネルは正しく接続し終えた。 そこで、widgetの相対的な位置関係のテストができるようになった: (こういったテストは面倒なだけだろうか? おそらくそうではない。 我々は、パネルを画面に表示し、その内容を目で確認することもできるだろう。 確かに、目で確認するテストが絶対に適切な場面というのは存在する。 たぶん、スタイルガイドに合っているかどうかを確認するときとか、 ウィンドウのフォーマットが安定していることが求められているときとか)

このテストを走らせることができるためには、作ってきたpanelをframeか windowの上にのせる必要がある。 (Componentはそれを含むwindowが作られるまでは画面上に配置されない)
public void testRelativePosition() {
    SearchPanel sp = new SearchPanel();

    JFrame display = new JFrame("test");
    display.getContentPane().add(sp);
    display.setSize(500,500);
    display.setVisible(true);

    //try {Thread.sleep(3000);} catch (Exception ex) {}

    assert ("label left-of query", 
	  sp.searchLabel.getLocationOnScreen().x 
	< sp.queryField.getLocationOnScreen().x);
        
    assert ("query left-of button", 
	  sp.queryField.getLocationOnScreen().x 
	< sp.findButton.getLocationOnScreen().x);
        
    assert ("query above table", 
	  sp.queryField.getLocationOnScreen().y 
	< sp.resultTable.getLocationOnScreen().y);
}

テストは失敗する。まだ、widgetをpanelの上に乗せていないから。 (sleep()のコメントをはずせば、スクリーン上で画面を見ることもできる)

panelを実装するために、私は普通、中間のパネルとレイアウトが見えるように スクリーンをデザインする:

さて、このパネルをレイアウトできるようになった:

 
public SearchPanel() {
    super (new BorderLayout());

    findButton.addActionListener(new ActionListener() { 
        public void actionPerformed(ActionEvent e) { 
	    Query q = new Query(queryField.getText()); 
            resultTable.setModel(
		new ResultTableAdapter(getSearcher().find(q))); 
        } 
    }); 

    JPanel topPanel = new JPanel(new BorderLayout());
    topPanel.add(searchLabel, BorderLayout.WEST);
    topPanel.add(queryField, BorderLayout.CENTER);
    topPanel.add(findButton, BorderLayout.EAST);

    this.add(topPanel, BorderLayout.NORTH);
    this.add(new JScrollPane(resultTable), BorderLayout.CENTER);
}

コンパイルし、テストし、ちゃんと動く。

パネルがうまく作れた!

メイン

システムを完成させるためにmain()ルーチンを作る:

 
public class Main {
    public static void main(String[] args) {
	if (args.length == 0) {
	    System.err.println(
		"Arg - file w/tab-delimited author/title/year");
	    System.exit(1);
	}

	Searcher searcher = null;
	try {
	    searcher = SearcherFactory.get(args[0]);
	} catch (Exception ex) {
	    System.err.println(
		"Unable to open file " + args[0] + "; " + ex);
	    System.exit(1);
	}
	
	SearchPanel sp = new SearchPanel();
	sp.setSearcher(searcher);

	JFrame display = new JFrame("Bibliographic System - " + args[0]);
     	display.getContentPane().add(sp);
     	display.setSize(500,500);
     	display.setVisible(true);
    }
}

結論

以上で、ユーザインターフェイスの開発は完了した。 これまでの話で、ユニットテストができるようなGUIの 側面をすべて網羅したわけではない。 しかし、多くの有益なテクニックを示すことができた: ユニットテストは書くのが面倒だ。しかし、将来の時間の節約になる。(修正したとき 入り込んだバグをこれで見つけ出すことによって) それほど明白ではないが、同じくらい重要なことは、今現在の時間の節約にもなっている ということだ。 テストによって、設計や実装が単純であることに集中できるし、 リファクタリングができるようになるし、開発した機能が正しいことを示せる。

関係資料

[Written 1-3-2000; revised 2-1-2000; re-titled and revised 2-4-2000.]


Copyright 2000, William C. Wake ....... Email ....... サイトマップ ....... サイトのトップ

翻訳)大村伸一 メール...この翻訳は Addison Wesley の許可を得ています。営利目的での使用は禁止しします。