WPFを基盤として作ったGUIプログラムについて、簡単に導入でき、且つ安定しており、且つユニットテストを可能とする範囲が広いライブラリを探った結果、Friendlyが最も開発チームに適しているとの考えに至った。
WPFプログラムのユニットテストを実現するライブラリたち
ネット検索で探した限り、WPFプログラムをユニットテスト1するライブラリ2は次のようなものを見つけた。
名前 | 私の感想 |
---|---|
Friendly | 最強。オンリーワンなのはDLLインジェクションをベースにしたアーキテクチャである点と感じた |
FlaUI | Friendlyを知ってからは次点。従来最強だと思ってた。他はUI Automationをベースにしている。UI Automationをベースにしているせいか、成功するはずのユニットテストが失敗に終わることが多い気がする。Visual Studioで「テスト実行」した際、その操作自体で成功するはずのユニットテストがFailすることがしばしばあった。 |
White | WPFで使う分にはFlaUIとそん色なさそうだが、どうやら古いらしく開発は止まっている模様 |
Appium | 実行環境が若干複雑になってしまう。クロスプラットフォームを重視する場合はありかも |
Visual Studio Enterprise付属ライブラリ | 名称不明。どうやらマイクロソフト謹製のGUI自動化?ライブラリがあるらしい。 |
入門
まだWPFプログラムの開発を始めたばかりのため事例が少なく、今後見えていなかった問題が顕在化する可能性はあるが、Friendlyが採用しているアーキテクチャを考えると致命的な問題はなさそう。そこで、今後のために一旦入門結果をまとめておく。
実装例
ユニットテストcsprojには次のFriendlyライブラリ(NuGetパッケージ)を参照に追加する。
- RM.Friendly.WPFStandardControls
- Codeer.Friendly.Windows.KeyMouse
[TestInitialize]
public void Initialize()
{
var path = System.IO.Path.GetFullPath(@"..\..\..\WpfApp1\bin\Debug\WpfApp1.exe");
// 被テスト対象プログラムを起動しFriendlyで操作可能にする
_app = new WindowsAppFriend(Process.Start(path));
// _winは念のため保持しておく
_win = _app.IdentifyFromTypeFullName("WpfApp1.MainWindow");
// 被テスト対象プログラムのラッパーを生成する
_drv = new WpfApp1Driver(_win);
}
例えば、被テスト対象プログラムの画面上にあるラベルの値を確認する方法は次のようになる。WPFXXXXはFriendlyで定義されているクラスであり、被テスト対象プログラムへの橋渡し役といったところである。
私のような入門者にはここで注意点がある。
FriendlyはDLLインジェクションを使って被テストプログラムを内部から操作するアーキテクチャを採用している。FlaUIのようにUI Automationを使って被テスト対象プログラムを外部から操作するアーキテクチャではない。
WPFXXXXは、ユニットテストプログラムと被テスト対象プログラムとをDLLインジェクションという経路を使って結びつけるための橋渡し役である。他のライブラリとは異なり、クリックという操作でUI Automationを使っているわけではない。
// "input"という名前のStackPanel中にLabelはひとつだけなので型だけで探す
var spInput = new WPFUIElement(_win.LogicalTree().ByType<StackPanel>().ByName("input").Single());
Label1 = new WPFContentControl(spInput.LogicalTree().ByType<Label>().Single());
var content = (string)Label1.Dynamic().Content;
ユニットテスト例を挙げてみる。被テスト対象プログラムのbutton1ボタンはフィートかメートルを切り替えるボタンである。右クリックするとftかmを選択でき、選択された単位をボタンのContentで表示するボタンである。
これをテストしてみる。
[TestMethod()]
public void WpfApp1Test_02()
{
// ftを選択するとボタンのContentがftに変わるか?
{
// 単位ボタンを右クリックする
_drv.Button1.Click(MouseButtonType.Right);
Assert.IsTrue(_drv.IsOpenContextMenu());
// ftラベルをクリックする
_drv.LabelMenu1.Click();
// 実行結果を確認する
var expect = "ft";
var actual = (string)_drv.Button1.Dynamic().Content;
// 合否判定する
Assert.AreEqual(expect, actual);
}
}
ボタンの右クリックはKeyMouseライブラリを使っているが、左クリックであればKeyMouseライブラリを使わなくともButton1.EmulateClick()
でクリックできる。
ひとつFriendlyらしい(というか他のライブラリにはまねのできない)機能を挙げておく。被テスト対象プログラムのLabel1を右クリックする手段として、ラベルのRaiseEvent()を呼び出すことができる。
// これは被テスト対象プログラム内にインスタンスを生成するコードになるそう
var primaryDevice = _app.Type(typeof(Mouse)).PrimaryDevice;
// これも同様に被テスト対象プログラム内にインスタンスを生成するコード
// DLLインジェクションを使って被テスト対象プログラムのメソッドを呼び出すため、
// 被テスト対象プログラム内で生成する必要がある(らしい)
var e = _app.Type<MouseButtonEventArgs>()(primaryDevice, 0, MouseButton.Right);
e.RoutedEvent = _app.Type(typeof(Mouse)).MouseUpEvent;
e.Source = Label1;
Label1.Dynamic().RaiseEvent(e);
// 同様にOnMouseUp()を呼び出すことも可能
_win.Dynamic().OnMouseUp(Label1, e);
入門したてで触りだけの話になるが、ここまでで次のように感じている。
- 被テスト対象プログラムの画面を操作するだけでなく、staticメソッドを呼び出すことができるため、ユニットテストの幅は他のライブラリに比べてユニットテストの対象が広くなる。
- ユニットテスト中はマウス操作を取られるため、ユニットテストをしながらネットサーフィンとか別のマウス操作できない点は同じだが、UI Automationに比べて影響を受けにくい気がする(気のせいかな?)。もしユニットテストしながらネットサーフィンする必要があるなら、先ほど挙げたRaiseEventを呼び出す方式にしてやればいい。
- 作者さんは日本人で、悩みとか相談しやすいかもしれない。実際私は行き詰ったところで助けていただいた。
世の中にはFriendlyを使ったコードのお手本が多いとは言いづらい。今後はGithub辺りにサンプルコードを積み上げられればいいなと思っている。
ソースコード全文
念のため、入門時にコーディングしたソースコード全文を載せておく。
被テスト対象
MainWindow.xaml
<Window x:Class="WpfApp1.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfApp1"
mc:Ignorable="d"
Title="MainWindow" Height="200" Width="300">
<DockPanel LastChildFill="True">
<StackPanel x:Name="input" Orientation="Horizontal" DockPanel.Dock="Top" >
<Label Content="身長:" Height="30" Width="52" HorizontalAlignment="Left" Margin="0,10,0,10"/>
<TextBox x:Name="value" Height="30" Width="120" Margin="0,10,0,10"/>
<Button x:Name="button1" Content="m" Height="30" HorizontalAlignment="Right" Width="60" Margin="0,10,0,10">
<Button.ContextMenu>
<ContextMenu>
<!-- 意図的にMenuItemではなくLabelを使っています -->
<Label x:Name="menu1" Content="ft" MouseUp="OnMouseUp"/>
<Label x:Name="menu2" Content="m" MouseUp="OnMouseUp"/>
</ContextMenu>
</Button.ContextMenu>
</Button>
</StackPanel>
<TextBlock Height="30" DockPanel.Dock="Bottom"></TextBlock>
<StackPanel DockPanel.Dock="Bottom">
<Button x:Name="calc" Content="計算" Width="120" Height="30" Click="calc_Click"/>
</StackPanel>
<TextBlock x:Name="result" Text="結果:" Height="30"></TextBlock>
</DockPanel>
</Window>
MainWindow.xaml.cs
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace WpfApp1
{
/// <summary>
/// MainWindow.xaml の相互作用ロジック
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
/// <summary>
/// 単位ボタンでコンテキストメニューが選択されたときのハンドラ。
/// 単位ボタンのContextは選択された単位に変更する。
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void OnMouseUp(object sender, MouseButtonEventArgs e)
{
Debug.WriteLine("OnMouseUp");
Debug.Assert(e != null);
if (e.Source is Label label)
{
// ボタンのContentはラベルで示される単位に変更する
button1.Content = label.Content;
}
else
{
Debug.Assert(e.Source is Label);
}
}
/// <summary>
/// 計算ボタンが押されたときのハンドラ
/// 計算ボタンが押されたら、入力された値と平均身長175cmより高いか低いか
/// 結果ラベルに表示する。
///
/// 平均身長より高い場合は「結果:175cmより高いです」と表示する
/// 平均身長より高くない場合は「結果:175cmより高くありません」と表示する
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void calc_Click(object sender, RoutedEventArgs e)
{
double m;
double aFeet = 0.3048;
double avarage = 1.75;
double v;
if (Double.TryParse(value.Text.ToString(), out v))
{
if (button1.Content.Equals("ft"))
{
m = v * aFeet;
}
else
{
m = v;
}
if (m > avarage)
{
result.Text = $"結果:{avarage*100}cmより高いです";
}
else
{
result.Text = $"結果:{avarage*100}cmより高くありません";
}
}
}
}
}
ユニットテスト
WpfApp1Tests.cs
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Codeer.Friendly.Dynamic;
using Codeer.Friendly.Windows;
using Codeer.Friendly.Windows.Grasp;
using RM.Friendly.WPFStandardControls;
using Codeer.Friendly.Windows.KeyMouse;
using System.Diagnostics;
using System.Windows.Input;
using System.Threading;
namespace WpfControlLibrary.Tests
{
[TestClass()]
public class WpfApp1Tests
{
private WindowsAppFriend _app;
private WindowControl _win;
private WpfApp1Driver _drv;
[TestInitialize]
public void Initialize()
{
var path = System.IO.Path.GetFullPath(@"..\..\..\WpfApp1\bin\Debug\WpfApp1.exe");
_app = new WindowsAppFriend(Process.Start(path));
_win = _app.IdentifyFromTypeFullName("WpfApp1.MainWindow");
_drv = new WpfApp1Driver(_win);
}
[TestCleanup]
public void Cleanup()
{
_app.Dispose();
Process process = Process.GetProcessById(_app.ProcessId);
process.CloseMainWindow();
}
/// <summary>
/// ラベルは"身長:"になっているか?
/// </summary>
[TestMethod()]
public void WpfApp1Test_01()
{
var expect = "身長:";
var actual = (string)_drv.Label1.Dynamic().Content;
Assert.AreEqual(expect, actual);
// コンテキストメニューは表示されていない
Assert.IsFalse(_drv.IsOpenContextMenu());
}
/// <summary>
/// 単位ボタンはContextMenuで選択した単位を表示するか?
/// </summary>
[TestMethod()]
public void WpfApp1Test_02()
{
// ftを選択するとボタンのContentがftに変わるか?
{
// 単位ボタンを右クリックする
_drv.Button1.Click(MouseButtonType.Right);
Assert.IsTrue(_drv.IsOpenContextMenu());
// ftラベルをクリックする
_drv.LabelMenu1.Click();
// 実行結果を確認する
var expect = "ft";
var actual = (string)_drv.Button1.Dynamic().Content;
// 合否判定する
Assert.AreEqual(expect, actual);
}
// mを選択するとボタンのContentがmに変わるか?
{
// 単位ボタンを右クリックする
_drv.Button1.Click(MouseButtonType.Right);
Assert.IsTrue(_drv.IsOpenContextMenu());
// mラベルをクリックする
_drv.LabelMenu2.Click();
// 実行結果を確認する
var expect = "m";
var actual = (string)_drv.Button1.Dynamic().Content;
// 合否判定する
Assert.AreEqual(expect, actual);
}
}
/// <summary>
/// ・単位ボタンを押して単位をftに変更する
/// ・身長5.8ftを入力する
/// ・計算ボタンを押す
/// この時、
/// ・結果は「175cmより高いです」と表示されるか?
/// </summary>
[TestMethod()]
public void WpfApp1Test_03()
{
var expect = "結果:175cmより高いです";
// 単位ボタンを押して単位をftに変更する
Assert.IsTrue(_drv.SelectFtLabel());
// 身長5.8ftを入力する
_drv.TextBox1.EmulateChangeText("5.8");
// 計算ボタンを押す
_drv.Button2.EmulateClick();
// 実行結果を確認する
var actual = (string)_drv.TextBlock1.Dynamic().Text;
// 合否判定する
Assert.AreEqual(expect, actual);
}
}
}
被テスト対象ドライバ
WpfApp1Driver.cs
using System.Diagnostics;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using Codeer.Friendly;
using Codeer.Friendly.Dynamic;
using Codeer.Friendly.Windows.Grasp;
using Codeer.Friendly.Windows.KeyMouse;
using RM.Friendly.WPFStandardControls;
namespace WpfControlLibrary.Tests
{
/// <summary>
/// 被テストプログラムへのアクセスをラッピングするクラス
/// </summary>
internal class WpfApp1Driver
{
public WindowControl MainWindow { get; private set; }
// "身長:"というラベル
public WPFContentControl Label1 { get; private set; }
// 身長の値を入力するテキストボックス
public WPFTextBox TextBox1 { get; private set; }
// 単位を切り替えるボタン(Feetとメートル)
public WPFButtonBase Button1 { get; private set; }
// 結果を表示するテキストブロック
public WPFTextBlock TextBlock1 { get; private set; }
// 計算を実行するボタン
public WPFButtonBase Button2 { get; private set; }
public AppVar ContextMenu1 { get; set; }
public WPFContentControl LabelMenu1 { get; private set; }
public WPFContentControl LabelMenu2 { get; private set; }
public WpfApp1Driver(WindowControl w)
{
MainWindow = w;
// "input"という名前のStackPanel中にLabelはひとつだけなので型だけで探す
var spInput = new WPFUIElement(w.LogicalTree().ByType<StackPanel>().ByName("input").Single());
Label1 = new WPFContentControl(spInput.LogicalTree().ByType<Label>().Single());
// プログラム中にTextBoxはひとつだけなので型だけで探す
TextBox1 = new WPFTextBox(w.LogicalTree().ByType<TextBox>().Single());
// ボタンもひとつだけだが名前がついていれば名前をキーにして一意に識別する方が妥当
Button1 = new WPFButtonBase(w.LogicalTree().ByType<ButtonBase>().ByName("button1").Single());
TextBlock1 = new WPFTextBlock(w.LogicalTree().ByType<TextBlock>().ByName("result").Single());
Button2 = new WPFButtonBase(w.LogicalTree().ByType<ButtonBase>().ByName("calc").Single());
var contextMenus = w.VisualTreeWithPopup().ByType("System.Windows.Controls.ContextMenu");
ContextMenu1 = contextMenus[0];
var labels = ContextMenu1.LogicalTree().ByType("System.Windows.Controls.Label");
LabelMenu1 = new WPFContentControl(labels[0]);
LabelMenu2 = new WPFContentControl(labels[1]);
}
/// <summary>
/// コンテキストメニューが表示されている否かを返す。
/// </summary>
/// <returns>コンテキストメニューが表示されている場合は真を返す。</returns>
public bool IsOpenContextMenu()
{
var contextMenus = MainWindow.VisualTreeWithPopup().ByType("System.Windows.Controls.ContextMenu");
var contextMenu = contextMenus[0];
return (bool)contextMenu.Dynamic().IsOpen();
}
/// <summary>
/// 単位ボタンをクリックしてftを選択する
/// </summary>
/// <returns></returns>
public bool SelectFtLabel()
{
Button1.Click(MouseButtonType.Right);
// Button1を右クリックしてコンテキストメニューが開いていないはずがない
Debug.Assert(IsOpenContextMenu());
LabelMenu1.Click();
return "ft" == (string)Button1.Dynamic().Content;
}
/// <summary>
/// 単位ボタンをクリックしてmを選択する
/// </summary>
/// <returns></returns>
public bool SelectMLabel()
{
Button1.Click(MouseButtonType.Right);
// Button1を右クリックしてコンテキストメニューが開いていないはずがない
Debug.Assert(IsOpenContextMenu());
LabelMenu2.Click();
return "m" == (string)Button1.Dynamic().Content;
}
}
}
その他
今後の課題
- いろんなデザインの画面をテストしてみる。(例)表、タブ遷移、モーダルダイアログ、etc
- Friendlyが関係しているか否か不明です。DLLインジェクションという性質が関係してそうだが、ApexOneというウィルスチェッカに誤検出されたことがあった。何だったのか詳細不明。