|
|
.NET Tools
NUnit入門 Test Firstのススメ [NUnit 2.0対応版]
5.唐突な仕様変更に対応する
(株)ピーデー 川俣 晶
2003/04/26
|
|
|
唐突な仕様変更
プログラム開発において、最初に「これこれこういう機能を作ってくれ、頼む」と言われてその通りに作って終わりになるとは限らない。非常に高い確率で、「そこはこう変更してくれ」と言われる。しかも、どんな変更を要求されるか予測できない場合も多い。最初の要求を実現するために書かれたソース・コードは、簡単に変更を余儀なくされてしまうのである。
この最大データ・サイズ記録クラスに対しても、このような機能変更の要求が出てきた。「最大データ・サイズを要求した顧客のみを記録するだけでは不十分だ。『要求された大きなデータのベスト10』を記録してほしい」と、そんなことを言われたとしよう。そんな仕様変更をいまごろいわれても……、と思いながらも、それを実装することになったとしよう。つまり、最大データ・サイズ記録クラスはこれまでのように1個の整数値ではなく、10個の整数値を保存しなければならない。しかし、すでに最大データ・サイズ記録クラスは、コードのあちこちから参照されているため、中身を書き換えて、もし挙動が変わってしまったりすると、面倒なことが起きる。「動いているものには触らない」というポリシーを持っているプログラマーも多いと思う。だが、それを書き換えねばならなくなったとしたら……。
機能追加によって新規に増えるメソッドはともかく、既存のメソッドの挙動が変わることは絶対に避けたい。しかし、どうすればそれが実現できるだろうか。
1つの方法が、コードを書き換えた後で、NUnitで確認するという方法だ。テスト・メソッドは、実装の詳細とは関係なく、メソッドの表面的な挙動だけをチェックしている。ならば、どんどん書き換えても、テストさえパスすれば、挙動は変わっていないと見なせる。テストはすべて自動化されているので、実行するのは簡単である。いちいちファイルを指定するのが面倒なら、引数にファイルを指定して起動するバッチを作ることもできる。Nunit-Guiは開くと前回読み込んだDLLを自動的に開くので、それを活用するのもよいだろう。また、VS.NETのプロジェクト設定の中で、プロジェクト実行時にNunit-Guiを実行してしまうように設定することも可能である。
これにより、安心してプログラムの書き換えに着手できる。つまり、NUnitと“Test First”は、すでに稼働しているコードを書き換えるための勇気を与えてくれるのである。
以下が書き直したコードである。
using System;
using System.Collections;
namespace ServerApp
{
public class MaxDataSizeRecorder
{
private const int numberOfPoints = 10;
class NamedPoint : IComparable
{
public string Name = "NoName";
public int Point = 0;
public int CompareTo( object obj )
{
return ((NamedPoint)obj).Point - Point;
}
public NamedPoint( string name, int point )
{
this.Name = name;
this.Point = point;
}
}
private ArrayList points;
public string Name
{
get { return ((NamedPoint)points[0]).Name; }
}
public int Point
{
get { return ((NamedPoint)points[0]).Point; }
}
public void SetPoint( string name, int point )
{
if( ((NamedPoint)points[numberOfPoints-1]).Point >= point ) return;
points.Add( new NamedPoint( name, point) );
points.Sort();
points.RemoveAt(numberOfPoints-1);
}
public bool IsHighest( int point )
{
return this.Point < point;
}
public MaxDataSizeRecorder()
{
points = new ArrayList();
for( int i=0; i<numberOfPoints; i++ )
{
points.Add( new NamedPoint("NoName", 0 ) );
}
}
}
}
|
|
10個のデータを記録するようにしたMaxDataSizeRecorderクラス(C#版) |
Imports System.Collections
Public Class MaxDataSizeRecorder
Private Const numberOfPoints As Integer = 10
Class NamedPoint
Implements IComparable
Public Name As String = "NoName"
Public Point As Integer = 0
Public Function CompareTo(ByVal obj As Object) As Integer Implements IComparable.CompareTo
Return obj.Point - Point
End Function
Public Sub New(ByVal name As String, ByVal point As Integer)
Me.Name = name
Me.Point = point
End Sub
End Class
Private points As ArrayList
Public ReadOnly Property Name() As String
Get
Return points(0).Name
End Get
End Property
Public ReadOnly Property Point() As Integer
Get
Return points(0).Point
End Get
End Property
Public Sub SetPoint(ByVal _name As String, ByVal _point As Integer)
If points(numberOfPoints - 1).Point >= _point Then Exit Sub
points.Add(New NamedPoint(_name, _point))
points.Sort()
points.RemoveAt(numberOfPoints - 1)
End Sub
Public Function IsHighest(ByVal _point As Integer) As Boolean
Return Me.Point < _point
End Function
Public Sub New()
points = New ArrayList()
Dim i As Integer
For i = 0 To numberOfPoints - 1
points.Add(New NamedPoint("NoName", 0))
Next
End Sub
End Class
|
|
10個のデータを記録するようにしたMaxDataSizeRecorderクラス(VB.NET版) |
要求された機能の追加にあまり手間を掛けたくなかったので、安易な解決方法を採ってみた。System.Collections.ArrayListクラスのSortメソッドを使って、上位10人のポイントを並べ替えるような仕組みを作ってみたのである。Sortメソッドは配列にも用意されているが、ソート計算時には新しいスコアを入れて11人になり、固定長である配列には合わないような気がして、あえて可変長のArrayListクラスを使ってみたのである。
さっそくNUnitで確認して、テストをパスするまでコードを手直しした。すでに最大データ・サイズ記録クラスを使用しているプログラム・コードにおいても、致命的な問題は起こしていないはずだ。
なお、新規に作成するクラスやpublicなメソッドに関しても、本当ならテスト・メソッドを用意すべきだが、今回は説明の都合上割愛した。
もっと手直しをしよう
上のコードはあまりきれいではない。特にArrayListクラスを使った結果、キャストがあちこちに入り込んでいる(もっともVB.NET版のコードにはキャストはないのだが……。「Option Strict On」で使うと、VB.NETでもキャストは必要になる)。何とかならないものかと思ったが、よく考えると、コレクションの長さを変えないで処理する方法があることに気付いたとしよう。新しいポイントを追加するときに、ベスト10に入るかどうかだけ確認して、入るときには、第10位の人の代わりに新しく追加する人を入れて、それからソートしてしまえばよいわけだ。これなら固定長なので、配列で処理できる。配列で処理できればキャストは不要だ。このほかpublicなメンバ変数などもすべて書き直してみた。書き直した結果がこれだ。
using System;
namespace ServerApp
{
public class MaxDataSizeRecorder
{
private const int numberOfPoints = 10;
class NamedPoint : IComparable
{
private string name = "NoName";
private int point = 0;
public string Name
{
get { return name; }
}
public int Point
{
get { return point; }
}
public int CompareTo( object obj )
{
return ((NamedPoint)obj).point - point;
}
public NamedPoint( string name, int point )
{
this.name = name;
this.point = point;
}
}
private NamedPoint [] points;
public string Name
{
get { return points[0].Name; }
}
public int Point
{
get { return points[0].Point; }
}
public void SetPoint( string name, int point )
{
if( points[numberOfPoints-1].Point >= point ) return;
points[numberOfPoints-1] = new NamedPoint( name, point);
Array.Sort( points );
}
public bool IsHighest( int point )
{
return this.Point < point;
}
public MaxDataSizeRecorder()
{
points = new NamedPoint[numberOfPoints];
for( int i=0; i<numberOfPoints; i++ )
{
points[i] = new NamedPoint("NoName", 0 );
}
}
}
}
|
|
ArrayListクラスをやめて配列により10個のデータを記録するようにしたMaxDataSizeRecorderクラス(C#版) |
Public Class MaxDataSizeRecorder
Private Const numberOfPoints As Integer = 10
Class NamedPoint
Implements IComparable
Private _name As String = "NoName"
Private _point As Integer = 0
Public ReadOnly Property Name() As String
Get
Return _name
End Get
End Property
Public ReadOnly Property Point() As Integer
Get
Return _point
End Get
End Property
Public Function CompareTo(ByVal obj As Object) As Integer Implements IComparable.CompareTo
Return obj.Point - _point
End Function
Public Sub New(ByVal name As String, ByVal point As Integer)
Me._name = name
Me._point = point
End Sub
End Class
Private points(numberOfPoints - 1) As NamedPoint
Public ReadOnly Property Name() As String
Get
Return points(0).Name
End Get
End Property
Public ReadOnly Property Point() As Integer
Get
Return points(0).Point
End Get
End Property
Public Sub SetPoint(ByVal _name As String, ByVal _point As Integer)
If points(numberOfPoints - 1).Point >= _point Then Exit Sub
points(numberOfPoints - 1) = New NamedPoint(_name, _point)
Array.Sort(points)
End Sub
Public Function IsHighest(ByVal _point As Integer) As Boolean
Return Me.Point < _point
End Function
Public Sub New()
Dim i As Integer
For i = 0 To numberOfPoints - 1
points(i) = New NamedPoint("NoName", 0)
Next
End Sub
End Class
|
|
ArrayListクラスをやめて配列により10個のデータを記録するようにしたMaxDataSizeRecorderクラス(VB.NET版) |
見比べていただけばすぐ分かると思うが、相当大胆な変更だ。しかし、プログラムの働きという意味では、前のコードでも問題はなかったはずだ。従って書き換えの結果は、コードがきれいになっただけである。「動いているものには触るな」というポリシーを持つ人なら、きっと手を出さないだろう。しかし、自動テストの環境がしっかり作られていれば、実装内容の書き換えは決してリスクの高い行為ではない。それよりも、必要に応じてきめ細かくコードの内容を変更していく勇気が与えられるのは非常に重要だ。
現実のソフトウェア開発では、仕様変更は必ず発生するものであり、何度も変わり続ければコードはどんどん読みにくくなる。読みにくくなったコードを読みやすいように書き直すことは、開発をスムーズに進めるためには必要な作業なのである。このようなコードの修正は「リファクタリング」と呼ばれるが、何の準備もなくいきなりコードを書き換えればバグはやすやすと入り込む。簡単に素早く何度でも自動テストを実行できる環境を用意することによって、バグが入り込みにくいコード修正が可能になるのである。
まとめ
さて、NUnitの効用が何となく見えてきただろうか。NUnitは自動テストを行う方法を提供する。自動テストは、コードを書き換える勇気を与えてくれる。コードを書き換える精神的負担や時間が軽減されれば、ソフトウェアの質を高めることが容易になる。例えば、同じ処理をコードの別の個所に見つけたら、それを1カ所にまとめることができる。そうすれば、保守性も高くなるし、コードも短くシンプルになって分かりやすさにもプラスになる。筆者の過去の経験からいっても、自動テストを作成した方が、いろいろな意味でプログラマーの負担が軽いように感じられる。
自動テストは、ちょっと見た目には遠回りに思えるかもしれない。だが、自動テストとは、道路をきちんと舗装するようなものだ。いったん舗装してしまえば、何倍ものスピードで、しかも軽い負担で走ることが可能になる。NUnitという手軽なツールもあることだし、ぜひ試してみてはどうだろうか?
今回の解説は、あくまでNUnitの使い方の入り口にすぎない。また、Test Firstの先にはTest-Driven Developmentというキーワードもあるらしい。いまこそ、あらためてテストについて注目してみてもよいのではないだろうか?