t-hom’s diary

主にVBAネタを扱っているブログ…とも言えなくなってきたこの頃。

VBA Functionプロシージャの動作をテストするコード

堅牢なソフトウェアを作ろうと思ったら、テストは欠かせないプロセスだ。

ソフトウェアテストというのは、要するに「ちゃんと動くかどうか」を確認する作業なのだが、「使ってみました。たぶん問題ありません。」という簡単な話でもない。

私もあまりテストに関して知識が無かったので、以下の書籍を読んだ。

知識ゼロから学ぶソフトウェアテスト 【改訂版】

知識ゼロから学ぶソフトウェアテスト 【改訂版】

語り口が軽快でサクサク読めるので、テストに興味があるけれどまったく初めてという人にお勧め。

さて、この本で学んだ知識(同値分割法・境界値分析法)を基にFizzBuzzのテストを書いてみようと思う。

まずは、普通のFizzBuzzコード

Sub FizzBuzz()
    For i = 1 To 100
        Select Case 0
            Case i Mod 3 + i Mod 5
                Debug.Print "FizzBuzz"
            Case i Mod 3
                Debug.Print "Fizz"
            Case i Mod 5
                Debug.Print "Buzz"
            Case Else
                Debug.Print i
        End Select
    Next
End Sub

問題なく動作するが、戻り値が無いのでテストができない。

これをテスタブルにするためには、Functionプロシージャで関数化すれば良い。

Function fnFizzBuzz(x) As String
    Dim ret As String
    Select Case 0
        Case x Mod 3 + x Mod 5
            ret = "FizzBuzz"
        Case x Mod 3
            ret = "Fizz"
        Case x Mod 5
            ret = "Buzz"
        Case Else
            ret = CStr(x)
    End Select
    fnFizzBuzz = ret
End Function

Sub TestableFizzBuzz()
    For i = 1 To 100
        Debug.Print fnFizzBuzz(i)
    Next
End Sub

すると、fnFizzBuzz関数のテストを書くことができる。

まずは簡単なテストから。

Sub TestfnFizzBuzz1()
    Debug.Assert fnFizzBuzz(6) = "Fizz"
    Debug.Assert fnFizzBuzz(10) = "Buzz"
    Debug.Assert fnFizzBuzz(30) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(16) = "16"
    MsgBox "テスト完了"
End Sub

Debug.Assertは、Falseになるとコードが中断する命令である。
つまり、コードが中断せずに最後のメッセージ「テスト完了」が出たら、テストにパスしたということだ。

テストする値は、同値分割の考え方でFizzになる数値グループ、Buzzになる数値グループ、FizzBuzzになる数値グループ、数値のままに表示される数値グルーブに分けて、グループ代表の数値ひとつをピックアップする考え方である。

逆に、同値分割の考え方だけなら、同じグループでいくつも書く必要はない。
ただし、0はよくバグの元になるので、必ずテストする。
また、3、5、15はそれぞれ最小値のグループの最小値になるので、境界値と考えて良いのだろうか。
あまり自信がないが境界値分析法のつもりで一応入れておく。

また、Fizz以下の最大値2も加えてみた。

Sub TestfnFizzBuzz2()
    Debug.Assert fnFizzBuzz(0) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(2) = "2"
    Debug.Assert fnFizzBuzz(3) = "Fizz"
    Debug.Assert fnFizzBuzz(6) = "Fizz"
    Debug.Assert fnFizzBuzz(5) = "Buzz"
    Debug.Assert fnFizzBuzz(10) = "Buzz"
    Debug.Assert fnFizzBuzz(15) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(16) = "16"
    Debug.Assert fnFizzBuzz(30) = "FizzBuzz"
    MsgBox "テスト完了"
End Sub

これもパス。
次に、負数を与えてみたらどうなるのか。
数学的には、負数の余り算も成り立つので、パスしてくれないと困る。
マイナスゼロなんてのは無いけれど、一応いれてみた。

Sub TestfnFizzBuzz3()
    '正の数
    Debug.Assert fnFizzBuzz(0) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(2) = "2"
    Debug.Assert fnFizzBuzz(3) = "Fizz"
    Debug.Assert fnFizzBuzz(6) = "Fizz"
    Debug.Assert fnFizzBuzz(5) = "Buzz"
    Debug.Assert fnFizzBuzz(10) = "Buzz"
    Debug.Assert fnFizzBuzz(15) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(16) = "16"
    Debug.Assert fnFizzBuzz(30) = "FizzBuzz"
    
    '負の数
    Debug.Assert fnFizzBuzz(-0) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(-2) = "-2"
    Debug.Assert fnFizzBuzz(-3) = "Fizz"
    Debug.Assert fnFizzBuzz(-6) = "Fizz"
    Debug.Assert fnFizzBuzz(-5) = "Buzz"
    Debug.Assert fnFizzBuzz(-10) = "Buzz"
    Debug.Assert fnFizzBuzz(-15) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(-16) = "-16"
    Debug.Assert fnFizzBuzz(-30) = "FizzBuzz"
    MsgBox "テスト完了"
End Sub

これも無事にテストパス。

次にLong型の最大値境界と最小値境界をテスト

Sub TestfnFizzBuzz4()
    '~既出テストは省略~

    'Long型の最大値境界
    Debug.Assert fnFizzBuzz(2147483647) = "2147483647"
    Debug.Assert fnFizzBuzz(2147483648#) = "2147483648"
    
    'Long型の最小値境界
    Debug.Assert fnFizzBuzz(-2147483648#) = "-2147483648"
    Debug.Assert fnFizzBuzz(-2147483649#) = "-2147483649"
    
    MsgBox "テスト完了"
End Sub

おっと、ここでオーバーフローエラー
f:id:t-hom:20160228170209p:plain
f:id:t-hom:20160228170258p:plain

つまりfnFizzBuzzは、Long型の最大値2147483647を超えるとバグが発生する関数ということ。

ということで、関数本体を以下のように修正。
境界値を超えると"ERROR"という文字列を返すようにした。

Function fnFizzBuzz(x) As String
    Dim ret As String
    If x <= 2147483647 And x >= -2147483648# Then
        Select Case 0
            Case x Mod 3 + x Mod 5
                ret = "FizzBuzz"
            Case x Mod 3
                ret = "Fizz"
            Case x Mod 5
                ret = "Buzz"
            Case Else
                ret = CStr(x)
        End Select
    Else
        ret = "ERROR"
    End If
    fnFizzBuzz = ret
End Function

テストも以下のように修正する。

Sub TestfnFizzBuzz5()
    '~既出テストは省略~

    'Long型の最大値境界
    Debug.Assert fnFizzBuzz(2147483647) = "2147483647"
    Debug.Assert fnFizzBuzz(2147483648#) = "ERROR"
    
    'Long型の最小値境界
    Debug.Assert fnFizzBuzz(-2147483648#) = "-2147483648"
    Debug.Assert fnFizzBuzz(-2147483649#) = "ERROR"
    
    MsgBox "テスト完了"
End Sub

これでテストは無事にパス。

次に、悪いデータのテストを行う。
悪いデータとは、本来想定されていないデータのことで、たとえば文字列、日付、小数などを受け取ったときにコードがどう振る舞うかをテストする。

出来上がった最終のテストコードはこちら。

Sub TestfnFizzBuzzFinal()
    '正の数
    Debug.Assert fnFizzBuzz(0) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(2) = "2"
    Debug.Assert fnFizzBuzz(3) = "Fizz"
    Debug.Assert fnFizzBuzz(6) = "Fizz"
    Debug.Assert fnFizzBuzz(5) = "Buzz"
    Debug.Assert fnFizzBuzz(10) = "Buzz"
    Debug.Assert fnFizzBuzz(15) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(16) = "16"
    Debug.Assert fnFizzBuzz(30) = "FizzBuzz"
    
    '負の数
    Debug.Assert fnFizzBuzz(-0) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(-2) = "-2"
    Debug.Assert fnFizzBuzz(-3) = "Fizz"
    Debug.Assert fnFizzBuzz(-6) = "Fizz"
    Debug.Assert fnFizzBuzz(-5) = "Buzz"
    Debug.Assert fnFizzBuzz(-10) = "Buzz"
    Debug.Assert fnFizzBuzz(-15) = "FizzBuzz"
    Debug.Assert fnFizzBuzz(-16) = "-16"
    Debug.Assert fnFizzBuzz(-30) = "FizzBuzz"

    'Long型の最大値境界
    Debug.Assert fnFizzBuzz(2147483647) = "2147483647"
    Debug.Assert fnFizzBuzz(2147483648#) = "ERROR"
    
    'Long型の最小値境界
    Debug.Assert fnFizzBuzz(-2147483648#) = "-2147483648"
    Debug.Assert fnFizzBuzz(-2147483649#) = "ERROR"
    
    '文字列
    Debug.Assert fnFizzBuzz("15") = "FizzBuzz"
    Debug.Assert fnFizzBuzz("150,000") = "FizzBuzz"
    Debug.Assert fnFizzBuzz("aa") = "ERROR"
    
    '日付
    Debug.Assert fnFizzBuzz(Now) = "ERROR"
    Debug.Assert fnFizzBuzz(Date) = "ERROR"
    Debug.Assert fnFizzBuzz(Time) = "ERROR"
    
    '小数
    Debug.Assert fnFizzBuzz(0.1) = "ERROR"
    Debug.Assert fnFizzBuzz(-0.1) = "ERROR"
    Debug.Assert fnFizzBuzz(1E-100) = "ERROR"
    
    MsgBox "テスト完了"
    
End Sub

一旦本体はそのままでテストしてみると、Nowを渡したときにコードが中断した。
f:id:t-hom:20160228171432p:plain

フム。。

"ERROR"を返すコードは書いてないが、Nowを渡すと例外が発生すると思っていた。
しかし、Now Mod 3とすると、0になる。ちなみに5で割ると4、15で割ると9になった。
なんじゃこりゃ。。DateやTimeに対しても、余り算ができてしまう。

ということで、IsNumericで数値でないものをERRORとするように変更。
また、小数や文字列もパスするように本体を修正すると、こうなった。

Function fnFizzBuzz(x) As String
    Dim ret As String
    If IsNumeric(x) Then
        If x <= 2147483647 And x >= -2147483648# Then
            If Int(x) = CDbl(x) Then
                Select Case 0
                    Case x Mod 3 + x Mod 5
                        ret = "FizzBuzz"
                    Case x Mod 3
                        ret = "Fizz"
                    Case x Mod 5
                        ret = "Buzz"
                    Case Else
                        ret = CStr(x)
                End Select
            Else
                ret = "ERROR"
            End If
        Else
            ret = "ERROR"
        End If
    Else
        ret = "ERROR"
    End If
    fnFizzBuzz = ret
End Function

色々考慮したためコードがごちゃごちゃしてしまったが、実用に耐えられるプログラムというのはこうしたテストを経て作られるものだ。

そして、テストコードがあると良いのは、リファクタリングが簡単にできること。リファクタリングとは、挙動を変えずにコードを整理することである。コードの書き換えには常に「間違える」というリスクが伴う。テストコードがあれば、すぐに間違いに気づくことができる。

先ほど作成したfnFizzBuzzはあれで完成しているが、Ifのネストが分かりにくいので、禁断の「GoTo」を使って少しスッキリさせてみた。

Function fnFizzBuzz(x) As String
    Dim ret As String

    '例外チェック
    If Not IsNumeric(x) Then GoTo Exception
    If x > 2147483647 Then GoTo Exception
    If x < -2147483648# Then GoTo Exception
    If Int(x) <> CDbl(x) Then GoTo Exception
    
    Select Case 0
        Case x Mod 3 + x Mod 5
            ret = "FizzBuzz"
        Case x Mod 3
            ret = "Fizz"
        Case x Mod 5
            ret = "Buzz"
        Case Else
            ret = CStr(x)
    End Select
    GoTo Fin    '正常終了

Exception:
    ret = "ERROR"
Fin:
    fnFizzBuzz = ret
End Function

このような場合でも、先ほどと全く同じテストコードを用いて検証することができる。

今回は実行条件である「If x <= 2147483647 And x >= -2147483648# Then」を、不実行の条件である以下のコードに書き換えている。

    If x > 2147483647 Then GoTo Exception
    If x < -2147483648# Then GoTo Exception

不実行なので不等号の向きが逆になり、イコールは外れる。
しかしこれは特に間違えやすいポイントで、イコールを付けたままにしたり、向きを変え忘れたり、Notを付けたにも関わらず向きを変えてしまったりという間違いがよく起こるのだ。

このような間違いは、先ほどのテストコードを実行すればすぐに判明する。

堅牢なコードを書くうえで、テストコードを書いておくことは非常に有用である。

ただ、テストを書くのは結構面倒くさい。今回は説明にちょうど良い規模だったのでFizzBuzzを題材としたが、FizzBuzzのような人畜無害なプログラムならテストなんて作らなくても、冒頭で紹介した以下のSubプロシージャで十分だと思う。

Sub FizzBuzz()
    For i = 1 To 100
        Select Case 0
            Case i Mod 3 + i Mod 5
                Debug.Print "FizzBuzz"
            Case i Mod 3
                Debug.Print "Fizz"
            Case i Mod 5
                Debug.Print "Buzz"
            Case Else
                Debug.Print i
        End Select
    Next
End Sub

逆に、お金が絡むような処理、セキュリティに関わる処理など、ビジネスにクリティカルな影響を与える部分は必ずテストを書いておこう。

当ブログは、amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、 Amazonアソシエイト・プログラムの参加者です。