[TDD の練習] WinForm を改造したい ~ GUI に埋もれたロジックを分離して、ユニットテストを書く
ネタ元 ⇒ わんくま掲示板 : PictureBoxを左右に往復するループ
質問されたかたはプログラミングの初心者らしく、 どこかから持ってきたサンプルコードを少し改造しようとして苦労しておられました。
※ サンプルコードを動かしてみる → 改造してみる という手法は、 プログラミングに上達するための有効な手段のひとつだと思います。 私も、 いまだによくやります。
掲示板では、 かなり紆余曲折はありましたが、 最後には御自分の納得のいく動きをするコードになったようです。 その途中で 「消えてしまった」 問題を、 ここでは扱ってみます。
・ オリジナルのソース : フォーム上を、 一定速度で画像が左右に動く。
・ やりたいこと : 一回ごとに移動する距離を、 乱数で決めさせたい。
※ 途中で、 「一回ごと」 が 「片道のあいだ中」 に仕様変更され、 「難しい問題」 が消えてしまいました。
今回は、 練習問題というよりも、 GUI からロジックを切り離すリファクタリングを、 ユニットテストのサポート無しで慎重に行う手順を見ていただけたら、 と思います。
回答例のソリューション一式はこちら → WankumaHomework20090810_20090817.zip
[69,127バイト]
※ VB 2008 Express + NUnit 2.5 用
オリジナルのソースコードは、 こんな感じだったようです。 ( Form1 )
' ※ メンバー変数名は、読みやすいように変更した。
Public Class Form1
Dim _movingDistanceX As Integer = 5
Dim _randomNumberGenerator As Random = New Random()
Private Sub Button1_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles Button1.Click
Me.Timer1.Enabled = (Not Me.Timer1.Enabled)
End Sub
Private Sub Timer1_Tick(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles Timer1.Tick
Me.PictureBox1.Top = Me._randomNumberGenerator.Next(400)
Me.PictureBox1.Left += Me._movingDistanceX
If ((Me.PictureBox1.Left < 0) Or (Me.PictureBox1.Left > Me.Width - Me.PictureBox1.Width)) Then
Me._movingDistanceX *= -1
End If
End Sub
End Class
先へ進む前に、 毎回の移動量を 0~9 のいずれかに乱数で決めるようにした場合、 どういう問題が出てくるか検討しておきましょう。 例によって、 問題としているメソッド ( ここでは Timer1_Tick() メソッド ) の外部設計を考えてみます。 シグネチャにも問題はありますがそれはメソッドを切り出せばよいとして、 まずは、 入力 / 出力を見てみましょう。 なお、 表の一番右の欄は、 そのときに横向きの進行方向が反転するかどうか、 です。
例えば、 右へ動いているとき、 前回設定されたメンバー変数 _movingDistanceX の値は正の整数です。 このとき、 乱数で 0~9 の整数を発生させ、 それが今回の移動量になりますから、 出力としての移動量は 0 または正の値となります。 左向きに動いているときは、 0 または負の値、 ということになりますね。
| 【入力】 | 【出力】 | 反転? | ||
|---|---|---|---|---|
| 画像位置 | 移動量 _movingDistanceX |
移動量 _movingDistanceX |
画像位置 | |
| フォーム内 | 正の値 | 0 または 正の値 | 以前と同じ または 以前より右 | NO |
| 0 | ??? | ??? | ??? | |
| 負の値 | 0 または 負の値 | 以前と同じ または 以前より左 | NO | |
| 左の外側 | 正の値 | 0 または 正の値 | 以前と同じ または 以前より右 | NO !! |
| 0 | 0 または 正の値 | 以前と同じ または 以前より右 | ??? | |
| 負の値 | 0 または 正の値 | 以前と同じ または 以前より右 | YES | |
| 右の外側 | 正の値 | 0 または 負の値 | 以前と同じ または 以前より左 | YES |
| 0 | 0 または 負の値 | 以前と同じ または 以前より左 | ??? | |
| 負の値 | 0 または 負の値 | 以前と同じ または 以前より左 | NO !! | |
では、 フォーム内に居るときに、 前回設定されたメンバー変数 _movingDistanceX の値が 0 だったら、 どうでしょう?
乱数で 0~9 のいずれかの整数を発生させて… で、 右へ進めばいいでしょうか、 左へ進めばいいでしょうか? _movingDistanceX の値だけからは決められませんね。 表には "???" と記載しました。
※ 0 が出なければ、 例えば 0~9 ではなく、 1~10 だったら、 この問題は起きません。
フォームの外側に居るときはどうでしょう? たとえば左の外側にいたとき、 前回設定されたメンバー変数 _movingDistanceX が正の値になっていることは、 ありえるでしょうか?
ありえます。 例えば、左側へ 5歩飛び出したとして、 その後、 右向きに ( 乱数で ) 1歩進んだとします。 そのとき、 あいかわらず左側へ 4歩飛び出した状態ですが、 _movingDistanceX はすでに正の値になっています。 この場合、 フォームの外に居るからといって、 次に方向を反転させてはいけないのです。
※ 以上が、 掲示板でレスしてくださった方々に出した宿題 (#39815) の回答となります。
ここまできちんと仕様が把握できたら、 最初から書き直してしまったほうが早いです。
ですが、 せっかくですから、 TDD に持ち込んでみましょう。
現在のコードは、 GUI のコードの中に、 新しい画像の位置を算出するロジックが埋もれています。 GUI のテストコードは書きにくい ( とても面倒です ) ので、 まずはロジックだけを切り出します。
この作業は、 ユニットテストのサポート無しに行わねばなりませんから、 きわめて慎重に作業し、 手作業でテストするしかありません。
【STEP-1】 Timer1_Tick() の中身を MovePicture() メソッドとして切り出す
【STEP-2】 MovePicture() メソッドの中身を、 メンバ変数の入出力と、 ローカル変数を使った処理に分離する。 ( 慎重に!! )
【STEP-3】 分離しきれなかった _randomNumberGenerator 利用部分を、 メソッドに切り出す。
以上のリファクタリングを行った結果が、 Form2 になります。 ( 詳細は、 Form2.vb 内のコメントを見てください。 )
Public Class Form2
Dim _movingDistanceX As Integer = 5
Dim _randomNumberGenerator As Random = New Random()
Private Sub Button1_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles Button1.Click
Me.Timer1.Enabled = (Not Me.Timer1.Enabled)
End Sub
Private Sub Timer1_Tick(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles Timer1.Tick
Me.MovePicture()
End Sub
Private Sub MovePicture()
'* メンバー変数からの入力 Dim pTop As Integer
Dim pLeft As Integer = Me.PictureBox1.Left
Dim distanceX As Integer = Me._movingDistanceX
Dim xMax As Integer = Me.Width - Me.PictureBox1.Width '* 処理
pTop = Me.GetRandomNumberUnder(400)
If ((pLeft < 0) Or (pLeft > xMax)) Then
distanceX *= -1
End If '* メンバー変数へ出力
Me.PictureBox1.Top = pTop
Me.PictureBox1.Left = pLeft
Me._movingDistanceX = distanceX
End Sub
Private Function GetRandomNumberUnder(ByVal maxValue As Integer) As Integer
Return Me._randomNumberGenerator.Next(maxValue)
End Function
End Class
上の Form2 で 「'* 処理」 とコメントしたロジック部分は、 GUI 部品に依存していませんから、 テスト可能なメソッドとして切り出すことが可能です。
【STEP-4】 MovePicture() から、 横方向の移動位置計算ロジックを切り出す。
※ 抜き出した CalculateNextPositionX() メソッドは、ユニットテスト可能。
【STEP-5】 仕様化テストの記述。 現状を肯定するテストを書く。 → RandomMoveTest3.vb
以上のリファクタリングを行った結果が、 Form3 になります。 ( 詳細は、 Form3.vb 内のコメントを見てください。 )
Public Class Form3
Dim _movingDistanceX As Integer = 5
Private Sub Button1_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles Button1.Click
Me.Timer1.Enabled = (Not Me.Timer1.Enabled)
End Sub
Private Sub Timer1_Tick(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles Timer1.Tick
Me.MovePicture()
End Sub
Private Sub MovePicture()
Dim pTop As Integer
Dim pLeft As Integer = Me.PictureBox1.Left
Dim distanceX As Integer = Me._movingDistanceX
Dim xMax As Integer = Me.Width - Me.PictureBox1.Width
pTop = Me.GetRandomNumberUnder(400)
pLeft = Me.CalculateNextPositionX(pLeft, distanceX, xMax)
Me.PictureBox1.Top = pTop
Me.PictureBox1.Left = pLeft
Me._movingDistanceX = distanceX
End Sub
Dim _randomNumberGenerator As Random = New Random()
Private Function GetRandomNumberUnder(ByVal maxValue As Integer) As Integer
Return Me._randomNumberGenerator.Next(maxValue)
End Function
Friend Function CalculateNextPositionX(ByVal nowX As Integer, ByRef ref_distance As Integer, ByVal xMax As Integer) As Integer
nowX += ref_distance
If ((nowX < 0) Or (nowX > xMax)) Then
ref_distance *= -1
End If
Return nowX
End Function
End Class
このようにして、 改造したいロジックの部分だけを CalculateNextPositionX() メソッドに分離することができました。 もとの Form1 にあった、 該当箇所を再掲しておきましょう。
Me.PictureBox1.Left += Me._movingDistanceX
If ((Me.PictureBox1.Left < 0) Or (Me.PictureBox1.Left > Me.Width - Me.PictureBox1.Width)) Then
Me._movingDistanceX *= -1
End If
見比べてみてください。 位置と移動量を計算している部分のコードは、 ほとんど同じカタチのままですね。 ( 一箇所、 Me.Width - Me.PictureBox1.Width を、 ひとつの変数に置き換えている。 …こうしないと引数が増えて面倒だったからですが、 う~ん、 置き換えないほうが安全だったかなぁ。 )
ユニットテストの安全網が無い状態で作業していますから、 ロジックが変わってしまうようなコードの変更は、 可能な限り避けるようにしないといけません。
対象がユニットテスト可能になりましたから、 ここで、 現状を表現するユニットテストを書きます。 ( 仕様化テスト ) → RandomMoveTest3.vb
ふだんのテストファーストでの書き方と違って、 仕様化テストの場合は、 テストの記述中に実装の誤りに気がついても、 実装を直してはいけません。 テストの方を、 まずは誤った実装に合わせます。
ここまで出来たら、 いつもの状態です。
・ 修正対象をきっちりテストする、 ユニットテストのコードがあります。
・ もちろん、 すべてのテストはグリーンです。
あとは、 仕様の変更/追加のためにテストを変更/追加し ( レッド ) → 実装を修正し ( グリーン ) → リファクタリングする、 という TDD のパターンに乗って作業をしていけます。
こうして最終的に、 次のようなコードになりました。 ( Form6 )
途中の経過は、 Form4.vb ~ Form6.vb と、 RandomMoveTest3.vb ~ RandomMoveTestt.vb のコードを読んでください。 なお、 RandomMoveTest4 は、 レッドが残ったままにしてあります。
また、 今回は行いませんでしたが、 この Form のコードから、 移動位置を計算するロジックだけのクラスを切り出すことも、 ここからなら簡単にできるはずです。
Public Class Form6
Private Const DirectionRight As Integer = +1
Private Const DirectionLeft As Integer = -1
Private _movingDirectionX As Integer = DirectionRight
#If DEBUG Then
Public Property testMovingDirectionX() As Integer
Get
Return Me._movingDirectionX
End Get
Set(ByVal value As Integer)
Me._movingDirectionX = value
End Set
End Property
#End If
Private Sub Button1_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles Button1.Click
Me.Timer1.Enabled = (Not Me.Timer1.Enabled)
End Sub
Private Sub Timer1_Tick(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles Timer1.Tick
Me.MovePicture()
End Sub
Private Sub MovePicture()
Dim pTop As Integer
Dim pLeft As Integer = Me.PictureBox1.Left
Dim xMax As Integer = Me.Width - Me.PictureBox1.Width
pTop = Me.GetRandomNumberUnder(400)
pLeft = Me.CalculateNextPositionX(pLeft, xMax)
Me.PictureBox1.Top = pTop
Me.PictureBox1.Left = pLeft
End Sub
#If DEBUG Then
Public testRandomDistance As Integer = -1
#End If
Dim _randomNumberGenerator As Random = New Random()
Private Function GetRandomNumberUnder(ByVal maxValue As Integer) As Integer
#If DEBUG Then
If (Me.testRandomDistance >= 0) Then
Return Me.testRandomDistance
End If
#End If
Return Me._randomNumberGenerator.Next(maxValue)
End Function
Friend Function CalculateNextPositionX(ByVal nowX As Integer, ByVal xMax As Integer) As Integer
nowX += Me.GetRandomNumberUnder(10) * Me._movingDirectionX
If (nowX < 0) Then
Me._movingDirectionX = DirectionRight
ElseIf (nowX > xMax) Then
Me._movingDirectionX = DirectionLeft
End If
Return nowX
End Function
End Class
最後に、 MovePicture() メソッドは、 ふたたびリファクタリングして短くしてもよいでしょう。
Private Sub MovePicture()
Dim xMax As Integer = Me.Width - Me.PictureBox1.Width
Me.PictureBox1.Top = Me.GetRandomNumberUnder(400)
Me.PictureBox1.Left = Me.CalculateNextPositionX(Me.PictureBox1.Left, xMax)
End Sub
| 固定リンク
「*TDD の練習」カテゴリの記事
- [TDD の練習] WinForm を改造したい ~ GUI に埋もれたロジックを分離して、ユニットテストを書く(2009.08.17)
- [TDD の練習] 文字を変換する(2009.06.16)
「<NUnit>」カテゴリの記事
- [NEWS] NUnit 2.6.0 beta2 リリース(2011.11.21)
- [NEWS] NUnit 2.5.2 リリース、 次版からは公開場所が変更(2009.08.25)
- [NEWS] NUnit 2.5 正式リリース(2009.06.15)
- [NEWS] NUnit 2.5.3 リリース、 .NET 4.0 に対応(2009.12.16)
- [NEWS] NUnit 2.5.4 リリース(2010.04.20)

コメント
初めまして。質問させてくださいm(_ _)m
私は今まで、単体テストをする際、
単体テスト仕様書+単体テスト結果(エビデンス)を作成してきました。
しかし、この成果物は非常に労力の無駄になると思っています。
仕様変更→ソース修正→単体テスト仕様書修正→エビデンス修正
また、見栄えがいいように作成してほしい等、不要な注文もあります。
そこでTDDを取り入れると、
テスト仕様書、テスト結果が不要になる可能性はあるのでしょうか?
投稿: TDDBiginner | 2009年8月20日 (木) 11時17分
こちらへ質問移動したいと思います。
http://bbs.wankuma.com/index.cgi?mode=al2&namber=40320
投稿: TDDBiginner | 2009年8月24日 (月) 09時09分