【テクニカル・上級編】Windows FormsアプリケーションのUI自動化テスト導入:FlaUIを用いた画面遷移と入力値検証のCI/CDパイプライン連携 – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク

Windows Forms UIテストの極限:FlaUIによる堅牢な自動化、COMメモリ解放、そしてCI/CDセッション0の壁を突破する

長年、エンタープライズの現場でWindows Forms(以下、WinForms)アプリケーションの保守と拡張に向き合ってきたアーキテクトにとって、システム変更時の「退行テスト(リグレッションテスト)」は常に頭痛の種である。特に、数十以上の画面、複雑なイベント駆動型UI、そしてVB6やWin32 APIの遺産を引きずったレガシーなVB.NETシステムにおいて、手動テストによる検証はコストと品質の両面で限界を迎えている。

かつてMicrosoftが提供していたCoded UI Test (CUIT) は非推奨となり、Appium / WinAppDriverも開発の停滞が目立つ。この混沌とした状況において、Windows UIテスト自動化の決定打となるのが「FlaUI」である。

本稿では、FlaUI(UIA3/UIA2)を用いたWinFormsアプリのUI自動化テストについて、単なる入門書に留まらない、メモリ最適化、COMオブジェクトの破棄、Win32 APIによる補完、そしてCI/CDパイプライン実行における「非対話型セッション(Session 0)の壁」を突破する実践的アプローチを徹底的に解説する。

—

1. なぜFlaUIなのか:UIA2/UIA3のアーキテクチャ選択とCOMの真実

FlaUIは、WindowsのUI Automation (UIA) ライブラリの薄いラッパーである。FlaUIを選択するにあたり、まず直面するのが UIA2 (UI Automation 2.0) と UIA3 (UI Automation 3.0) の選択である。

| 項目 | FlaUI.UIA2 | FlaUI.UIA3 |
| :— | :— | :— |
| ベース技術 | 管理コード(UIAutomationClient.dll) | ネイティブCOM(UIAutomationCore.dll) |
| 対象UI | WinForms, WPF, Win32 (レガシー) | WPF, UWP, WinUI, WinForms (モダンOS) |
| パフォーマンス | 中(マネージドブリッジによるオーバーヘッド) | 高(ネイティブCOM直接呼び出し) |
| メモリ特性 | GCに依存しやすい | COM参照カウントの厳密な管理が必要 |

レガシーなWinFormsアプリケーション、特にカスタムコントロールやActiveXコントロールを内包するシステムの場合、UIA3では一部のコントロール要素を正しく認識できない(またはプロパティ取得時にフリーズする)ケースがある。その場合はUIA2を選択せざるを得ない。しかし、Windows 10/11環境で動作する純粋な.NET WinFormsであれば、パフォーマンスと機能性に勝るUIA3を選択すべきである。

COMオブジェクトのライフサイクルとメモリリークの罠

FlaUIは内部で多量のCOMオブジェクトを生成する。通常、.NETのガベージコレクション(GC)がRCW(Runtime Callable Wrapper)を通じてこれらを解放するが、数千ものUI要素を走査する大規模なUIテストでは、GCの動作が追いつかずにメモリが急増し、テスト実行中にプロセスがアウト・オブ・メモリ(OOM)でクラッシュすることがある。

堅牢な自動化コードを構築するためには、テストスイートの節目(各テストクラスの終了時など)で明示的にGCを強制し、COM参照をクリアする必要がある。

—

2. 実践:FlaUIによるWinForms自動化テストコード(VB.NET)

ここでは、ログイン画面(`LoginForm`)からメイン画面(`MainForm`)へと遷移し、データを入力・検証するテストシナリオをVB.NETで実装する。

ターゲットとするWinFormsアプリケーション(検証対象)の仕様

1. `LoginForm` が起動。
2. `txtUsername` (AutomationId) にユーザー名を入力。
3. `txtPassword` (AutomationId) にパスワードを入力。
4. `btnLogin` (AutomationId) をクリック。
5. 認証成功後、`MainForm` が表示され、ログイン画面はクローズまたは非表示。
6. `MainForm` の `lblStatus` (AutomationId) のテキストが “Welcome [ユーザー名]” になっていることを検証。

テストコード実装(NUnit 3 / FlaUI.UIA3)

Imports System.IO
Imports SystemImports System.Runtime.InteropServices
Imports FlaUI.Core
Imports FlaUI.Core.AutomationElements
Imports FlaUI.Core.Input
Imports FlaUI.Core.Tools
Imports FlaUI.UIA3
Imports NUnit.Framework


Public Class WinFormsUiAutomationTests

Private _automation As UIA3Automation
Private _app As Application
Private Const TargetAppPath As String = “C:\Deploy\TargetWinFormsApp.exe”


Public Sub SetUp()
‘ 1. UIA3オートメーションインスタンスの生成
_automation = New UIA3Automation()
End Sub


Public Sub Test_Login_And_Verify_WelcomeMessage()
‘ 2. アプリケーションの起動(タイムアウト制御付き)
Try
_app = Application.Launch(TargetAppPath)
Catch ex As Exception
Assert.Fail($”アプリケーションの起動に失敗しました: {ex.Message}”)
End Try

‘ 3. ログインウィンドウの取得(スピンロックによる待機)
Dim mainWindow As Window = Retry.Find(Function()
Return _app.GetMainWindow(_automation)
End Function,
TimeSpan.FromSeconds(10),
TimeSpan.FromMilliseconds(500))

Assert.That(mainWindow, Is.Not.Null, “メインウィンドウ(ログイン画面)が見つかりません。”)
mainWindow.Focus()

‘ 4. コントロールの走査と値入力(AutomationIDによる厳密な探索)
‘ ※レガシー環境でName属性が変わっても影響を受けないよう、AutomationIDを強く推奨。
Dim txtUsername As TextBox = mainWindow.FindFirstDescendant(Function(cf) cf.ByAutomationId(“txtUsername”)).AsTextBox()
Assert.That(txtUsername, Is.Not.Null, “ユーザー名入力フィールドが見つかりません。”)
txtUsername.Text = “admin_user”

Dim txtPassword As TextBox = mainWindow.FindFirstDescendant(Function(cf) cf.ByAutomationId(“txtPassword”)).AsTextBox()
Assert.That(txtPassword, Is.Not.Null, “パスワード入力フィールドが見つかりません。”)
txtPassword.Text = “SecureP@ss123”

Dim btnLogin As Button = mainWindow.FindFirstDescendant(Function(cf) cf.ByAutomationId(“btnLogin”)).AsButton()
Assert.That(btnLogin, Is.Not.Null, “ログインボタンが見つかりません。”)

‘ 5. イベント駆動UIの考慮:クリック実行後の画面遷移待機
btnLogin.Click()

‘ ログイン処理に伴うメインウィンドウの切り替えをポーリング監視
‘ ログイン成功後に表示されるメイン画面(MainForm)を最大15秒待機する
Dim mainForm As Window = Nothing
Dim isTransitioned As Boolean = SpinWait.SpinUntil(Function()
Try
‘ プロセスに紐づくトップレベルウィンドウ群から「MainForm」を探索
Dim windows = _app.GetAllTopLevelWindows(_automation)
For Each w In windows
If w.Properties.AutomationId.ValueOrDefault = “MainForm” Then
mainForm = w
Return True
End If
Next
Catch
‘ 例外は無視してループ継続
End Try
Return False
End Function, TimeSpan.FromSeconds(15))

Assert.That(isTransitioned, Is.True, “メイン画面への遷移がタイムアウトしました。”)
Assert.That(mainForm, Is.Not.Null)
mainForm.Focus()

‘ 6. 遷移後の状態検証
Dim lblStatus As Label = mainForm.FindFirstDescendant(Function(cf) cf.ByAutomationId(“lblStatus”)).AsLabel()
Assert.That(lblStatus, Is.Not.Null, “ステータスラベルが見つかりません。”)
Assert.That(lblStatus.Text, Is.EqualTo(“Welcome admin_user”), “ウェルカムメッセージが一致しません。”)

‘ 7. メイン画面の閉じる処理
mainForm.Close()
End Sub


Public Sub TearDown()
‘ — 徹底的なリソースのクリーンアップ —

‘ 1. アプリケーションプロセスの強制終了(リーク防止)
If _app IsNot Nothing Then
Try
If Not _app.HasExited Then
_app.Kill()
End If
Catch
‘ プロセス終了時の例外は無視
Finally
_app.Dispose()
_app = Nothing
End Try
End If

‘ 2. FlaUIオートメーションインスタンスの解放
If _automation IsNot Nothing Then
_automation.Dispose()
_automation = Nothing
End If

‘ 3. COMオブジェクトの参照カウントを強制的にゼロにする
‘ これを怠ると、CI/CDでテストを連続実行した際に、バックグラウンドにゾンビプロセスやCOM参照が残る。
GC.Collect()
GC.WaitForPendingFinalizers()
GC.Collect()
End Sub
End Class

—

3. レガシーシステム連携:Win32 APIによる強硬突破策

FlaUI(ひいてはUI Automation)は強力だが、OSのネイティブダイアログ(ファイル選択ダイアログ、プリンター設定など)や、一部のサードパーティ製レガシーコントロールに対して、要素のキャプチャが機能しない、あるいはクリックイベントが不発に終わる局面が存在する。

このような「UI Automationの死角」を突破するために、テストランナー側からWin32 APIを直接叩いて対象ウィンドウを制御するハイブリッド・アプローチを組み込む。

Windows APIを呼び出すヘルパークラス

Public Class NativeMethods
‘ ウィンドウの検索

Public Shared Function FindWindow(
ByVal lpClassName As String,
ByVal lpWindowName As String) As IntPtr
End Function

‘ 子ウィンドウ(ボタンやエディットボックス)の検索

Public Shared Function FindWindowEx(
ByVal hwndParent As IntPtr,
ByVal hwndChildAfter As IntPtr,
ByVal lpszClass As String,
ByVal lpszWindow As String) As IntPtr
End Function

‘ メッセージの送信(クリック、テキスト設定など)

Public Shared Function SendMessage(
ByVal hWnd As IntPtr,
ByVal Msg As UInteger,
ByVal wParam As IntPtr,
ByVal lParam As String) As IntPtr
End Function

‘ ウィンドウを最前面に持ってくる(これを行わないと入力がエミュレートできない場合がある)

Public Shared Function SetForegroundWindow(ByVal hWnd As IntPtr) As Boolean
End Function

Public Const WM_SETTEXT As UInteger = &H000C
Public Const BM_CLICK As UInteger = &H00F5
End Class

Win32 APIを用いたフォールバック処理の例

FlaUIでモーダルダイアログの「OK」ボタンが捕捉できない場合、上記APIを用いて直接ハンドルを取得し、ウィンドウメッセージを送る。

Public Sub ClickLegacyDialogButton(dialogTitle As String, buttonText As String)
‘ 1. ダイアログのウィンドウハンドルを取得
Dim dialogHandle As IntPtr = NativeMethods.FindWindow(Nothing, dialogTitle)
If dialogHandle <> IntPtr.Zero Then
‘ 最前面化
NativeMethods.SetForegroundWindow(dialogHandle)

‘ 2. ボタンコントロールのハンドルを取得
Dim buttonHandle As IntPtr = NativeMethods.FindWindowEx(dialogHandle, IntPtr.Zero, “Button”, buttonText)
If buttonHandle <> IntPtr.Zero Then
‘ 3. BM_CLICKメッセージを送信して直接クリックをトリガー
NativeMethods.SendMessage(buttonHandle, NativeMethods.BM_CLICK, IntPtr.Zero, Nothing)
Else
Throw New Exception($”ダイアログ内のボタン ‘{buttonText}’ が見つかりません。”)
End If
Else
Throw New Exception($”ダイアログ ‘{dialogTitle}’ が見つかりません。”)
End If
End Sub

—

4. CI/CDパイプライン統合:非対話型セッション(Session 0)の壁を越える

自動テストは、CI/CDパイプライン(GitHub Actions, Azure Pipelines, Jenkinsなど)に組み込まれて初めて真価を発揮する。しかし、多くのエンジニアがここで「UI要素が見つからない」「テストがタイムアウトする」という最大にして最後の壁にぶつかる。

なぜCI/CD上でUIテストが落ちるのか?

CI/CDエージェント(Runner)がWindowsの「サービス」として動作している場合、それは「セッション0(Session 0)」と呼ばれる非対話型セッションで実行される。セッション0にはデスクトップ(GUI画面を描画する仮想ディスプレイ)が存在しない。そのため、WinFormsが起動しても画面がレンダリングされず、FlaUIは要素を一切捕捉できずにクラッシュする。

解決策:インタラクティブ・セッションの確保と自動ログオン

この問題を解決するためには、以下の構成を厳守しなければならない。

1. 自己ホスト型エージェント(Self-hosted Runner)の利用:
クラウド上のマネージドランナーではなく、専用のWindows仮想マシン(VM)または物理マシンをテスト実行環境として用意する。
2. 自動ログオン(AutoLogon)の設定:
Windows Sysinternalsの `Autologon` ツール等を使用し、OS起動時に特定のテスト実行用ユーザーで自動的にデスクトップにサインインさせる。
3. スタートアップ起動:
CI/CDエージェント(GitHub RunnerやAzure Pipelines Agent)を「Windowsサービス」として登録してはならない。必ず自動ログオンするユーザーの「スタートアップ(またはタスクスケジューラ)」に登録し、「インタラクティブなコンソールセッション(Session 1以降)」としてエージェントを起動する。

リモートデスクトップ(RDP)切断時のセッション維持

もう一つの罠は、管理者がRDPでテストマシンに接続し、作業を終えてRDPを閉じた(切断した)瞬間、Windowsが画面をロックし、デスクトップセッションが消失することである。これを防ぐため、RDPを閉じる際は単に×ボタンを押すのではなく、以下のバッチファイルを管理者権限で実行してセッションをアクティブなままコンソールに返却する。

@echo off
:: RDPセッションをコンソール(ローカル画面)に強制切断してセッションを維持する
for /f “skip=1 tokens=3” %%i in (‘query user %USERNAME%’) do (
%windir%\System32\tscon.exe %%i /dest:console
)

GitHub Actionsのワークフロー定義(例)

以下は、自己ホスト型ランナー(`self-hosted`)を指定し、画面解像度を確保した上でテストを実行するワークフロー定義である。

name: WinForms UI Test Automation

on:
push:
branches: [ “main” ]

jobs:
ui-test:
runs-on: [self-hosted, windows] # デスクトップセッションが維持された自前VM

steps:

  • name: Checkout Code

uses: actions/checkout@v3

  • name: Setup MSBuild

uses: microsoft/setup-msbuild@v1.1

  • name: Restore NuGet Packages

run: nuget restore YourSolution.sln

  • name: Build Solution (Release)

run: msbuild YourSolution.sln /p:Configuration=Release

  • name: Set Virtual Screen Resolution

shell: powershell
run: |
# CI実行中の解像度変化を防ぐため、仮想ディスプレイの解像度を固定
# ※必要に応じてフリーの解像度変更ユーティリティなどを実行

  • name: Run FlaUI Tests

shell: cmd
run: |
# NUnitコンソールランナー等によるテスト実行
“C:\Program Files (x86)\NUnit.org\nunit3-console\nunit3-console.exe” YourTestProject\bin\Release\YourTestProject.dll –result=TestResults.xml

  • name: Upload Test Results

uses: actions/upload-artifact@v3
if: always()
with:
name: ui-test-results
path: |
TestResults.xml
/Screenshots/.png # テスト失敗時にコード側で保存したスクリーンショット

テスト失敗時の原因究明を容易にするため、テストコード側で例外発生時に `Capture.Screen().ToFile(“Screenshots/error.png”)` を呼び出し、アーティファクトとしてCIにアップロードする仕組みを組み込むことは、実務上必須のプラクティスである。

—

5. アーキテクトが語る総括

Windows Formsという枯れた技術だからこそ、そのUIテスト自動化には「Windows OSの内部構造に対する深い理解」が求められる。

FlaUIは強力なツールであるが、それを生かすも殺すも、

  • COMオブジェクトの適切なライフサイクル管理(メモリリーク対策)
  • イベント駆動UIに対するポーリング・同期制御(スリープ乱用の排除)
  • Win32 APIを駆使したエッジケースの強硬突破
  • OSのデスクトップセッションを維持するCI/CDインフラ設計

これら「泥臭くも極めて重要な技術要素」の精緻な積み重ねである。これらを制覇したとき、あなたのシステムは「レガシー」という呪縛から解き放たれ、CI/CDの高速なフィードバックループの中で、真の安定性と俊敏性を手に入れることになるだろう。

タイトルとURLをコピーしました