VB.NET 言語用の単体テストジェネレーターを自作して使う

いるかなーと思って。未来の自分が。

目次

単体テスト(のひな型)は、自動生成してほしい

Visual Studio 上で右クリックしても「単体テストの作成」コンテキストメニューが出なくて悲しいので、自作してみました。気になった方は、自分好みに改造して使ってくださいね。

以下にソースコードを載せておきます。メイン処理はコンソールアプリケーションで作成して、画面ありアプリは WinForms で、内部的にコンソールアプリを呼び出して実行依頼をするような仕組みです。

コンソールアプリケーション

プロジェクト名は「TestGenerator.Console」で作成しています。dll ファイルをリフレクションで読み込んで、単体テストを出力する処理を実施しています。

ArgumentData.vb

Imports System.IO

' コマンドラインからの指示を受け取る、作業指示データみたいなやつ
' LINQ は、.NET Framework 3.5? 以降
' Directory.EnumerateFileSystemEntries() は、.NET Framework 4.0 以降
Public Class ArgumentData

    Public Property DllFile As String
    Public Property Scope As Scopes
    Public Property OutputDirectory As String

    Public Sub New()

        Me.Scope = Scopes.Public
        Me.OutputDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Output")

    End Sub

    Public Sub Initialize()

        ' Output フォルダが無いなら作成、あるなら前回のデータを全削除した後再作成
        If Directory.Exists(Me.OutputDirectory) Then

            Dim files = Directory.EnumerateFileSystemEntries(Me.OutputDirectory, "*", SearchOption.AllDirectories)
            files.ToList().ForEach(Sub(x) File.SetAttributes(x, FileAttributes.Normal))
            Directory.Delete(Me.OutputDirectory, True)

        End If

        Directory.CreateDirectory(Me.OutputDirectory)

    End Sub

End Class

' Public メソッドのみ対象とするか Private 含む全アクセスレベルのメソッドを対象とするか
Public Enum Scopes
    [Public]
    All
End Enum

ReflectionHelper.vb

Imports System.IO
Imports System.Reflection
Imports System.Text
Imports System.Text.RegularExpressions

Public Class ReflectionHelper

    Public Sub Start(ByVal item As ArgumentData)

        Dim asm As Assembly = Assembly.LoadFrom(item.DllFile)
        Dim types As IEnumerable(Of Type) = asm.GetTypes().Where(Function(x) x.IsClass)
        types.ToList().ForEach(Sub(x) StartCore(item, x))

    End Sub

    Private Sub StartCore(ByVal item As ArgumentData, ByVal classType As Type)

        Dim publicFlags As BindingFlags = BindingFlags.Instance Or BindingFlags.Static Or BindingFlags.Public Or BindingFlags.DeclaredOnly
        Dim allFlags As BindingFlags = BindingFlags.Instance Or BindingFlags.Static Or BindingFlags.Public Or BindingFlags.NonPublic Or BindingFlags.DeclaredOnly
        Dim targetFlags As BindingFlags = If(item.Scope = Scopes.Public, publicFlags, allFlags)
        Dim methods As MethodInfo() = classType.GetMethods(targetFlags)
        Dim projectNS As String = classType.Namespace

        ' 挿入文字列が未対応の場合は、String.Format() などに書き換えてください
        Dim fileName As String = classType.Name
        If classType.IsGenericTypeDefinition Then
            Dim generics As IEnumerable(Of String) = classType.GetGenericArguments().Select(Function(x) x.Name)
            fileName &= String.Join("", generics)
            fileName = Regex.Replace(fileName, "`\d+", "")
        End If

        fileName &= "Test.vb"
        Dim testFile As String = Path.Combine(item.OutputDirectory, $"{fileName}")
        Dim className As String = fileName.Substring(0, fileName.Length - 3)
        OutputFile(projectNS, className, methods, testFile)

    End Sub

    Private Sub OutputFile(ByVal projectNS As String, ByVal className As String, ByVal methods As MethodInfo(), ByVal testFile As String)

        Dim sb As New StringBuilder
        sb.AppendLine("Imports System.Text")
        sb.AppendLine("Imports Microsoft.VisualStudio.TestTools.UnitTesting")
        sb.AppendLine($"Imports {projectNS}")
        sb.AppendLine("")

        sb.AppendLine("")
        sb.AppendLine("#Region ""Private なインスタンスメソッドのテスト方法""")
        sb.AppendLine("")
        sb.AppendLine("' ' Private なインスタンスメソッドのテスト")
        sb.AppendLine("' ' Private Sub Print()")
        sb.AppendLine("' ' Private Sub Print(s As String)")
        sb.AppendLine("' ' Private Function One() As Integer")
        sb.AppendLine("' ' Private Function Plus(i1 As Integer, i2 As Integer) As Integer")
        sb.AppendLine("")
        sb.AppendLine("' Dim helper As New PrivateObject(New Class1)")
        sb.AppendLine("")
        sb.AppendLine("")
        sb.AppendLine("' ' 戻り値無し系のメソッド")
        sb.AppendLine("' helper.Invoke(""Print"")")
        sb.AppendLine("' helper.Invoke(""Print"", ""bbb"")")
        sb.AppendLine("")
        sb.AppendLine("' ' 戻り値あり系のメソッド")
        sb.AppendLine("' Dim actual As Object = helper.Invoke(""One"")")
        sb.AppendLine("' Console.WriteLine(actual)")
        sb.AppendLine("")
        sb.AppendLine("' actual = helper.Invoke(""Plus"", 1, 2)")
        sb.AppendLine("' Console.WriteLine(actual)")
        sb.AppendLine("")
        sb.AppendLine("#End Region")
        sb.AppendLine("")
        sb.AppendLine("#Region ""Private な共有メソッドのテスト方法""")
        sb.AppendLine("")
        sb.AppendLine("' ' Private な共有メソッドのテスト")
        sb.AppendLine("' ' Private Shared Sub Show()")
        sb.AppendLine("' ' Private Shared Sub Show(s As String)")
        sb.AppendLine("' ' Private Shared Function Two() As Integer")
        sb.AppendLine("' ' Private Shared Function Minus(i1 As Integer, i2 As Integer) As Integer")
        sb.AppendLine("")
        sb.AppendLine("' Dim helper As New PrivateType(GetType(Class1))")
        sb.AppendLine("")
        sb.AppendLine("")
        sb.AppendLine("' ' 戻り値無し系のメソッド")
        sb.AppendLine("' helper.InvokeStatic(""Show"")")
        sb.AppendLine("' helper.InvokeStatic(""Show"", ""bbb"")")
        sb.AppendLine("")
        sb.AppendLine("' ' 戻り値あり系のメソッド")
        sb.AppendLine("' Dim actual As Object = helper.InvokeStatic(""Two"")")
        sb.AppendLine("' Console.WriteLine(actual)")
        sb.AppendLine("")
        sb.AppendLine("' actual = helper.InvokeStatic(""Minus"", 1, 2)")
        sb.AppendLine("' Console.WriteLine(actual)")
        sb.AppendLine("")
        sb.AppendLine("#End Region")
        sb.AppendLine("")


        sb.AppendLine("<TestClass()>")
        sb.AppendLine($"Public Class {className}")
        sb.AppendLine("")
        sb.AppendLine("#Region ""クラス / メソッド毎の初期化とクリーンアップ""")
        sb.AppendLine("")
        sb.AppendLine("    <ClassInitialize()>")
        sb.AppendLine("    Public Shared Sub ClassInitialize(context As TestContext)")
        sb.AppendLine("")
        sb.AppendLine("    End Sub")
        sb.AppendLine("")
        sb.AppendLine("    <ClassCleanup()>")
        sb.AppendLine("    Public Shared Sub ClassCleanup()")
        sb.AppendLine("")
        sb.AppendLine("    End Sub")
        sb.AppendLine("")
        sb.AppendLine("    <TestInitialize()>")
        sb.AppendLine("    Public Sub TestInitialize()")
        sb.AppendLine("")
        sb.AppendLine("    End Sub")
        sb.AppendLine("")
        sb.AppendLine("    <TestCleanup()>")
        sb.AppendLine("    Public Sub TestCleanup()")
        sb.AppendLine("")
        sb.AppendLine("    End Sub")
        sb.AppendLine("")
        sb.AppendLine("#End Region")
        sb.AppendLine("")

        ' オーバーロードの判定用にメソッド名リスト(重複含む)と、オーバーロードの場合、定義個数を管理する辞書を準備(見つけるたびに現在値を1カウントアップ)
        Dim methodNames As IEnumerable(Of String) = methods.Select(Function(x) GetMethodName(x))
        Dim overloadz As Dictionary(Of String, Integer) = methodNames.Distinct().ToDictionary(Function(x) x, Function(y) 0)

        For Each method As MethodInfo In methods

            Dim methodName As String = GetMethodName(method)
            overloadz(methodName) += 1

            If 1 < methodNames.Count(Function(x) x = methodName) Then
                methodName = $"{methodName}_o{overloadz(methodName)}_"
            End If

            sb.AppendLine("    <TestMethod()>")
            sb.AppendLine($"    Public Sub {methodName}Test()")
            sb.AppendLine("")
            sb.AppendLine("        Assert.Fail()")
            sb.AppendLine("")
            sb.AppendLine("    End Sub")
            sb.AppendLine("")

        Next

        sb.AppendLine("End Class")
        File.WriteAllText(testFile, sb.ToString(), New UTF8Encoding(True))

    End Sub

    ' ジェネリック名を含むメソッド名を返却
    Private Function GetMethodName(method As MethodInfo) As String

        Dim methodName As String = method.Name
        If method.IsGenericMethodDefinition Then
            Dim generics As IEnumerable(Of String) = method.GetGenericArguments().Select(Function(x) x.Name)
            methodName &= String.Join("", generics)
        End If

        Return methodName

    End Function

End Class

Module1.vb

Imports System.IO
Imports System.Text

Module Module1

    ' System.Console.WriteLine と わざわざ System を付けているのは、
    ' このプログラムの名前空間の Console とコンフリクトしているせいです。
    Sub Main(ByVal args As String())

        If args.Length = 0 Then
            System.Console.WriteLine(GetHowToUse())
            Return
        End If

        Dim item As New ArgumentData
        For i As Integer = 0 To args.Length - 1

            Dim key As String = args(i)
            Select Case key.ToLower()
                Case "-f" : item.DllFile = args(i + 1).Trim()
                Case "-a" : item.Scope = Scopes.All
                Case "-o" : item.OutputDirectory = args(i + 1).Trim()
                Case Else
            End Select

        Next

        If String.IsNullOrWhiteSpace(item.DllFile) Then
            System.Console.WriteLine("エラー: dll ファイルが不明です。dll ファイルを指定してください。")
            System.Console.WriteLine("処理を中断します。")
            Return
        End If

        If Not File.Exists(item.DllFile) Then
            System.Console.WriteLine("エラー: 指定された dll ファイルが見つかりませんでした。")
            System.Console.WriteLine("処理を中断します。")
            Return
        End If

        If item.OutputDirectory = "." Then
            item.OutputDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Output")
        End If

        ' 出力先フォルダの初期化(フォルダが存在すると、再作成されないバグの暫定対応。2回呼び出しする)
        item.Initialize()
        item.Initialize()

        ' 出力開始
        Try

            System.Console.WriteLine("")
            System.Console.WriteLine("処理中です ...")

            Dim helper As New ReflectionHelper
            helper.Start(item)

            System.Console.WriteLine("処理が完了しました!")
            System.Console.WriteLine("")


        Catch ex As Exception

            System.Console.WriteLine("------------------------------------------")
            System.Console.WriteLine("例外エラーが発生しました。")
            System.Console.WriteLine(ex.ToString())
            System.Console.WriteLine("------------------------------------------")

        End Try

    End Sub

    ' 使い方の説明文を返却
    Private Function GetHowToUse() As String

        Dim sb As New StringBuilder
        sb.AppendLine("---------------------------------------------------")
        sb.AppendLine("VB.NET 言語用の単体テスト生成ツール へようこそ!")
        sb.AppendLine("---------------------------------------------------")
        sb.AppendLine("")
        sb.AppendLine("使い方は以下の通りです。")
        sb.AppendLine("TestGenerator.Console.exe -f xxx.dll -a -o Output")
        sb.AppendLine("")
        sb.AppendLine("・必須")
        sb.AppendLine("-f(--file という意味)")
        sb.AppendLine("テストしたい dll ファイルを指定します。フルパスの場合、ダブルコーテーションで囲ってください。")
        sb.AppendLine("")
        sb.AppendLine("・任意")
        sb.AppendLine("-a(--all scope という意味)")
        sb.AppendLine("テスト対象のメソッドに関して、Public メソッドのみを対象とするか、Private 含めて全てのアクセスレベルのメソッドを対象とするかを指定します。-a を省略すると Public メソッドのみを対象とする、-a を記載すると、全てのアクセスレベルのメソッドを対象に出力します。")
        sb.AppendLine("")
        sb.AppendLine("-o(--output directory という意味)")
        sb.AppendLine("単体テストを出力するフォルダパスです。-o を省略すると、TestGenerator.Console.exe と同じ場所に Output フォルダを生成してその中に出力、-o で指定した場合は、そのフォルダに単体テストを出力します。フルパスの場合、ダブルコーテーションで囲ってください。もしもすでにそのフォルダが存在している場合、TestGenerator.Console.exe は ""強制的にフォルダを削除して再作成"" します。消されては困るデータがある場合は、実行前にバックアップしておいてください。")
        sb.AppendLine("")
        sb.AppendLine("")

        Return sb.ToString()

    End Function

End Module

WinForms アプリケーション

画面あり版も一応作成しました。プロジェクト名は「TestGenerator」で作成しています。デザインは適当にこんな感じ。

f:id:sutefu7:20190413054430p:plain

Imports System.IO
Imports System.Text
Imports System.Text.RegularExpressions

Public Class Form1

    Private Sub Form1_Shown(sender As Object, e As EventArgs) Handles MyBase.Shown

        Me.txtOutput.Text = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Output")

    End Sub

#Region "dll ファイルの入力欄"

    ' AllowDrop = True に変えている

    Private Sub txtDll_DragEnter(sender As Object, e As DragEventArgs) Handles txtDll.DragEnter

        If e.Data.GetDataPresent(DataFormats.FileDrop) Then
            e.Effect = DragDropEffects.Copy
        Else
            e.Effect = DragDropEffects.None
        End If

    End Sub

    Private Sub txtDll_DragDrop(sender As Object, e As DragEventArgs) Handles txtDll.DragDrop

        Dim fileNames As String() = CType(e.Data.GetData(DataFormats.FileDrop, False), String())
        For Each fileName As String In fileNames

            If fileName.ToLower().EndsWith(".dll") Or fileName.ToLower().EndsWith(".exe") Then
                Me.txtDll.Text = fileName
                Return
            End If
        Next

    End Sub

    Private Sub btnDll_Click(sender As Object, e As EventArgs) Handles btnDll.Click

        Using dlg As New OpenFileDialog

            dlg.Title = "ファイを選択してください"
            dlg.FileName = "*.dll"
            dlg.Filter = "dllファイル(*.dll)|*.dll|exeファイル(*.exe)|*.exe"

            If dlg.ShowDialog(Me) = DialogResult.OK Then
                Me.txtDll.Text = dlg.FileName
            End If

        End Using

    End Sub

#End Region

#Region "出力先フォルダの入力欄"

    ' AllowDrop = True に変えている

    Private Sub txtOutput_DragEnter(sender As Object, e As DragEventArgs) Handles txtOutput.DragEnter

        If e.Data.GetDataPresent(DataFormats.FileDrop) Then
            e.Effect = DragDropEffects.Copy
        Else
            e.Effect = DragDropEffects.None
        End If

    End Sub

    Private Sub txtOutput_DragDrop(sender As Object, e As DragEventArgs) Handles txtOutput.DragDrop

        Dim folderNames As String() = CType(e.Data.GetData(DataFormats.FileDrop, False), String())
        For Each folderName As String In folderNames

            If Directory.Exists(folderName) Then
                Me.txtOutput.Text = folderName
                Return
            End If
        Next

    End Sub

    Private Sub btnOutput_Click(sender As Object, e As EventArgs) Handles btnOutput.Click

        Using dlg As New FolderBrowserDialog

            dlg.Description = "フォルダを選択してください"

            If dlg.ShowDialog(Me) = DialogResult.OK Then
                Me.txtOutput.Text = dlg.SelectedPath
            End If

        End Using

    End Sub

#End Region

    Private Sub btnStart_Click(sender As Object, e As EventArgs) Handles btnStart.Click

        Dim dllFile As String = Me.txtDll.Text.Trim()
        Dim outputFolder As String = Me.txtOutput.Text.Trim()

        If Not File.Exists(dllFile) Then
            MessageBox.Show("指定されたdllファイルは見つかりませんでした。", "エラー", MessageBoxButtons.OK, MessageBoxIcon.Error)
            Return
        End If

        If Not Regex.IsMatch(outputFolder, "^\w:\\") Then
            MessageBox.Show("出力先フォルダはフルパスで指定してください。", "エラー", MessageBoxButtons.OK, MessageBoxIcon.Error)
            Return
        End If

        Try

            btnStart.Enabled = False
            OutputFile(dllFile, outputFolder, Me.rdoPublic.Checked)
            MessageBox.Show("処理が完了しました。", "インフォメーション", MessageBoxButtons.OK, MessageBoxIcon.Information)

        Catch ex As Exception
            MessageBox.Show(ex.ToString(), "エラー", MessageBoxButtons.OK, MessageBoxIcon.Error)
        Finally
            btnStart.Enabled = True
        End Try

    End Sub

    ' 挿入文字列が未対応の場合は、String.Format() などに書き換えてください
    Private Sub OutputFile(ByVal dllFile As String, ByVal outputFolder As String, ByVal isPublic As Boolean)

        Dim sb As New StringBuilder
        sb.Append($" -f {ControlChars.Quote}{dllFile}{ControlChars.Quote}")
        sb.Append($" -o {ControlChars.Quote}{outputFolder}{ControlChars.Quote}")

        If Not isPublic Then
            sb.Append($" -a ")
        End If

        Dim psi As New ProcessStartInfo
        psi.FileName = "TestGenerator.Console.exe"
        psi.Arguments = sb.ToString()

        Dim p As Process = Process.Start(psi)
        p.WaitForExit()

    End Sub

    Private Sub btnExit_Click(sender As Object, e As EventArgs) Handles btnExit.Click

        Me.Close()

    End Sub

End Class

自動生成された単体テストソース

リフレクションのフィルタ処理が緩いので余計なものも出力されていますので、改良の余地ありです。テストメソッドのメソッド名って、テストするメソッド名でいいのか問題でしたが、こちらもお好きなように改良の余地ありです。

オーバーロードのメソッドがある場合、メソッド名が重複しているのでリネームする必要があります。テストメソッド名に引数の型を書こうかと迷いましたが分かりづらいので止めました。

逆に?ジェネリッククラス、ジェネリックメソッドの場合は、ジェネリック引数の型のみ残すようにしました。Class1(Of T) なら Class1T みたいな感じです。後は、Private なメソッドを呼び出したい場合に備えて、サンプル操作もくっつけています。

以下は、出力サンプルです。

オリジナル

Public Class Class1(Of T)

    Public Function Plus(i1 As Integer, i2 As Integer) As Integer
        Return i1 + i2
    End Function

    Public Function Plus(i1 As Integer, i2 As Integer, i3 As Integer) As Integer
        Return i1 + i2 + i3
    End Function

    Public Sub MyDebug(obj As Object)

    End Sub

    Public Sub MyDebug(Of M)(obj As M)

    End Sub

    Public Function MyDebug2(Of M)(instance As M, dic As Dictionary(Of String, M)) As Dictionary(Of String, M)
        Return Nothing
    End Function

    ' Private

    Private Function Minus(i1 As Integer, i2 As Integer) As Integer
        Return i1 - i2
    End Function

    Private Function Minus(i1 As Integer, i2 As Integer, i3 As Integer) As Integer
        Return i1 - i2 - i3
    End Function

End Class

出力ソース

Imports System.Text
Imports Microsoft.VisualStudio.TestTools.UnitTesting
Imports ClassLibrary1


#Region "Private なインスタンスメソッドのテスト方法"

' ' Private なインスタンスメソッドのテスト
' ' Private Sub Print()
' ' Private Sub Print(s As String)
' ' Private Function One() As Integer
' ' Private Function Plus(i1 As Integer, i2 As Integer) As Integer

' Dim helper As New PrivateObject(New Class1)


' ' 戻り値無し系のメソッド
' helper.Invoke("Print")
' helper.Invoke("Print", "bbb")

' ' 戻り値あり系のメソッド
' Dim actual As Object = helper.Invoke("One")
' Console.WriteLine(actual)

' actual = helper.Invoke("Plus", 1, 2)
' Console.WriteLine(actual)

#End Region

#Region "Private な共有メソッドのテスト方法"

' ' Private な共有メソッドのテスト
' ' Private Shared Sub Show()
' ' Private Shared Sub Show(s As String)
' ' Private Shared Function Two() As Integer
' ' Private Shared Function Minus(i1 As Integer, i2 As Integer) As Integer

' Dim helper As New PrivateType(GetType(Class1))


' ' 戻り値無し系のメソッド
' helper.InvokeStatic("Show")
' helper.InvokeStatic("Show", "bbb")

' ' 戻り値あり系のメソッド
' Dim actual As Object = helper.InvokeStatic("Two")
' Console.WriteLine(actual)

' actual = helper.InvokeStatic("Minus", 1, 2)
' Console.WriteLine(actual)

#End Region

<TestClass()>
Public Class Class1TTest

#Region "クラス / メソッド毎の初期化とクリーンアップ"

    <ClassInitialize()>
    Public Shared Sub ClassInitialize(context As TestContext)

    End Sub

    <ClassCleanup()>
    Public Shared Sub ClassCleanup()

    End Sub

    <TestInitialize()>
    Public Sub TestInitialize()

    End Sub

    <TestCleanup()>
    Public Sub TestCleanup()

    End Sub

#End Region

    <TestMethod()>
    Public Sub Plus_o1_Test()

        Assert.Fail()

    End Sub

    <TestMethod()>
    Public Sub Plus_o2_Test()

        Assert.Fail()

    End Sub

    <TestMethod()>
    Public Sub MyDebugTest()

        Assert.Fail()

    End Sub

    <TestMethod()>
    Public Sub MyDebugMTest()

        Assert.Fail()

    End Sub

    <TestMethod()>
    Public Sub MyDebug2MTest()

        Assert.Fail()

    End Sub

    <TestMethod()>
    Public Sub Minus_o1_Test()

        Assert.Fail()

    End Sub

    <TestMethod()>
    Public Sub Minus_o2_Test()

        Assert.Fail()

    End Sub

End Class