作成日: 2016/08/22, 更新日: 2016/08/22
Friendly を使った WinForms アプリの自動テストを楽したい
- 以前、uwscを使ったWinFormsアプリの自動テストを楽したいという記事を書いたのですが、 やっぱり Visual Studio 上でテストしたいですよね。
- そこで今回は、Friendly(フレンドリー) を使って WinForms な画面を操作しつつ、MSTest 形式のテストコードを書いてテストしようと思います。 Friendly というのは、Win32、WinForms、WPF 等の画面を操作することができるフレームワークのことです。
- ただし、タイトルにもある通り、普通に動かしてテストするのではなく、前回同様、簡単に実装できるように、 ラッパークラスを作って使用した版で動かしたいと思います。
- 以降では、その話をしているわけですが、MSTest、Friendly、Ong.Friendly.FormsStandardControls の3つについて、既に知ってるよ&使い方わかるよ、という方であれば今回は話が早いかな、理解するのが容易かなと思います。 また、3つとも知らないという方も、もし興味があれば、ぜひこのまま読んでいただきたいと思っています。 そのために作成したのがこの記事みたいなものですので。
- ちなみに、MSTest はググればすぐに分かりますが、Friendly は、C#er さん向けのサイトしか見つけられなかったため、 VBer さん向けに紹介記事を作っておきたいとは思っていました。ただ、ここではその応用の話になるため、 そのうち、別記事として作ろうと思います。ただし、私自身まだ勉強中なので基礎レベルの記事になりそうです。 また、今回は、C# と VB のサンプルコードを載せています。
スポンサーリンク
Friendly をもっと簡単に、扱いやすく
- Friendly ではダイナミック型を使って対象の画面クラスを操作していきます( .NET Framework 4.0 以上の場合。それ以前の場合は、インデクサー経由で操作します)。 でもインテリセンスが効かないため、分かりやすい操作が出来る反面、ちょっと実装しにくいところがあります。 ただ、それでも満足していました。
- UI テスターは他に何があるのか調べていたら、FEST-Swing というツールにたどり着きました。 FEST-Swing は、Java 言語用の GUI ツールキットである Swing のテスティングツールなんだそうです。
- VB だったらこう書くだろう的な操作イメージ、型推論に頼る版
Dim form = WindowFinder.FindFrame("Form1") form.TextBox("textBox1").ChangeText("hello, friendly!") form.Button("button1").Click()
Friendly を隠蔽しちゃえ!
- 今回の目標は、【Friendly を知らない人でも、最低限のお作法で画面操作できる】ようにすることにしました。 具体的な着眼点は、WindowsAppFriend から取得した画面と、Ong.Friendly.FormsStandardControls でラッピングしたコントロールを、自然な流れでシームレスにつなげることができれば実現できるのでは、 と考えました。上記サンプルソースみたいなやつですね。このシームレスに次につなげる仕組み、これはつまり、 画面クラスやコントロールクラスの型を意識しなくても操作(実装)ができる、ということにつながります。 同時に、インテリセンスの恩恵も受けることもできます。
- 要するに、上のイメージみたいな操作をしたいよ、インテリセンスのサポートも受けたいよ。 両方叶えるには、上記着眼点を攻略する必要があるね。ということが分かりました。
- で、実現方法を考えたのですが、1.画面を見つける人、2.画面操作をサポートする人を担当者として作成することにしました。 1.があることで画面取得が容易になり、2.があることでコントロール取得と操作が容易になります。
画面を見つける人(FormFinder)
- プロダクトプロセス中で開いている画面を基に、欲しい画面を探し出すのが仕事です。
- C# コード
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Diagnostics; using Codeer.Friendly.Dynamic; using Codeer.Friendly.Windows; using Codeer.Friendly.Windows.Grasp; using Codeer.Friendly; using Codeer.Friendly.Windows.NativeStandardControls; using Ong.Friendly.FormsStandardControls; using System.Windows.Forms; using System.Threading; namespace FriendlyTest5.CSharp { // 捕まえたプロセスを基に、取得したい画面を見つけ出して返却するのが仕事 public static class FormFinder { public static FormOperator ActiveForm(WindowsAppFriend app) { dynamic form = null; while (form == null || ((AppVar)form).IsNull == true) { form = app.Type<Form>().ActiveForm; Thread.Sleep(100); } return new FormOperator(form); } public static FormOperator FindForm(WindowsAppFriend app, string controlName) { FormOperator result = null; while (true) { Enumerate forms = new Enumerate(app.Type<Application>().OpenForms); foreach (AppVar form in forms) { var name = (string)form["Name"]().Core; if (name.Contains(controlName)) { result= new FormOperator(form.Dynamic()); break; } } if(result != null) { break; } Thread.Sleep(100); } return result; } public static NativeMessageBox FindMessageBox(WindowsAppFriend app) { var dlgCore = WindowControl.FromZTop(app); var dlg = new NativeMessageBox(dlgCore); return dlg; } } }
Option Strict Off ' Dynamic を使いたいため Imports Codeer.Friendly Imports Codeer.Friendly.Dynamic Imports Codeer.Friendly.Windows Imports Codeer.Friendly.Windows.Grasp Imports Codeer.Friendly.Windows.NativeStandardControls Imports Ong.Friendly.FormsStandardControls Imports System.Threading Imports System.Windows.Forms ' 捕まえたプロセスを基に、取得したい画面を見つけ出して返却するのが仕事 Public Class FormFinder Public Shared Function ActiveForm(ByVal app As WindowsAppFriend) As FormOperator Dim form As Object = Nothing While (form Is Nothing) OrElse (CType(CType(form, DynamicAppVar), AppVar).IsNull = True) form = app.Type(Of Form)().ActiveForm Thread.Sleep(100) End While Return New FormOperator(form) End Function Public Shared Function FindForm(ByVal app As WindowsAppFriend, ByVal controlName As String) As FormOperator Dim result As FormOperator = Nothing While True Dim dItems As DynamicAppVar = CType(app.Type(Of Application)().OpenForms, DynamicAppVar) Dim aItems As AppVar = CType(dItems, AppVar) Dim forms As Enumerate = New Enumerate(aItems) For Each form As AppVar In forms Dim name As String = CType(form("Name")().Core, String) If name.Contains(controlName) Then result = New FormOperator(form.Dynamic()) Exit For End If Next If result IsNot Nothing Then Exit While End If Thread.Sleep(100) End While Return result End Function Public Shared Function FindMessageBox(ByVal app As WindowsAppFriend) As NativeMessageBox Dim dlgCore As WindowControl = WindowControl.FromZTop(app) Dim dlg As New NativeMessageBox(dlgCore) Return dlg End Function End Class
画面操作をサポートする人(FormOperator)
- 指定画面内にある各コントロール操作を、扱いやすくサポートするのが仕事です。 操作画面を内部に持っていて、ここから取得してきます。
- C# コード
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Diagnostics; using Codeer.Friendly.Dynamic; using Codeer.Friendly.Windows; using Codeer.Friendly.Windows.Grasp; using Codeer.Friendly; using Codeer.Friendly.Windows.NativeStandardControls; using Ong.Friendly.FormsStandardControls; using System.Windows.Forms; namespace FriendlyTest5.CSharp { // 指定画面内にある各コントロール操作を、扱いやすくサポートするのが仕事 public class FormOperator { // やりたい機能が提供されていない場合は、TargetForm を直接操作してください。 // xxx.TargetForm.button1.PerformClick() とか。 public dynamic TargetForm = null; public FormOperator(dynamic form) { TargetForm = form; } //public FormsButton Button(string name) //{ // AppVar control = GetControl(name); // return new FormsButton(control); //} //public Xxx1 Xxx2(string name) { return new Xxx1(GetControl(name)); } public FormsButton Button(string name) { return new FormsButton(GetControl(name)); } public FormsCheckBox CheckBox(string name) { return new FormsCheckBox(GetControl(name)); } public FormsCheckedListBox CheckedListBox(string name) { return new FormsCheckedListBox(GetControl(name)); } public FormsComboBox ComboBox(string name) { return new FormsComboBox(GetControl(name)); } public FormsDataGridView DataGridView(string name) { return new FormsDataGridView(GetControl(name)); } public FormsDateTimePicker DateTimePicker(string name) { return new FormsDateTimePicker(GetControl(name)); } public FormsLinkLabel LinkLabel(string name) { return new FormsLinkLabel(GetControl(name)); } public FormsListBox ListBox(string name) { return new FormsListBox(GetControl(name)); } public FormsListView ListView(string name) { return new FormsListView(GetControl(name)); } public FormsMaskedTextBox MaskedTextBox(string name) { return new FormsMaskedTextBox(GetControl(name)); } public FormsMonthCalendar MonthCalendar(string name) { return new FormsMonthCalendar(GetControl(name)); } public FormsNumericUpDown NumericUpDown(string name) { return new FormsNumericUpDown(GetControl(name)); } public FormsProgressBar ProgressBar(string name) { return new FormsProgressBar(GetControl(name)); } public FormsRadioButton RadioButton(string name) { return new FormsRadioButton(GetControl(name)); } public FormsRichTextBox RichTextBox(string name) { return new FormsRichTextBox(GetControl(name)); } public FormsTabControl TabControl(string name) { return new FormsTabControl(GetControl(name)); } public FormsTextBox TextBox(string name) { return new FormsTextBox(GetControl(name)); } public FormsToolStrip ToolStrip(string name) { return new FormsToolStrip(GetControl(name)); } public FormsTrackBar TrackBar(string name) { return new FormsTrackBar(GetControl(name)); } public FormsTreeView TreeView(string name) { return new FormsTreeView(GetControl(name)); } private AppVar GetControl(string controlName) { AppVar form = (AppVar)TargetForm; AppVar control = form[controlName](); return control; } } }
Option Strict Off ' Dynamic を使いたいため Imports Codeer.Friendly Imports Codeer.Friendly.Dynamic Imports Codeer.Friendly.Windows Imports Codeer.Friendly.Windows.Grasp Imports Codeer.Friendly.Windows.NativeStandardControls Imports Ong.Friendly.FormsStandardControls ' 指定画面内にある各コントロール操作を、扱いやすくサポートするのが仕事 Public Class FormOperator ' 実体は、DynamicAppVar 型 ' やりたい機能が提供されていない場合は、TargetForm を直接操作してください。 ' xxx.TargetForm.button1.PerformClick() とか。 Public TargetForm As Object = Nothing Public Sub New(ByVal form As Object) TargetForm = form End Sub Public Function Button(ByVal name As String) As FormsButton Dim control As AppVar = GetControl(name) Return New FormsButton(control) End Function Public Function CheckBox(ByVal name As String) As FormsCheckBox Dim control As AppVar = GetControl(name) Return New FormsCheckBox(control) End Function Public Function CheckedListBox(ByVal name As String) As FormsCheckedListBox Dim control As AppVar = GetControl(name) Return New FormsCheckedListBox(control) End Function Public Function ComboBox(ByVal name As String) As FormsComboBox Dim control As AppVar = GetControl(name) Return New FormsComboBox(control) End Function Public Function DataGridView(ByVal name As String) As FormsDataGridView Dim control As AppVar = GetControl(name) Return New FormsDataGridView(control) End Function Public Function DateTimePicker(ByVal name As String) As FormsDateTimePicker Dim control As AppVar = GetControl(name) Return New FormsDateTimePicker(control) End Function Public Function LinkLabel(ByVal name As String) As FormsLinkLabel Dim control As AppVar = GetControl(name) Return New FormsLinkLabel(control) End Function Public Function ListBox(ByVal name As String) As FormsListBox Dim control As AppVar = GetControl(name) Return New FormsListBox(control) End Function Public Function ListView(ByVal name As String) As FormsListView Dim control As AppVar = GetControl(name) Return New FormsListView(control) End Function Public Function MaskedTextBox(ByVal name As String) As FormsMaskedTextBox Dim control As AppVar = GetControl(name) Return New FormsMaskedTextBox(control) End Function Public Function MonthCalendar(ByVal name As String) As FormsMonthCalendar Dim control As AppVar = GetControl(name) Return New FormsMonthCalendar(control) End Function Public Function NumericUpDown(ByVal name As String) As FormsNumericUpDown Dim control As AppVar = GetControl(name) Return New FormsNumericUpDown(control) End Function Public Function ProgressBar(ByVal name As String) As FormsProgressBar Dim control As AppVar = GetControl(name) Return New FormsProgressBar(control) End Function Public Function RadioButton(ByVal name As String) As FormsRadioButton Dim control As AppVar = GetControl(name) Return New FormsRadioButton(control) End Function Public Function RichTextBox(ByVal name As String) As FormsRichTextBox Dim control As AppVar = GetControl(name) Return New FormsRichTextBox(control) End Function Public Function TabControl(ByVal name As String) As FormsTabControl Dim control As AppVar = GetControl(name) Return New FormsTabControl(control) End Function Public Function TextBox(ByVal name As String) As FormsTextBox Dim control As AppVar = GetControl(name) Return New FormsTextBox(control) End Function Public Function ToolStrip(ByVal name As String) As FormsToolStrip Dim control As AppVar = GetControl(name) Return New FormsToolStrip(control) End Function Public Function TrackBar(ByVal name As String) As FormsTrackBar Dim control As AppVar = GetControl(name) Return New FormsTrackBar(control) End Function Public Function TreeView(ByVal name As String) As FormsTreeView Dim control As AppVar = GetControl(name) Return New FormsTreeView(control) End Function Private Function GetControl(ByVal controlName As String) As AppVar Dim dItem As DynamicAppVar = CType(TargetForm, DynamicAppVar) Dim form As AppVar = CType(dItem, AppVar) Dim control As AppVar = form(controlName)() Return control End Function Public Shared Function GetData(Of T)(ByVal targetControl As Object, ByVal memberName As String) As T Dim item1 = CType(targetControl, DynamicAppVar) Dim item2 = CType(item1, AppVar) Dim item3 = CType(item2(memberName)().Core, T) Return item3 End Function End Class
スポンサーリンク
いっちょ、テストしてみっか
- 準備ができたので、テストをしてみます。 操作対象となる WinForms アプリは、足し算プログラムです。 NumericUpDown で数字を入力して、加算した結果をラベルとメッセージボックスで表示します。
Public Class Form1 Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click Dim i1 As Decimal = NumericUpDown1.Value Dim i2 As Decimal = NumericUpDown2.Value Dim item As Decimal = i1 + i2 Label4.Text = item.ToString() MessageBox.Show("答えは " & Label4.Text & " です。") End Sub End Class
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Diagnostics; using Codeer.Friendly.Dynamic; using Codeer.Friendly.Windows; using Codeer.Friendly.Windows.Grasp; using Codeer.Friendly; using Codeer.Friendly.Windows.NativeStandardControls; using Ong.Friendly.FormsStandardControls; using System.Windows.Forms; using System.Threading; namespace FriendlyTest5.CSharp { [TestClass] public class UnitTest1 { private string exeFile = "WindowsApplication1.exe"; private WindowsAppFriend app = null; [TestInitialize] public void TestInitialize() { // 実行中のプロセス一覧から操作対象プロセスを探して、見つけて操作する //app = new WindowsAppFriend(Process.Start(exeFile)); Process.Start(exeFile); var target = Process.GetProcessesByName(exeFile.Replace(".exe", string.Empty))[0]; app = new WindowsAppFriend(target); } [TestCleanup] public void TestCleanup() { CloseMainWindowIfExists(); app.Dispose(); } private void CloseMainWindowIfExists() { // 存在しないプロセスID を指定したときの GetProcessById メソッドの動作確認が未検証orz //Process.GetProcessById(app.ProcessId).CloseMainWindow(); // テストメソッド側で、プロセスごと終了してしまった場合ても大丈夫なように、 // 存在チェックしつつプロセス終了する。 var processes = Process.GetProcesses(); foreach (var process in processes) { if (process.Id == app.ProcessId) { process.CloseMainWindow(); break; } } } [TestMethod] public void TestMethod1() { // 操作が速すぎて見えないため、何かするたびに1秒待機しています。 var form = FormFinder.FindForm(app, "Form1"); Thread.Sleep(1000); form.NumericUpDown("NumericUpDown1").EmulateChangeValue(1); Thread.Sleep(1000); form.NumericUpDown("NumericUpDown2").EmulateChangeValue(2); Thread.Sleep(1000); // 次の画面が ShowDialog 系の場合、非同期処理で実行させないと、閉じるまでここで処理が止まってしまう form.Button("Button1").EmulateClick(new Async()); Thread.Sleep(1000); // メッセージボックスはネイティブウィンドウなので別枠での取得 var dlg = FormFinder.FindMessageBox(app); var actual = dlg.Message; var expected = "答えは 3 です。"; dlg.EmulateButtonClick("OK"); Assert.AreEqual(expected, actual, "計算結果が正しい結果のメッセージになっていること"); // Label が無いので、ダイナミック型で取得 actual = (string)form.TargetForm.Label4.Text; expected = "3"; Assert.AreEqual(expected, actual, "計算結果が正しい値で表示されていること"); } } }
Option Strict Off ' ダイナミック型を使って操作する場合は、コメントアウトを外します。 Imports System.Text Imports Microsoft.VisualStudio.TestTools.UnitTesting Imports Codeer.Friendly Imports Codeer.Friendly.Dynamic Imports Codeer.Friendly.Windows Imports Codeer.Friendly.Windows.Grasp Imports Codeer.Friendly.Windows.NativeStandardControls Imports Ong.Friendly.FormsStandardControls Imports System.Threading Imports System.Windows.Forms <TestClass()> Public Class UnitTest1 Private exeFile As String = "WindowsApplication1.exe" Private app As WindowsAppFriend = Nothing <TestInitialize()> Public Sub TestInitialize() ' 実行中のプロセス一覧から操作対象プロセスを探して、見つけて操作する ' app = New WindowsAppFriend(Process.Start(exeFile)) Process.Start(exeFile) Dim target As Process = Process.GetProcessesByName(exeFile.Replace(".exe", String.Empty))(0) app = New WindowsAppFriend(target) End Sub <TestCleanup()> Public Sub TestCleanup() CloseMainWindowIfExists() app.Dispose() End Sub Private Sub CloseMainWindowIfExists() ' 存在しないプロセスID を指定したときの GetProcessById メソッドの動作確認が未検証orz ' Process.GetProcessById(app.ProcessId).CloseMainWindow() ' テストメソッド側で、プロセスごと終了してしまった場合ても大丈夫なように、 ' 存在チェックしつつプロセス終了する。 Dim processes As Process() = Process.GetProcesses() For Each p As Process In processes If p.Id = app.ProcessId Then p.CloseMainWindow() Exit For End If Next End Sub <TestMethod()> Public Sub TestMethod1() ' 操作が速すぎて見えないため、何かするたびに1秒待機しています。 Dim form = FormFinder.FindForm(app, "Form1") Thread.Sleep(1000) form.NumericUpDown("NumericUpDown1").EmulateChangeValue(1) Thread.Sleep(1000) form.NumericUpDown("NumericUpDown2").EmulateChangeValue(2) Thread.Sleep(1000) ' 次の画面が ShowDialog 系の場合、非同期処理で実行させないと、閉じるまでここで処理が止まってしまう form.Button("Button1").EmulateClick(New Async) Thread.Sleep(1000) ' メッセージボックスはネイティブウィンドウなので別枠での取得 Dim dlg = FormFinder.FindMessageBox(app) Dim actual As String = dlg.Message Dim expected As String = "答えは 3 です。" dlg.EmulateButtonClick("OK") Assert.AreEqual(expected, actual, "計算結果が正しい結果のメッセージになっていること") ' Label が無いので、ダイナミック型で取得 ' ダイナミック型を使用する場合、冒頭の Option Strict Off を有効にします。 actual = FormOperator.GetData(Of String)(form.TargetForm.Label4, "Text") expected = "3" Assert.AreEqual(expected, actual, "計算結果が正しい値で表示されていること") End Sub End Class
サンプルコードのダウンロード
- 上記サンプルは、以下のリンクからダウンロードできます。
- 無料ダウンロードする(FriendlyTest5.zip, 52 KB)
- 軽くしたいため、NuGet からの取得分を削除しています。 NuGet の復元をしてほしいので、ビルド時は、インターネットに接続してビルドしてください。
- テストする時は、捜査対象の WinForms アプリケーション( WindowsApplication1 )をビルドして、 できた exe ファイルを、テスト dll と同じ場所に配置してください。
おわりに
- ところでこのネタ、まだ完成していなくて、キーボード操作(特にファンクションキー押下のエミュレート)、 操作中の画面キャプチャと画像ファイル保存する機能の2つが全然できていませんorz。 作んなきゃ・・・、とりあえず今日は休もう。
- そういえば、ここでは標準コントロール向けのラッパー集を利用しましたが、Infragistics、GrapeCity などベンダー向けのラッパー集もあるそうです。こちらの方がみなさん使われているんでしょうね。 最後までこの記事を読んでいただき、ありがとうございました。
スポンサーリンク