VBAでメタプログラミング 動的にテスト関数を検索して実行する

VBAは、言語的には、VB6ベースの言語で、自由も無い言語ですが、エクセルを読んだり書いたりするような、ちょっとしたツールを作成するのには便利だったりする。でもさすがにVBなので、プログラミングしてるといろんなところで我慢しなければならないことが多い。


以前、ユニットテストモジュールみたいなものがあった方がいいかと思ったこともあったけど、引数の無いSubを定義して、カーソルがSubのところにある状態でF5を押すだけで、そのテストメソッドを実行することができるので、今はこの方法でテストメソッドを実行している。assert文は、Debug.Assertというのがあるが、これまた微妙なもので、引数がTrueになるときにはなにも起こらず、引数がFlaseになるときはデバッグモード(ブレイクポイントで止まった状態になる)になるだけ。テストの自動化は難しそうだけど、今はこれを使ってテストメソッドを書いてる。


メタプログラミング(風)

テストメソッドが増えてくると、纏めて実行したい。しかし、TestSuite用のメソッドを自分で作ってテストメソッドが増えたら、TestSuiteもメンテするというのは、辛い。そこで、test_で始まる引数も戻り値も無い関数一覧を実行するという仕組みを作ってみることにした。

モジュール・クラスのソースコードを取得してテストメソッドを検索する

最近の言語のように動的に、動的に定義されているメソッド一覧などを取得する機能は無さそうだったので、モジュールやクラスのソースコードを取得して、解析してtest_で始まる関数(Sub)を探すことにした。モジュールやクラスのソースコードを取得することができるというのも驚きだったけど、検索すると意外と多くヒットするので、これは良く知られていることなのかもしれない。

Function GetAllTestMethodNames() As Collection
  Dim testSubs As Collection
  Set testSubs = New Collection
  Dim m As Object
  For Each m In ThisWorkbook.VBProject.VBComponents
    Dim moduleName As String
    moduleName = m.name
    Dim codeModule As Object
    Set codeModule = m.codeModule
    If codeModule.CountOfLines > 1 Then
      Dim content As String
      content = codeModule.Lines(1, codeModule.CountOfLines)
      Dim line As Variant
      For Each line In Split(content, vbNewLine)
        Dim subIndex As Integer, parenIndex As Integer
        Dim subName As String
        subIndex = InStr(line, "Sub ")
        parenIndex = InStr(line, "(")
        If subIndex > 0 And parenIndex > subIndex Then
          subName = Mid(line, subIndex + Len("Sub "), parenIndex - (subIndex + Len("Sub ")))
          If Left(subName, Len("test_")) = "test_" Then
            Debug.Print "found." & subName
            Call testSubs.Add(moduleName & "." & subName)
          End If
        End If
      Next
    End If
  Next
  Set GetAllTestMethodNames = testSubs
End Function

これで、テストメソッドの一覧をコレクションで取得することができるようになった。

モジュールの作成・削除

次は、生成したTestSuiteを格納するメソッドを配置する場所を作成したり削除したりする関数を作成した。

Sub deleteModule1()
  On Error Resume Next
  Dim component As Object
  For Each component In ThisWorkbook.VBProject.VBComponents
    If component.Type = 1 And component.name = "Module1" Then
      Application.VBE.activeVBProject.VBComponents.Remove component
    End If
  Next component
End Sub
 
Sub addModule1()
  Application.VBE.activeVBProject.VBComponents.Add (1)
End Sub

本当は任意のモジュール名のモジュールを作成したり削除したりしたかったけど、モジュール名を変更する手段がわからなかったので、Module1という名前のモジュールを使うことにした。このコードを使おうとしてる変わった人がいるなら、Module1というモジュールが存在しないことを確認しないとModule1が消えてしまいます。

コードの動的追加

その次は、Module1に対して動的に、コードを追加するコード。

Sub DefineTestSuiteSub(testSubNames As Collection)
  Call deleteModule1
  Call addModule1
 
  Dim code As String
  code = "'--------------------------------------generated sub" & vbNewLine & _
  "Sub TestSuite()" & vbNewLine
 
  Dim s As Variant
  For Each s In testSubNames
    code = code & "   Call " & s & vbNewLine
  Next
 
  code = code & "End Sub"
  Call ThisWorkbook.VBProject.VBComponents.Item("Module1").codeModule.AddFromString(code)
End Sub
テスト実行

最後に今までのメソッドを呼び出して、自動生成したモジュールの関数を呼び出す。

Sub ExecuteAllTest()
  Dim testSubs As Collection
  Set testSubs = GetAllTestMethodNames
  Call DefineTestSuiteSub(testSubs)
  Call ExecTestSuite
End Sub
 
Sub ExecTestSuite()
  Call TestSuite
End Sub

ExecTestSuiteは、TestSuite(自動生成した関数)を呼び出すだけだが、ExecuteAllTestの内部で参照しようとすると、まだ存在しないのでエラーになる。それを回避するために、別関数に切り出した。VBAのコンパイルは関数単位にしか行なわれないらしい。


まとめ

やってることが汚なすぎてメタプログラミングとは呼べないけど、test_から始まる名前で定義されているメソッドを動的に実行することに成功した。基本、Debug.Assertを使ってるので、Assertエラーがある場合には、Assert文でプログラムが中断する。改善点はたくさんあるけど、改善はしないと思う。VBAって結構無茶しても速度的に問題が出ることがあるものの意外と落ちたりせずに、ちゃんと動いてくれるから、VBAすごいと思った。

TestUtility

TestUtilityモジュールの全体です。

Option Explicit
 
Sub ExecuteAllTest()
  Dim testSubs As Collection
  Set testSubs = GetAllTestMethodNames
  Call DefineTestSuiteSub(testSubs)
  Call ExecTestSuite
End Sub
 
Sub ExecTestSuite()
  Call TestSuite
End Sub
 
Sub DefineTestSuiteSub(testSubNames As Collection)
  Call deleteModule1
  Call addModule1
 
  Dim code As String
  code = "'--------------------------------------generated sub" & vbNewLine & _
  "Sub TestSuite()" & vbNewLine
 
  Dim s As Variant
  For Each s In testSubNames
    code = code & "   Call " & s & vbNewLine
  Next
 
  code = code & "End Sub"
  Call ThisWorkbook.VBProject.VBComponents.Item("Module1").codeModule.AddFromString(code)
End Sub
 
Function GetAllTestMethodNames() As Collection
  Dim testSubs As Collection
  Set testSubs = New Collection
 
  Dim m As Object
  For Each m In ThisWorkbook.VBProject.VBComponents
    Dim moduleName As String
    moduleName = m.name
    Dim codeModule As Object
    Set codeModule = m.codeModule
    If codeModule.CountOfLines > 1 Then
      Dim content As String
      content = codeModule.Lines(1, codeModule.CountOfLines)
      Dim line As Variant
      For Each line In Split(content, vbNewLine)
        Dim subIndex As Integer, parenIndex As Integer
        Dim subName As String
        subIndex = InStr(line, "Sub ")
        parenIndex = InStr(line, "(")
        If subIndex > 0 And parenIndex > subIndex Then
          subName = Mid(line, subIndex + Len("Sub "), parenIndex - (subIndex + Len("Sub ")))
          If Left(subName, Len("test_")) = "test_" Then
            Debug.Print "found." & subName
            Call testSubs.Add(moduleName & "." & subName)
          End If
        End If
      Next
    End If
  Next
  Set GetAllTestMethodNames = testSubs
End Function
 
Sub deleteModule1()
  On Error Resume Next
 
  Dim component As Object
  For Each component In ThisWorkbook.VBProject.VBComponents
    If component.Type = 1 And component.name = "Module1" Then
      Application.VBE.activeVBProject.VBComponents.Remove component
    End If
  Next component
End Sub
 
Sub addModule1()
  Application.VBE.activeVBProject.VBComponents.Add (1)
End Sub

Leave a Comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.