« [ブログ紹介] 単体テスト用アドイン TestDriven.Net | トップページ | [ブログ紹介] TDD三原則 »

2009年8月17日 (月)

[TDD の練習] WinForm を改造したい ~ GUI に埋もれたロジックを分離して、ユニットテストを書く

Randommove6_01 ネタ元 ⇒ わんくま掲示板 : 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

|

« [ブログ紹介] 単体テスト用アドイン TestDriven.Net | トップページ | [ブログ紹介] TDD三原則 »

*TDD の練習」カテゴリの記事

<NUnit>」カテゴリの記事

コメント

初めまして。質問させてください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分

コメントを書く



(ウェブ上には掲載しません)




トラックバック

この記事のトラックバックURL:
http://app.cocolog-nifty.com/t/trackback/209349/45954814

この記事へのトラックバック一覧です: [TDD の練習] WinForm を改造したい ~ GUI に埋もれたロジックを分離して、ユニットテストを書く:

« [ブログ紹介] 単体テスト用アドイン TestDriven.Net | トップページ | [ブログ紹介] TDD三原則 »