VBAだってユニットテストがしたい！

*2019/6/2追記*
改訂版を投稿しました。
こちらをご覧ください。

[【改訂版】VBAだってユニットテストがしたい！](https://qiita.com/hiki_neet_p/items/860a16f44ad835624ce6)

# 前提

Microsoft Officeに付属するVBEは最も普及しているIDEといっていいのではないでしょうか。業務の現場では、簡単なExcelワークシートの編集から、Accessなどによる大規模なアプリまで、幅広く使用されていることと思います。

しかし、開発に使われる言語であるVBAは、絶望的に不便な言語です。モダンな言語がしのぎを削る現在において、ここまでプログラマを舐めきった言語はそうないでしょう。

しかし、私は仕事上どうしてもExcel VBA（並びにVBScript）での開発をやらねばなりません。VBAも一応はプログラミング言語。規模の大きなプロダクトを作る能力を持っています。そのようなプロダクトの開発においては、当然ながらきちんとテストを実施する必要があります。

本記事では、Excel VBAを題材に、VBAでテストを行なう手法について考察してみたいと思います。

この記事を開いてくださった方の中にも、私のような境遇にあるというプログラマがいらっしゃるかもしれません。そうした方々の一助となれば幸いです。

# 目次

* クラスモジュールの使用
* 副作用の隔離
* ファクトリ
* モック
* DI
* ユニットテスト

# クラスモジュールの使用

目次を見て気づかれた方もおられると思いますが、本記事ではDI（依存性の注入）により機能をテストダブルに置き換える手法をとります。

サービスにあたる振る舞いのみのモジュールは、標準モジュールとして定義するのが普通かと思いますが、DIのためにサービスもクラスモジュールとして定義しましょう。

# 副作用の隔離

Excel VBAによるアプリは、Excelワークシートに対する読み書きが多く発生するので、副作用をきちんと管理しておかないとテスト可能性が低くなります。

副作用といっても、ダイアログの表示やファイル入出力等、色々ありますが、ここではExcelワークシートの単純な読み書きについて考えてみたいと思います。

まずは、Excelの読み書きの機能を取り決めた`IExcelIO`インターフェイスを定義します。

```vb:IExcelIO
Option Explicit

Public Function OpenBook(ByVal FilePath As String) As Workbook
End Function

Public Function GetSheet(ByVal Book As Workbook, ByVal SheetName As String) As Worksheet
End Function

Public Function ReadCell(ByVal Sheet As Worksheet, ByVal Row As Long, ByVal Col As Long) As Variant
End Function

Public Sub WriteCell(ByVal Sheet As Worksheet, ByVal Row As Long, ByVal Col As Long, ByVal Value As Variant)
End Sub
```

次に、Excelの読み書きを扱うサービスとして、`ExcelIO`クラスを定義します。上記の`IExcelIO`インターフェイスを実装させます。

```vb:ExcelIO
Option Explicit

Implements IExcelIO

Public Function IExcelIO_OpenBook(ByVal FilePath As String) As Workbook
    
    Set IExcelIO_OpenBook = Application.Workbooks.Open(FilePath)
    
End Function

Public Function IExcelIO_GetSheet(ByVal Book As Workbook, ByVal SheetName As String) As Worksheet
    
    Dim Sheet As Object
    
    For Each Sheet In Book.Worksheets
        If Sheet.Name = SheetName Then
            Set IExcelIO_GetSheet = Sheet
            Exit Function
        End If
    Next
    
    Set IExcelIO_GetSheet = Nothing
    
End Function

Public Function IExcelIO_ReadCell(ByVal Sheet As Worksheet, ByVal Row As Long, ByVal Col As Long) As Variant
    
    IExcelIO_ReadCell = Sheet.Cells(Row, Col).Value
    
End Function

Public Sub IExcelIO_WriteCell(ByVal Sheet As Worksheet, ByVal Row As Long, ByVal Col As Long, ByVal Value As Variant)
    
    Sheet.Cells(Row, Col).Value = Value
    
End Sub
```

この`ExcelIO`クラスは、Excelワークシートを読み書きする上で最低限の機能をラッピングしただけのものです。しかし、単純な読み書きのみのアプリであれば、ここに隔離した副作用で事足ります。

こうして隔離しておけば、テストの際にこのクラスをテストダブルに置き換えてしまうことで、単体テストの環境が整います。

# ファクトリ

DIの対象となるのはサービスですが、実際の開発では複数のサービスクラスが存在するでしょう。管理を楽にするために、それらを生成するファクトリを用意しておきましょう。

```vb:Factory
Option Explicit

Public Constants as IConstants
Public ExcelIO As IExcelIO
Public XXXService as IXXXService

Private Sub Class_Initialize()
    
    Set Constants = New Constants

    Set ExcelIO = New ExcelIO

    Set XXXService = New XXXService
    Call XXXService.Init(Constants)
    
End Sub
```

# モック

さて、ここで以下のようなクラスをテストしたいとしましょう。

* (1, 1)セルと(2, 1)セルの値を読み込む
* (3, 1)セルに両者を足し合わせた値を書き込む

これをテストするためのモックを用意します。

```vb:Mock_ExcelIO
Option Explicit

Implements IExcelIO

Public TargetFilePath As String
Public TargetSheetName As String
Public TargetRow As Long
Public TargetCol As Long
Public WrittenValue As Variant

Public Function IExcelIO_OpenBook(ByVal FilePath As String) As Workbook
    
    TargetFilePath = FilePath
    Set IExcelIO_OpenBook = Nothing
    
End Function

Public Function IExcelIO_GetSheet(ByVal Book As Workbook, ByVal SheetName As String) As Worksheet
    
    TargetSheetName = SheetName
    Set IExcelIO_GetSheet = Nothing
    
End Function

Public Function IExcelIO_ReadCell(ByVal Sheet As Worksheet, ByVal Row As Long, ByVal Col As Long) As Variant
    
    IExcelIO_ReadCell = Row + Col
    
End Function

Public Sub IExcelIO_WriteCell(ByVal Sheet As Worksheet, ByVal Row As Long, ByVal Col As Long, ByVal Value As Variant)
    
    TargetRow = Row
    TargetCol = Col
    WrittenValue = Value
    
End Sub
```

場合によっては、ファクトリも用意しましょう（今回は使用しません）。

```vb:Mock_Factory
Option Explicit

Public Constants as IConstants
Public ExcelIO As IExcelIO
Public XXXService as IXXXService

Private Sub Class_Initialize()
    
    Set Constants = New Mock_Constants

    Set ExcelIO = New Mock_ExcelIO

    Set XXXService = New Mock_XXXService
    Call XXXService.Init(Constants)
    
End Sub
```

# DI

DI（依存性の注入）は、具象型を外部で生成しておき、それを対象クラスの抽象型フィールドに注入することで、型に関する依存性を解決する手法です。今回は、初期化メソッドの引数に注入する方法をとります。

前節で示したテスト対象は、以下のようなものでした。

* (1, 1)セルと(2, 1)セルの値を読み込む
* (3, 1)セルに両者を足し合わせた値を書き込む

このクラスに、`Init`メソッドを通じてサービスを注入します。以下にコードを示します。

```vb:SampleClass
Option Explicit

Private ExcelIO_ As IExcelIO

Public Sub Init(ByVal ExcelIO As IExcelIO)
    
    Set ExcelIO_ = ExcelIO
    
End Sub

Public Sub SampleMethod()
    
    Dim Book As Workbook: Set Book = ExcelIO_.OpenBook("C:\Users\UserName\Desktop\Test.xlsx")
    Dim Sheet As Worksheet: Set Sheet = ExcelIO_.GetSheet(Book, "Sheet1")
    
    Dim Value1 As Long: Value1 = ExcelIO_.ReadCell(Sheet, 1, 1)
    Dim Value2 As Long: Value2 = ExcelIO_.ReadCell(Sheet, 2, 1)
    
    Call ExcelIO_.WriteCell(Sheet, 3, 1, Value1 + Value2)
    
End Sub
```

# ユニットテスト

テストにおいては、`SampleClass`に注入されるのは`Mock_ExcelIO`クラスです。

テストはクラスである必要がないので、標準モジュールに定義します。

```vb:Test_SampleClass
Option Explicit

Public Sub Test_SampleMethod()
    
    Dim Mock_ExcelIO As Mock_ExcelIO: Set Mock_ExcelIO = New Mock_ExcelIO
    
    Dim SampleClass As SampleClass: Set SampleClass = New SampleClass
    Call SampleClass.Init(Mock_ExcelIO)
    
    Call SampleClass.SampleMethod
    
    Debug.Assert "C:\Users\UserName\Desktop\Test.xlsx" = Mock_ExcelIO.TargetFilePath
    Debug.Assert "Sheet1" = Mock_ExcelIO.TargetSheetName
    Debug.Assert 3 = Mock_ExcelIO.TargetRow
    Debug.Assert 1 = Mock_ExcelIO.TargetCol
    Debug.Assert 5 = Mock_ExcelIO.WrittenValue
    
End Sub
```

実行ボタンを押してエラー停止しなければ、テストが通っています。

以上により、テストダブルを用いたユニットテストを正常に行なうことができました。

# まとめ

* テストコード以外はクラスモジュールとして定義する。
* 副作用を特定のサービスクラスに隔離する。
* モックを作成し、DIで注入する。
* テストはツライが楽しく行なう。

以上です。ありがとうございました。