GUI自動テストでアプリケーション操作にJNIを使う(ただしIE)
こんにちは。自動化エンジニアの森川です。
本日は、WindowsのE2E自動テストでありがちな課題をJNIを使って乗り越える具体的な例をご紹介したいと思います。
※ 本エントリーはこちらのエントリーの続編的なTipsとなります。
【Java】JNIを使ったデスクトップアプリの操作 - SHIFT Group
JNIとは
まずはじめに、JNIについて簡単に説明です。
これによってJavaでWin32APIを操作するコードを書いてWindowsのアプリケーションにはたらきかけることができるというわけです。
環境
・Java:AdoptOpenJdk 11.x
・gradle: 6.x
・JUnit: 5.6.x
・Selenium 4.0.0-alpha-6
・Browser: Internet Explorer 11
・JNA: 5.5.0
<本日のindex>
・なぜJNIが必要なのか
・ウインドウハンドル引き当ての難しさ
・検索用ユーティリティを書く
・IEのファイルダウンロードを試みる
・IEのファイルダウンロードフォルダの設定を試みる
・まとめ
なぜJNIが必要なのか
IE(Internet Explorer)のE2E自動テストでは、ダイアログ系の操作に困ることが多いです。
なぜならファイルダウンロード時の通知バーや印刷ダイアログなどは、アプリケーション側の操作になるため、Seleniumでは処理ができないからです。
JavaであればRobotという自動操作ライブラリが常套なのですが、Robotはデスクトップ全般に操作するためウインドウの捕捉や検知はできないという課題があります。
デスクトップに他のウインドウが前面に表示されたりすることを検知できないので、テスト実行時に不安定な動作になりがちです。
ただでさえFlaky(≒不安定)なテストが多いE2Eテストですので、自動化エンジニアならばこれ以上不安定になるのは避けたいところです。
その点JNIならば、ウインドウや部品を特定しながら操作をすることができます。
Windowsデスクトップアプリを自動操作するWinAppDriverというOSSツールがあり、こちらはSeleniumに似たインターフェイスを持っていますが、残念ながらWindows 10のみの対応です。
microsoft/WinAppDriver: Windows Application Driver
そもそもIEで困っているわけですから、閉鎖ネットワーク内のWindoowsXPやWindows7をガンガン相手にすることを考えなければなりません。
というわけで若干消去法的になりますが、JNIに白羽の矢が当たるというわけです。
しかしながらJNIでアプリケーションの操作を実装するのはなかなか骨が折れる作業、というのもまた事実です。
ウインドウハンドル引き当ての難しさ
先述の過去エントリーでは、対象のウインドウや部品のウインドウハンドルを取得するためにツリー構造になっている部品を掘り下げるとありました。
実はこの作業が難しく、場合によってはFindWindowExでは辿ることができない部品もあります。
※ 上図:FindWindowExでウインドウクラス名が見つからないケース(inspect.exe使用)
こうなると筆者のようにWin32 API仕様に詳しくない自動化エンジニアにとってはお手上げで、とてもE2Eテスト作成の片手間にできないというのが正直なところです。
検索用ユーティリティを書く
さいわいJNIには、コールバック関数を指定して、ウインドウや子要素部品を列挙してくれるAPIが準備されていますので、こちらを使ってウインドウハンドルを検索するユーティリティ・メソッドを書いてみることにします。
・EnumWindows
・EnumChildWindows
実装例としてはそれぞれ、次の様になります。(一部のサブメソッドは割愛しています)
ウインドウを探すユーティリティメソッド
import com.sun.jna.platform.win32.BaseTSD;
import com.sun.jna.platform.win32.User32;
import com.sun.jna.platform.win32.WinDef;
import com.sun.jna.platform.win32.WinUser;
public class JnaUtils implements JnaConstants {
(中略)
// ウインドウを探すユーティリティ
public boolean searchWindow(String expected) {
resultHwnd = null; // クラスメンバ WinDef.HWND
User32.INSTANCE.EnumWindows((hWnd, data) -> {
// debug用、見失ったら、眺めるためのロギング
logger.info("title:" + title(hWnd));
if (checkTitle(hWnd, expected) && User32.INSTANCE.IsWindowVisible(hWnd)) {
resultHwnd = hWnd;
return false;
}
return true;
}, null);
return resultHwnd != null;
}
// ウインドウタイトル
private static String title(WinDef.HWND hWnd) {
char[] name = new char[512];
User32.INSTANCE.GetWindowText(hWnd, name, name.length);
return Native.toString(name);
}
ウインドウから子要素(部品)を探すユーティリティメソッド
// 指定したウインドウハンドルの直系の子部品を「親ウインドウクラス名」
// 「子ウインドウクラス名」をキーとして探すユーティリティメソッド
public static WinDef.HWND searchChildItem(String myClassName, String parentClassName, int index) {
List<WinDef.HWND> result = new ArrayList<>();
User32.INSTANCE.EnumChildWindows(resultHwnd, (hWnd, data) -> {
String className = getWindowClassName(hWnd);
String classNamePrnt = getWindowClassName(User32.INSTANCE.GetAncestor(hWnd, 1));
// debug用、見失ったら、親子関係を眺めるためのロギング
logger.info(String.format("child %s(%s):parent %s", className , hWnd.toString(), classNamePrnt));
if (className.equals(myClassName) && classNamePrnt.equals(parentClassName)) {
result.add(hWnd);
}
return true;
}, null);
return result.get(index - 1); // 複数ある場合はIndexで指定する
}
// ウインドウクラス名
private static String getWindowClassName(WinDef.HWND hWnd) {
char[] buf = new char[512];
User32.INSTANCE.GetClassName(hWnd, buf, 512);
return Native.toString(buf);
}
ウインドウハンドルが見つからない場合は、ログに列挙されたウインドウハンドルと、inspect/spy++のモニタリングを比較して調べるようにして進めます。クラス名の指定誤りや、階層の誤りにすぐに気づけたらラッキーです。
この他にも幾つかユーティリティメソッドを書きます。
// キー押下
public static void sendKey(int key) {
User32.INSTANCE.PostMessage(resultHwnd, SYS_KEYDOWN, new WinDef.WPARAM(key), new WinDef.LPARAM(0));
sleep(SHORT_TIMEOUT);
User32.INSTANCE.PostMessage(resultHwnd, SYS_KEYUP, new WinDef.WPARAM(key), new WinDef.LPARAM(0));
sleep(SHORT_TIMEOUT);
}
// キー同時押し
public static void sendPairKey(int key1, int key2) {
User32.INSTANCE.PostMessage(resultHwnd, SYS_KEYDOWN, new WinDef.WPARAM(key1), new WinDef.LPARAM(0));
sleep(SHORT_TIMEOUT);
User32.INSTANCE.PostMessage(resultHwnd, SYS_KEYDOWN, new WinDef.WPARAM(key2), new WinDef.LPARAM(0));
sleep(SHORT_TIMEOUT);
User32.INSTANCE.PostMessage(resultHwnd, SYS_KEYUP, new WinDef.WPARAM(key2), new WinDef.LPARAM(0));
sleep(SHORT_TIMEOUT);
User32.INSTANCE.PostMessage(resultHwnd, SYS_KEYUP, new WinDef.WPARAM(key1), new WinDef.LPARAM(0));
}
IEのファイルダウンロードを試みる
ユーティリティメソッドを整えたところで、IEのダウンロード通知バーを操作する処理を書いてみましょう。
まずテストクラスにWebページからファイルをダウンロードするテストを書きます。
(ブラウザ起動については割愛しています)
@Test
void downloadFileViaNoticeBar() {
int amount = downloadDir.listFiles().length;
driver.get("download_page_url");
driver.findElementByCssSelector("download_link").sendKeys(Keys.ENTER);
// 数秒待機
sleep(LONG_TIMEOUT);
// 通知バー処理
JnaUtils.downloadVisUIForIE();
// ファイルが一個ふえてたらPass
assertEquals(amount + 1 ,downloadDir.listFiles().length);
}
spy++を使って「保存(S)」ボタンのウインドウハンドル取得を試みましたが、残念ながら単体の部品としては認識されませんでした。通知バーのウインドウハンドルは取得可能だったので、こちらに対して「Alt + S」キーを送出する処理をJnaUtilsクラスに実装することにします。
public static void downloadVisUIForIE() {
searchWindow(driver.getTitle(), true);
WinDef.HWND h = User32.INSTANCE.FindWindowEx(resultHwnd, null, "Frame Notification Bar", "");
WinDef.HWND notice = searchChildItem(h, "DirectUIHWND", "Frame Notification Bar",1);
sendPairKey(notice, VK_MENU, S_KEY);
}
Testメソッドでは、ダウンロードを待つために数秒待機していますが、ファイルによっては数秒で終わらない場合もありますし、反対に1秒かからない場合もあるでしょう。これは明らかによろしくない実装例です。
実際の運用ではJNIで通知バーの処理を完了するまでリトライと待機をするロジックが必要となります。
IEのファイルダウンロードフォルダの設定を試みる
IEではファイルダウンロード時のデフォルト保存フォルダをSelenium側で指定することができません
※ Firefox、Chrome、Edge(Chromium)では可能です。
あらかじめIEのファイルダウンロードを設定しておく必要があるのですが、
実行端末が増えると手間がかかり、管理が面倒です。
そこで、この操作もJNIで自動化してみることにします。
IEのダウンロードフォルダの設定を行うには、合計3つのダイアログを処理します。
実装例はこちらです。
// ダウンロードフォルダを設定する
void setDownloadDir(String path) {
// IEウインドウからキーボードで「ダウンロードの表示」を呼び出し
JnaUtils.searchWindow(driver.getTitle());
JnaUtils.sendKey(VK_MENU);
sleep(SHORT_TIMEOUT);
JnaUtils.sendKey(T_KEY);
sleep(SHORT_TIMEOUT);
JnaUtils.sendKey(N_KEY);
// 「オプション」をクリック
JnaUtils.searchWindow("ダウンロードの表示");
JnaUtils.findChildWindow("DirectUIHWND", "");
JnaUtils.sendPairKey(VK_MENU, 'O');
sleep(SHORT_TIMEOUT);
// 「ダウンロードオプション」で「参照」ボタンを捕捉してクリック
JnaUtils.searchWindow("ダウンロード オプション");
JnaUtils.searchChildItem("Button","#32770",1);
JnaUtils.leftClick();
sleep(SHORT_TIMEOUT);
// 「ダウンロードのインポート先フォルダーを選択」でファイルパスを記入
JnaUtils.searchWindow("ダウンロードのインポート先フォルダーを選択");
JnaUtils.searchChildItem("Edit","#32770",1);
JnaUtils.pasteTextData(path);
// 「フォルダーを選択」ボタンをクリック
JnaUtils.searchWindow("ダウンロードのインポート先フォルダーを選択");
JnaUtils.searchChildItem("Button","#32770",1);
JnaUtils.leftClick();
sleep(SHORT_TIMEOUT);
// ダイアログを閉じる
JnaUtils.searchWindow("ダウンロード オプション");
JnaUtils.searchChildItem("Button","#32770",3);
JnaUtils.leftClick();
sleep(SHORT_TIMEOUT);
// ダイアログを閉じり
JnaUtils.searchWindow("ダウンロードの表示");
JnaUtils.findChildWindow("DirectUIHWND", "");
JnaUtils.sendPairKey(VK_MENU,'C');
}
かなり長くなってしまいました。
テストメソッドから呼び出す形をとっており、ウインドウハンドルはJnaUtils内部でメンバとして持たせて、外部からはアクセスできないようにしています。これは、ユーティリティを使用するユーザにウインドウハンドルのことを考えて欲しくないという配慮からです。
おおよその処理の流れは以下のとおりです。
・IEウインドウからキーボードで「ダウンロードの表示」を呼び出し
・「オプション」をクリック
・「ダウンロードオプション」で「参照」ボタンを捕捉してクリック
・「ダウンロードのインポート先フォルダーを選択」でファイルパスを記入
・「フォルダーを選択」ボタンをクリック
・ダイアログを閉じていく
繰り返しになりますが、実際の運用ではこれらの処理の一つ一つに処理の完了チェックやリトライ・待機のロジックを組むことになります。
まとめ
最後にご紹介した実装例では3つのダイアログウインドウを検索し、4つの部品(ボタンやテキストボックス)を検出して処理しています。
ダイアログ・ウインドウはタイトル名をキーとするので比較的容易ですが、それ以外のテキストボックスやボタンについてはInspectやspy++を使って調べなければいけません。
1つの部品あたり10分ほどの調査と試行錯誤の時間を要したと思います。
慣れれば多少は早くなるものの、RPAツールのように柔軟なデスクトップアプリの自動化を、この技術で実現するのはちょっと厳しそうなことがわかります。
どうしても自動化したいが他の技術は適用できないピンポイントな共通処理、といったユースケースに効果を発揮しそうだと思いました。
最後にちょっとだけ宣伝です。
弊社のGUI自動化ツールRacine(関連記事)では、上述のJNIユーティリティを標準で実装していますので、テストコード作成と同時にご利用になれます。また、お客様のサービスにあわせて、自動化アーキテクトが過去のナレッジを生かした柔軟な対応をすることで、自動化の要件に合わせた最適なE2E自動テストをご提供します。
E2Eテスト自動化をご検討の場合は、是非とも弊社の窓口にご相談ください。
__________________________________
お問合せはお気軽に
https://service.shiftinc.jp/contact/
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/