はじめに
2015年の暮れに、結合テスト自動化ツール「Friendly」についての小説仕立ての解説記事を内輪向けに書きました。そのまま腐らせておくのももったいないし、もし誰かの参考になるのならと思い、思い切って公開することにしました。
したがって記事の内容は2015年当時のものであり、それ以降はFriendlyの動向もあまり追いかけていないので、__現時点ですでに情報が古い可能性がある__ことに留意してください。
なお、本記事は「関数型プログラミングに目覚めた! IQ145の女子高生の先輩から受けた特訓5日間」とは一切関係ありません。
テスト自動化に目覚めた! IQ100の女子高生の先輩から受けた特訓30分
プロローグ
「ずいぶんとダサいコードを書いてるのね」
不意に背後から声をかけられた。セキナが振り向くと白い首筋が目の前にあった。同時に蜂蜜の甘い香りがした。この地方には蜂蜜を髪に塗りこむ風習はないから、これはきっとシャンプーの香りだ。
「はっ、ササクラ先輩!」
ササクラ先輩は、セキナの背後から前かがみにモニターを覗き込んでいた。たいして身長の高くない彼女の足はバレリーナのようにつま先立ちで、モニターの文字を目で追いながらプルプルと震えていた。
セキナが椅子を横にずらして場所を譲ると、ササクラ先輩は「ありがとう」と礼を言ってディスプレイの前に陣取った。
放課後のコンピュータ室(かつて電子計算機室と呼ばれていた部屋だ)は、コンピュータ部が自由に使用していいという取り決めがなされていた。今日は他の部員は誰も来ていない。ずっとセキナ一人きりで黙々と作業をしていたのだが、いつのまにか元部長であるササクラが様子を見に来ていたようだ
ちなみにササクラは高校三年生で、今は十二月だからセンター試験まであと一ヶ月もないはずだ。本来はこんなところに顔を出せるほど暇な人ではないのだが。部長の肩書も、二学期に入ってからは二年生の部員に譲られていた。
「セキヤ君は何のプロジェクトやってるんだっけ?」
「セキナです。今は何もしてません。ちょっと個人的に、WPFを使ってアプリを作ってました。あと先輩、受験大丈夫ですか?」
「どんなアプリ?」
「ええと、人の名前とか年齢とか誕生日とか、そういうデータを管理するようなアプリです。名鑑ですね」
「ふうん。ゴミね」
バッサリ切り捨てられた。
「まあ、まだ作りかけなので……。今はベースとなる部分を組み立てているところです。機能追加はあとでやろうと思ってるんで」
「さっき後ろから見てたら、ちょっとコードを書くたびにウィンドウを出したりボタンを押したり閉じたりしてたけど、あれは?」
「動作確認ですよ。ちょっと改造したら、これまで作った部分がちゃんと動くかどうかを一通り操作して確かめるんです。じゃないと、どこでデグレしたか分からないですからね」
「ゴミね。まるでゴミね。ゴミという言葉すらもったいないくらい。喩えるなら……そう、これは……すごく……ゴミね」
「『ゴミ』から一歩も外に出てませんけど」
ササクラは語彙が貧弱だった。
「セキナ君はVisual Studioでアプリを作っているけど、Visual Studioのテストプロジェクトは知ってる?」
「知ってますけど、今のアプリのテストには使えませんよ。データ処理の機能を実装したらそこだけテストできますけど、今はまだUIの部分しか作ってないので」
「UI層の単体テストを作ればいいじゃない」
「確かに、UIAutomationみたいなUI操作をエミュレートするライブラリを使えば画面操作を自動実行することはできますけど、でもアプリ開発を進めるとすぐにテストが壊れちゃいますし、UI操作で内部の状態がどう変わったかがテスト側で判定できないじゃないですか。それじゃあ総合テストくらいにしか使えないですよ。あとUIテストって、ボタン押すタイミングとかですぐに失敗するし……」
「そう、セキナ君はFriendlyを知らないのね」
「Friendly?」
聞き慣れない単語だった。ササクラは得意気に頷いた。
「教えてあげようか?」
「いえ、別に大丈夫です」
導入
「まずは導入方法から。Nugetはもちろん知ってるわね? Nugetから『Friendly』、『Friendly.Windows』、『Friendly.Windows.Grasp』、『Friendly.WPFStandardControls』を検索するの」
「勝手に始めるんですね。……はい、ありました」
「それじゃ、ダウンロードボタンをポチっとな」
「あんたほんとに女子高生か」
言われたとおり、セキナはテストプロジェクトにFriendly、Friendly.Windows、Friendly.Windows.Grasp、Friendly.WPFStandardControlsのパッケージを追加した。
「これ、どういうライブラリなんですか?」
「Friendlyはね、実行中の他のプロセスに介入して、外から操作を行うためのライブラリよ」
「他のプロセス……? でもそれって、他のGUIテスト自動化ライブラリでもそうですよね。UIAutomationとか」
「そうね。ただ、さっきセキナ君が言った通り、他のプロセスを操作するテストライブラリにはタイミング依存で失敗するという弱点があったわ。テスト対象のプロセス――プロダクトプロセスに対して、OSを介してマウス操作やキーボード入力をエミュレートすることはできても、そのプロセスが入力に対しての処理を完了したかどうかを、テストプロセスから判別する方法がなかったの。だからそういうときは、Thread.Sleepメソッドで一定時間テストプロセスを待機させたりした後に次の処理を行うようにしてたのだけど――」
「処理にかかる時間って、マシンやタイミングでも変わりますからね。それに、処理が終わるまで十分な時間をいちいち待機させていたらテストに時間がかかって仕方ないです」
「Friendlyはプロダクトプロセスに対して同期的にUI操作をエミュレートできるという利点があるの。だから、UI操作をエミュレートしたとき、その操作に対するプロダクトプロセスの反応が終わったら自動的に次のテスト処理が実行されるわ。それからもうひとつ、Friendlyはプロダクトプロセス内にテストプロセスのメソッドを送り込むという非常に強力な機能を持っている」
「送り込む……って、どういう意味ですか?」
「そのことは順番に話すとして……まずは基本的な使い方から説明するわ」
そう言うとササクラは、立ったままの状態で、テストプロジェクトのソースファイルにキーボードでコードを打ち始めた。
プロセスへのアタッチ
/// <summary>
/// Friendly用APPクラス
/// </summary>
WindowsAppFriend _app;
/// <summary>
/// テスト対象に存在するMainWindowインスタンス
/// </summary>
dynamic _main;
/// <summary>
/// テスト開始前の処理
/// ここで、テスト対象のプロセスにアタッチする
/// </summary>
[TestInitialize]
public void TestInitialize()
{
// 外部プロセスにアタッチ
this._app = new WindowsAppFriend(Process.Start("WPFApp.exe"));
// MainWindowを取得
this._main = this._app.Type<Application>().Current.MainWindow;
}
"WPFApp.exe"というのはセキナが作っている名鑑アプリの実行ファイルの名前である。
「こんな風に、WindowsAppFriendクラスのコンストラクタにSystem.Diagnostics.Processクラスのインスタンスを渡せば準備完了よ」
「『MainWindowを取得』と書いてあるのは?」
「Friendlyでは、アタッチしたプロセスの空間にあるStaticなメソッドを自由に呼び出す方法が提供されているの。二つ目に書いた、『MainWindowを取得』の部分は、staticなApplicationクラスのCurrentメソッドを呼び出しているところね」
「『アタッチしたプロセスの空間にある』……?」
「今ここに書いてあるこのコードが動いているプロセスと、Process.Startで起動したWPFApp.exeが動いているプロセスは別のプロセス……ここまではいいわね?」
「何とか」
Process.Startは別プロセスを起動するメソッドだ。単体テストのメソッドで、例えばWPFAppのMainWindowクラスのインスタンスを作る(newする)のとはわけが違う。
「テストコードでApplication.Current.MainWindowを呼び出しても、それで呼び出されるのはテストコードが動いているプロセスのApplication.Current.MainWindowでしかない。別のプロセスのstaticメソッドを呼び出す方法はないわ。……普通はね」
「Friendlyならそれができる……ですか」
「そう。そして、staticなメソッドを呼び出せるということはMainWindowを取得できるということ。MainWindowを取得できるということは、WPFならばそのプロセスに存在するあらゆるオブジェクトにアクセスできるということ。つまり、Friendlyを使えばプロダクトプロセスのすべてを見通すことができるのよ。まさに透視……! 圧倒的透視力……!」
めちゃくちゃ顔を近づけてササクラは言った。頭突きされたらひとたまりもない距離だ。セキナはざわざわした。
「この、二つ目のコードの戻り値……_mainフィールドがdynamic型なのはどうしてですか?」
「ここで取得したMainWindowクラスのインスタンスへの参照は、あくまでプロダクトプロセス――今回の場合は、単体テストが動いているプロセスとは別のプロセスで動いている"WPFApp.exe"の中に存在しているものよ。そして、他プロセスの中に存在するものへは、Friendlyはdynamic型を使うことでアクセスできるようにしているの」
「dynamicだとインテリセンスきかないんですけど」
「インテリセンスは死んだわ」
真顔で言われた。現実は非情である。
「ところで、Friendlyは他プロセスの変数の値をこちら側のプロセスの方に取ってくる機能があるんだけど、それはプロセス間通信を使って実現しているの。例えばWPFApp.exeのMainWindowクラスのTitle属性を、テストプロセスの側にstring型の変数として取得できるわ。ただし、プロセス間通信でやりとりできるものはシリアライズが可能な型だけで、たとえばWindowクラスをそのままテストプロセスにコピーすることはできないわ」
「プロセス間通信でやりとりできる型って何ですか?」
「intやstring型なんかは普通に取得できるわね。……ところで、このdynamic型の参照――実際にはDynamicAppVar型っていうFriendlyで定義されたクラスなんだけど、それはともかく――これを、例えばintやstringなどの実際の型にキャストした時点で、プロダクトプロセスにあるオブジェクトがコピーされて、テストプロセスに送られてきて実体となるの」
途中からササクラが何を言っているのか分からなくなってきたのでセキナは生返事を返した。それを敏感に察したのか、ササクラも「まあ具体的な書き方については後で見せるわ」と説明を打ち切った。
たとえばボタンクリックのエミュレート
「それじゃ、まずは簡単なところから――『追加』ボタンを押したときの挙動をテストする場合を考えてみましょうか」
ササクラは顔をディスプレイに向けるとものすごいスピードでコードをタイプし始めた。タイプしながらセキナに対して言葉で説明する。
「『追加』ボタンを押すと、データを追加するためのダイアログが出てくる――ダイアログが出てくるのを確認するためのテストを書く、という想定で考えてみるわ。こんな感じかしら」
ササクラは体をディスプレイの前から動かし、セキナにコードを見るように促した。
/// <summary>
/// 「追加」ボタンを押すと項目の追加画面が出てくるかどうかをテスト
/// </summary>
[TestMethod]
public void TestAddButton()
{
// ボタンを押す前は、Windowは1つだけなのを確認
Assert.AreEqual(1, (int)this._app.Type<Application>().Current.Windows.Count);
// ボタンの特定方法1:コントロールに指定された"Name"から特定
var buttonAdd = new WPFButtonBase(_main.addButton);
// クリック操作を非同期でエミュレート
Async a = new Async();
buttonAdd.EmulateClick(a);
// 詳細ウィンドウが出てきたのを確認
Assert.AreEqual(2, (int)this._app.Type<Application>().Current.Windows.Count);
// "新規追加"というタイトルのウィンドウが見つからなかったらNG
Assert.IsNotNull(WindowControl.IdentifyFromWindowText(this._app, "新規追加"));
}
「WPFButtonBaseというのは、Friendlyでボタンを扱うためのラッパークラスのようなもので……このクラスを介して、例えばボタンならクリック操作をエミュレートすることができるわ。『Friendly.WPFStandardControls』にはButton以外にもTextBoxやDataGridのようなコントロールへの操作をエミュレートするクラスが揃っているの」
ButtonBase、という名前が気になったが、よく考えればButtonクラスの基底クラスがButtonBaseクラスだったはずだ。つまり、ButtonBaseクラスを継承して作った(標準のButtonを拡張するような)独自のコントロールに対してもWPFButtonBaseが使えるということだ。
ササクラが、メソッドの最後の方をディスプレイの上から指で押さえた。
「このテストメソッドの最後、"新規追加"ウィンドウを取得しているところで使っているWindowControlクラスは、Windowクラスのラッパーみたいなものね。これは『Friendly.Windows.Grasp』の方にあるクラスよ。『Friendly.Windows.Grasp』にはウィンドウに対する操作を楽にするためのクラスが揃っているわ。ちなみにこれ、タイトルの文字列でウィンドウを特定しているけど、他にもクラス名や、トップレベルにあるウィンドウを取得、なんてこともできるわ」
コントロールの特定
「ここのコメントのところに『ボタンの特定方法1:コントロールに指定された"Name"から特定』……って書いてあるのは?」
「まず、WPFButtonBaseクラスのコンストラクタにはAppVarのインスタンスを渡すことになっているのね」
「AppVarクラスって何ですか?」
「プロダクトプロセスに存在している変数への参照を意味するクラスね。上の方でちょっと教えたDynamicAppVarクラスと同じようなものよ。プロダクトプロセスに存在している変数はすべてAppVar型かDynamicAppVar型になるわけ。OK?」
「えーっと……AppVar型とDynamicAppVar型の違いは何ですか?」
「意味は同じよ。違いといえば、DynamicAppVar型はdynamic型になるというくらい。AppVar型は操作するのがちょっと面倒なの。だから、AppVar型のインスタンスを操作しやすくるために、後からDynamicAppVar型がFriendlyに追加されたの」
「なるほど。OKです」
「さて問題はWindowからControlのインスタンスをどうやって取ってくるか。一番簡単な方法は、Viewを定義するときにNameプロパティを設定して、Windowクラスからコントロールのフィールドの名前を指定してアクセスする方法よ。ほら、MainWindowのUIの定義を見ると、追加ボタンには"addButton"という名前がついているでしょう」
ササクラはMainWindow.xamlを開いて指で示した。すなわちこれは、セキナがさっきまで書いていた、WPFAppのコードだ。
<Button Name="addButton"
Command="{Binding AddCommand}"
Content="追加" />
「じゃ、さっそくこれを実行してみましょうか」
ササクラはマウスを操作して、単体テスト"TestAddButton"を実行した。一瞬で名鑑アプリのウィンドウが立ち上がると、データ追加用のサブウィンドウが開いた、ように見えた。すぐに親ウィンドウごと名鑑アプリは終了した。テスト結果を見ると、「テスト 成功」の文字があった。
「おお、すごい。勝手に起動した」
「UIテストなんだから当たり前でしょう」
ササクラは冷めた反応を返した。
「『クリック操作を非同期でエミュレート』というのは? 同期的にエミュレートできないんですか?」
「できるけど、『追加』ボタンを押すとウィンドウが出てくるでしょう? このボタンを同期的にエミュレートすると、このウィンドウが閉じるまで制御が戻ってくれないの。もちろん、Asyncクラスのインスタンスを引数に渡さなければ同期的にエミュレートされるから、ちゃんと制御が戻るならわざわざ同期的に呼び出す必要はないわ」
バインドからコントロールの特定
「まあとにかく、こんな風に『ボタンを押す』と『ウィンドウが出てくる』という仕様をテストすることができるわ」
「『ボタンの特定方法1』って書いてますけど、『ボタンの特定方法2』はあるんですか?」
「せっかちね。せっかちな男は嫌いよ」
「じゃあ質問を撤回します」
「のんびりした男はもっと嫌いよ」
軽口を叩いている間も、ササクラは休むことなくキーボードを叩いていた。
/// <summary>
/// 削除ボタンの活性状態をテスト
/// </summary>
[TestMethod]
public void TestDeleteButton_Enable()
{
// 未選択状態のときは削除ボタンを押せないことをテスト
AppVar mainVar = _app.Type<Application>().Current.MainWindow;
// ボタンの特定方法2:コントロールに指定されたバインディングから特定
var logicalTree = mainVar.LogicalTree();
var deleteButton = new WPFButtonBase(logicalTree.ByBinding("DeleteCommand").Single());
Assert.IsFalse(deleteButton.IsEnabled);
// リストから選択する操作を実行
// DataGridを取得
var dataGrid = new WPFDataGrid(logicalTree.ByBinding("PeopleModel.People").Single());
// DataGridの最初の行を取得して選択操作をエミュレート
var row = dataGrid.GetRow(0);
row.EmulateChangeSelected(true);
// 削除ボタンを押せるようになっていればOK
Assert.IsTrue(deleteButton.IsEnabled);
}
「次のテストはこれよ。人名の一覧を選択して『削除』ボタンを押すと、そのデータが削除されるけど、リストで何も選択していないときは『削除』ボタンは非活性状態で押せないようになっている――という仕様をテストするためのコードね」
「『ボタンの特定方法2:コントロールに指定されたバインディングから特定』……バインディング対象で取得できるんですか?」
ササクラは再びMainWindow.xamlを開き、該当の場所を示した。
<Button Command="{Binding DeleteCommand}" Content="削除" />
削除ボタンの方にはNameプロパティを設定していなかった。セキナにとってこれは単なる書き忘れである。意図があってそうしたわけではなかった。
「削除ボタンにはNameが指定されていないけど、CommandにViewModelの"DeleteCommand"がバインドされているでしょう。Friendlyが提供するByBindingメソッドを使えば、LogicalTreeの中にあるコントロールの中から、バインド対象が"DeleteCommand"になっているコントロールを検索することができるわ。ちなみにLogicalTreeを取得する便利な拡張メソッドがあるから、それを使えば一発よ」
「複数のコントロールが同じ対象にバインドしていた場合はどうなるんです?」
「最後に.Single()を呼び出しているところに注目なさい。つまりlogicalTree.ByBindingの戻り値はコレクションで、LINQを使った絞り込みができるのよ」
「なるほど……必要があれば、さらに条件を追加して検索することができる、と」
「そういうことね」
ササクラは頷いた。
コントロールの特定方法(裏技)
「コントロールの特定方法は2つだけですか?」
「もうひとつあるわ。裏技がね……。クククク」
ササクラは怪しく笑った。直後、猛烈な勢いでキーボードを叩き、新しいテストコードを書き始めた。
/// <summary>
/// 編集ボタンの活性状態をテスト
/// </summary>
[TestMethod]
public void TestEditButton_Enable()
{
// ボタンの特定方法3:相手プロセスでボタンを特定するstaticメソッドを実行する
WindowsAppExpander.LoadAssembly(this._app, this.GetType().Assembly); // ←DLLインジェクション
var targetButton = this._app.Type(this.GetType())
.GetButton(this._app.Type<Application>().Current.MainWindow,
"編集");
var editButton = new WPFButtonBase(targetButton);
Assert.IsFalse(editButton.IsEnabled);
// リストから選択する操作を実行
// DataGridを取得
AppVar mainVar = this._app.Type<Application>().Current.MainWindow;
var logicalTree = mainVar.LogicalTree();
var dataGrid = new WPFDataGrid(logicalTree.ByBinding("PeopleModel.People").Single());
// DataGridの最初の行を取得して選択操作をエミュレート
var row = dataGrid.GetRow(0);
row.EmulateChangeSelected(true);
// 編集ボタンを押せるようになっていればOK
Assert.IsTrue(editButton.IsEnabled);
}
/// <summary>
/// 指定されたWindow配下を検索し、指定された文字列とContentが一致するボタンを取得します
/// </summary>
/// <param name="window">検索対象となるWindow</param>
/// <param name="label">検索する文字列</param>
/// <returns>一致したボタン</returns>
static Button GetButton(Window window, string label)
{
var logicalTree = window.LogicalTree();
return (Button)logicalTree.ByType<Button>().FirstOrDefault(c =>
{
string content = c.Content as string;
return content == label;
});
}
どうやら今度は「編集」ボタンの活性状態をテストするためのコードらしい。テストメソッドの中に「ボタンの特定方法3:相手プロセスでボタンを特定するstaticメソッドを実行する」の記述を見つけた。
「『相手プロセスで』……? つまり、あらかじめ名鑑アプリに、ボタンを取得するようなstaticメソッドを用意しておくということですか?」
「もちろんその方法でも取得できるけど、その場合は『テストのためにプロダクトを変更する』ということになって、美しくないわ」
「美しさですか」
「プログラムには美しさが必要よ」
ササクラが気取って答えた。
DLLインジェクション
「Friendlyには、テストプロセスにあるstaticメソッドを、プロダクトプロセスの中で実行する機能があるの。つまり、単体テストプロジェクトで定義したstaticメソッドを、名鑑アプリの世界に『送り込んで』、『名鑑アプリの中で実行する』ということね。そのためにはまず、送り込む先のプロセスに、こちらのDLLを読みこませる操作が必要よ」
ササクラが指で、その場所を示した。
WindowsAppExpander.LoadAssembly(this._app, this.GetType().Assembly); // ←DLLインジェクション
「LoadAssemblyで渡したのは単体テストプロジェクトのDLLよ。こうすれば、以降はこのプロジェクトにあるstaticなメソッドを自由に相手プロセスの中で実行できるわ。こんなふうにね」
var targetButton = this._app.Type(this.GetType())
.GetButton(this._app.Type<Application>().Current.MainWindow,
"編集");
var editButton = new WPFButtonBase(targetButton);
/// <summary>
/// 指定されたWindow配下を検索し、指定された文字列とContentが一致するボタンを取得します
/// </summary>
/// <param name="window">検索対象となるWindow</param>
/// <param name="label">検索する文字列</param>
/// <returns>一致したボタン</returns>
static Button GetButton(Window window, string label)
{
var logicalTree = window.LogicalTree();
return (Button)logicalTree.ByType<Button>().FirstOrDefault(c =>
{
string content = c.Content as string;
return content == label;
});
}
「このGetButtonメソッドは、単体テストプロジェクトに定義されているstaticなメソッドね。見ればわかるけど、ちゃんと引数も与えられるし戻り値も受け取れる。ただし、渡す引数は『シリアライズ可能な型のインスタンスをテストプロジェクトの方から渡す』か、『すでに名鑑アプリの中に存在するインスタンスを指定する』のどちらかである必要があるわ。このコードだと、名鑑アプリの中に存在するMainWidowクラスのインスタンスを引数に渡しているわ」
「すごいですね、これ……相手のプロセスで何でも実行できるなら、困ったら全部これで解決できそうですね」
「まあね。相手のプロセスで実行するのだからプロセス間の壁なんてないし。どこでもお触り自由よ」
「女子高生がそのたとえはどうかと思いますが……」
応用問題
「それじゃ、今まで教えたことの応用ね。名鑑アプリの人名一覧からひとつ選択して『編集』ボタンを押す、そしたら詳細ダイアログが出てきて、そこで『名前』欄を書き換えて『適用』ボタンを押す。すると、最初の人名一覧の名前がちゃんと書き換わっている――というシナリオでテストを書いてみるわ」
今度のコードは長かった。何度かデバッグモードでテストメソッドを動かして、完成したのがこのコードだった。
/// <summary>
/// 名前を編集できるかテスト
/// </summary>
[TestMethod]
public void TestEditName()
{
// テストのシナリオ
// 1.リストの先頭を選択
// 2.「編集」ボタンをクリック
// 3.「名前」テキストボックスを書き換える
// 4.「確定」ボタンを押す
// 編集ボタンを取得
AppVar mainVar = _app.Type<Application>().Current.MainWindow;
var logicalTree = mainVar.LogicalTree();
var editButton = new WPFButtonBase(logicalTree.ByBinding("EditCommand").Single());
// 1.リストの先頭を選択
var dataGrid = new WPFDataGrid(logicalTree.ByBinding("PeopleModel.People").Single());
var row = dataGrid.GetRow(0);
row.EmulateChangeSelected(true);
// 編集対象の名前を退避
string name = dataGrid.GetCellText(0, 0);
// 2.「編集」ボタンをクリック
editButton.EmulateClick(new Async());
AppVar subWindow = WindowControl.IdentifyFromWindowText(this._app, name + " - 編集").AppVar;
Assert.IsNotNull(subWindow);
var subLogicalTree = subWindow.LogicalTree();
// 名前入力テキストボックスを取得
var nameTextBox = new WPFTextBox(subLogicalTree.ByBinding("Name.Value").Single());
// 3.「名前」テキストボックスを書き換える
nameTextBox.EmulateChangeText(name + "変更後");
// 確定ボタンを取得
var commitButton = new WPFButtonBase(subLogicalTree.ByBinding("CommitCommand").Single());
// 4.「確定」ボタンを押す
commitButton.EmulateClick(new Async());
// ちゃんと名前が書き換わっていることを確認
Assert.AreEqual(name + "変更後", dataGrid.GetCellText(0, 0));
// ViewModelも書き換わっていることを確認
Assert.AreEqual(name + "変更後", (string)this._main.DataContext.SelectedItem.Value.Name);
// ついでにModelが書き換わっていることを確認
Assert.AreEqual(name + "変更後", (string)this._main.DataContext.PeopleModel.PeopleSource[0]._Name);
}
「基本的に、今まで説明した内容をつなぎあわせただけだから、特に説明の必要はないと思うけど」
「WPFDataGridというのは、WPFButtonBaseのDataGrid版ですか」
「そう。前にも言ったけど、WPFの基本的なコントロールについてはラッパークラスが揃っているから、それぞれ必要に応じて使い分ければいいわ」
ユーザーコントロールの特定方法
「複数のコントロールを組み合わせて作ったみたいなユーザーコントロールについてはどうなんですか? 僕、詳細ダイアログの『年齢』のところは、普通のテキストボックスじゃなくてxceedのtoolkitってライブラリのコントロールを使ってるんですけど……」
年齢の入力にはXceed.Wpf.Toolkit.IntegerUpDownというクラスを使っていた。これは、数値しか入力できないテキストボックスと、その値をインクリメントしたりデクリメントするためのスピンボタンが1つになったコントロールだ。
「まったくのゼロから標準コントロールを何も使わずに作ったコントロールだとどうしようもないけど、例えば標準のTextBoxを継承して改造したり、ボタンを付けて拡張したりしたくらいなら十分に対応可能よ」
/// <summary>
/// 年齢をテキストボックスから編集できるかテスト
/// </summary>
[TestMethod]
public void TestEditAgeByTextBox()
{
AppVar mainVar = _app.Type<Application>().Current.MainWindow;
var logicalTree = mainVar.LogicalTree();
var editButton = new WPFButtonBase(logicalTree.ByBinding("EditCommand").Single());
var dataGrid = new WPFDataGrid(logicalTree.ByBinding("PeopleModel.People").Single());
var row = dataGrid.GetRow(0);
row.EmulateChangeSelected(true);
string name = dataGrid.GetCellText(0, 0);
editButton.EmulateClick(new Async());
AppVar subWindow = WindowControl.IdentifyFromWindowText(this._app, name + " - 編集").AppVar;
Assert.IsNotNull(subWindow);
var subLogicalTree = subWindow.LogicalTree();
// インジェクション
// WPFToolKitのIntegerUpDownのように複数の標準コントロールを組み合わせたコントロールは
// 取得できないため、VisualTreeからTextBox部分のみを取り出す
WindowsAppExpander.LoadAssembly(this._app, GetType().Assembly);
{ //テキストボックスから編集
var targetTextBox = this._app.Type(this.GetType())
.GetAgeTextBox(subWindow);
var ageTextBox = new WPFTextBox(targetTextBox);
// 年齢を99に変更
ageTextBox.EmulateChangeText("99");
}
// 確定
var commitButton = new WPFButtonBase(subLogicalTree.ByBinding("CommitCommand").Single());
commitButton.EmulateClick(new Async());
// ちゃんと年齢が書き換わっていることを確認
Assert.AreEqual("99", dataGrid.GetCellText(0, 1));
}
/// <summary>
/// 「年齢」のテキストボックス部分のみを取得します
/// </summary>
/// <param name="window">詳細ウィンドウ</param>
/// <returns>「年齢」のテキストボックス部分</returns>
static TextBox GetAgeTextBox(Window window)
{
var logicalTree = window.LogicalTree();
DependencyObject IntegerUpDown = logicalTree.ByBinding("Age.Value").Single();
// IntegerUpDownのVisualTreeから検索
return IntegerUpDown.Descendants<TextBox>().Single();
}
「例えばユーザーがキーボードからテキストボックスに文字を入力するシナリオを考えるなら、コントロールの中にあるTextBoxの部分『のみ』を取り出せば十分でしょう。コントロールの中にある部品をどうやって取り出すかは、まあ色々あるけど、相手プロセスに検索用のメソッドを送り込んで、テキストボックスの部分のみを返してもらうのが楽ね。このコードだとstaticなGetAgeTextBoxメソッドを名鑑アプリに送り込んで実行しているわ。IntegerUpDownにはテキストボックスは1つしかないから、TextBoxクラスを継承するコントロールを、VisualTreeの中から探せばいいわ。ちなみにGetAgeTextBoxメソッドの中で使っているDescendantsメソッドというのは、テストクラスで定義している拡張メソッドよ」
public static class DependencyObjectExtensions
{
/// <summary>
/// VisualTreeの子要素を取得
/// </summary>
/// <param name="obj"></param>
/// <returns></returns>
public static IEnumerable<DependencyObject> Children(this DependencyObject obj)
{
if (obj == null)
throw new ArgumentNullException("obj");
var count = VisualTreeHelper.GetChildrenCount(obj);
if (count == 0)
yield break;
for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(obj, i);
if (child != null)
yield return child;
}
}
/// <summary>
/// VisualTreeの子孫要素を取得
/// </summary>
/// <param name="obj"></param>
/// <returns></returns>
public static IEnumerable<DependencyObject> Descendants(this DependencyObject obj)
{
if (obj == null)
throw new ArgumentNullException("obj");
foreach (var child in obj.Children())
{
yield return child;
foreach (var grandChild in child.Descendants())
yield return grandChild;
}
}
/// <summary>
/// 指定した型の子要素を取得
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="obj"></param>
/// <returns></returns>
public static IEnumerable<T> Children<T>(this DependencyObject obj)
where T : DependencyObject
{
return obj.Children().OfType<T>();
}
/// <summary>
/// 指定した型の子孫要素を取得
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="obj"></param>
/// <returns></returns>
public static IEnumerable<T> Descendants<T>(this DependencyObject obj)
where T : DependencyObject
{
return obj.Descendants().OfType<T>();
}
}
「やってることは、VisualTreeの子要素を取り出して中身を再帰的に検索してるだけのコードだから説明はしないけど。こういう、VisualTreeの中からメソッドを検索する方法を用意しておくと、標準じゃないコントロールに対するテストも書きやすくなるからおすすめよ」
「上下のスピンボタンも同じように取り出せますね」
「セキナ君、ちょっと書いてみて」
「え、あ、僕がですか?」
突然命じられてセキナは戸惑った。正直面倒くさいなと思ったが、ここで「できません」というのもちょっと格好悪い。ササクラはすでに一歩横に移動して、セキナにディスプレイの正面に来るようにジェスチャで促していた。
仕方なく、椅子ごとディスプレイの前に移動して、テスト用の新しいメソッドを作り始めた。
/// <summary>
/// 年齢をスピンボタンから編集できるかテスト
/// </summary>
[TestMethod]
public void TestEditAgeBySpinButton()
{
AppVar mainVar = _app.Type<Application>().Current.MainWindow;
var logicalTree = mainVar.LogicalTree();
var editButton = new WPFButtonBase(logicalTree.ByBinding("EditCommand").Single());
var dataGrid = new WPFDataGrid(logicalTree.ByBinding("PeopleModel.People").Single());
var row = dataGrid.GetRow(0);
row.EmulateChangeSelected(true);
string name = dataGrid.GetCellText(0, 0);
// 元の年齢を取得
string age = dataGrid.GetCellText(0, 1);
editButton.EmulateClick(new Async());
AppVar subWindow = WindowControl.IdentifyFromWindowText(this._app, name + " - 編集").AppVar;
Assert.IsNotNull(subWindow);
var subLogicalTree = subWindow.LogicalTree();
// インジェクション
// WPFToolKitのIntegerUpDownのように複数の標準コントロールを組み合わせたコントロールは
// 取得できないため、VisualTreeからTextBox部分のみを取り出す
WindowsAppExpander.LoadAssembly(this._app, GetType().Assembly);
{ // スピンボタンから編集
var targetButtons = this._app.Type(this.GetType())
.GetAgeSpinButton(subWindow);
var up = new WPFButtonBase(targetButtons.Item1);
var dw = new WPFButtonBase(targetButtons.Item2);
// 5回Upする
up.EmulateClick();
up.EmulateClick();
up.EmulateClick();
up.EmulateClick();
up.EmulateClick();
// 2回Downする
dw.EmulateClick();
dw.EmulateClick();
}
// 確定
var commitButton = new WPFButtonBase(subLogicalTree.ByBinding("CommitCommand").Single());
commitButton.EmulateClick(new Async());
// ちゃんと年齢が書き換わっている(元の値から+5、-2されている)ことを確認
Assert.AreEqual((int.Parse(age) + 5 - 2).ToString() , dataGrid.GetCellText(0, 1));
}
/// <summary>
/// 「年齢」のスピンボタン部分のみを取得します
/// </summary>
/// <param name="window">詳細ウィンドウ</param>
/// <returns>
/// item1:増やす方
/// item2:減らす方
/// </returns>
static Tuple<ButtonBase, ButtonBase> GetAgeSpinButton(Window window)
{
var logicalTree = window.LogicalTree();
DependencyObject IntegerUpDown = logicalTree.ByBinding("Age.Value").Single();
// IntegerUpDownのVisualTreeから検索
return new Tuple<ButtonBase, ButtonBase>(IntegerUpDown.Descendants<ButtonBase>().First(c => c.Name == "PART_IncreaseButton"),
IntegerUpDown.Descendants<ButtonBase>().First(c => c.Name == "PART_DecreaseButton"));
}
「ええと、IntegerUpDownの中をLINQで検索して……最終的にはVisualTeeの中を覗いて、名前で検索をかけてみました」
各ボタンの名前はIntegerUpDownの定義から調べたものである。部品の名前が分からないライブラリの場合は、このあたりの処理がもう少し煩雑になるかもしれない。
「それでもいいわね。……とにかく大事なことは、相手プロセスにメソッドを送り込めば、コントロールのVisualTreeやらLogicalTreeやらを検索して、いくらでもやりたいことを実行できるということよ」
終わりに
「実は他にも色々説明したいことはあるけど、とりあえずここまでの知識があれば簡単なUIテストはできるはずよ。ちなみに言うと、ここまではずっとWPFのコントロールについての操作をエミュレートしてきたけど、Friendlyはネイティブなウィンドウに対する操作もサポートしているから調べてみるといいわ。Windows標準のメッセージボックスなんかはWPFのコントロールじゃないから、ここまでのやり方じゃ操作できないわよ」
「どうやって調べればいいですか?」
「公式サイトを見に行きなさい。そこそこ丁寧な解説とサンプルコードがあるから。あとFriendlyをテーマにしたAdvent Calendarがまとまっていて便利よ」
「先輩、『テスト自動化に目覚めた! IQ100の女子高生の先輩から受けた特訓30分』という記事を見つけました!」
「それはただのポエムだから見るだけ時間の無駄よ」
あとがき
今回のコードはプロダクトコード、テストコード含めて以下に上げてあります。このポエム読むよりもこっちのコードを直接見る方がわかりやすいと思います。
https://github.com/anareta/2015AC
そしてこのコード見るよりネットで情報探すほうがもっとわか(以下略
なんでこんなの書いたの?
気の迷い。
参考文献
Friendly(配布元)
http://www.codeer.co.jp/AutoTest
Friendly Advent Calendar 2014
http://qiita.com/advent-calendar/2014/friendly
VisualTreeの子孫要素を取得する
http://blog.xin9le.net/entry/2013/10/29/222336