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」で作成しています。デザインは適当にこんな感じ。
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