pywinautoでテスト自動化(電卓アプリ)

今回はpywinautoを用いて、電卓アプリのテスト自動化をしてみました。
その内容について、記載したいと思います。

# 伝えたいこと

- pywinautoはGUIアプリを自動操作するツールの1つで、要素指定方法を選択できる（Win32 api or UI Automation）
- pywinautoとpytestを組み合わせればテスト自動化できる

# 今回実施した環境

| 項目 | バージョン |
|:-----------|:------------|
| OS       | Windows 10 pro       |
| Python      | 3.9.6        |
| pywinauto         | 0.6.8          |
| pytest    | 7.1.2     |
| Pillow       | 9.1.1      |

## pywinautoとは

- Microsoft WindowsGUIを自動化するためのPythonモジュールのセット
- 特長としては、要素指定する時のバックエンドを選択可能（Win32 API or MicroSoft UIAutomation）
  - ちなみに、AutoItはWin32 APIがベース

## インストール方法

Pythonが入っている環境で、以下のコマンド実行します。
```
pip install pywinauto
```
## 自動化するテスト内容

Windowsの電卓を起動して 1+2 を計算すると、結果表示欄に3が表示されるかというテストを自動化してみます。

## 実装コード

[公式ページ](https://pywinauto.readthedocs.io/en/latest/contents.html)やQiita記事（参考資料に後述）をもとに実装してみました。
※処理内容を説明するため、コメント多めです。

```test_calc.py
import pytest
from subprocess import Popen
from pywinauto import Desktop
from PIL import Image
from time import sleep

def test_calc():

    # 電卓アプリを起動
    Popen('calc.exe', shell=True)
    # バックエンドをuiaに切り替え、自動操作するアプリをName要素で指定
    mainwin = Desktop(backend="uia")["電卓"]

    # 電卓アプリが起動するまで待機する
    mainwin.wait('visible')

    # 1+2の計算をする
    mainwin['1'].click()
    mainwin['プラス'].click()
    mainwin['2'].click()
    mainwin['等号'].click()
    
    # 1秒待機した後、スクリーンショット
    sleep(1)
    mainwin.capture_as_image().save("result.png")

    # 計算結果表示欄が3であるか検証
    result = mainwin.child_window(auto_id='CalculatorResults', control_type='Text').texts()
    assert result == ['表示は 3 です']

    # 電卓アプリを閉じる
    mainwin.close()

```

実施は通常のpytestと同じです。

```
pytest ファイル名
```

実際に自動テストが実行されました。
![スクリーンショット 2022-05-20 173835.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1895890/0eb65e09-8e7b-7e85-54bf-c235783af329.png)

また、実行後のキャプチャもばっちり取れています。
![result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1895890/c6310e82-7705-9ab0-39b0-e641d724ce6f.png)

## 実装の詳細

### アプリ起動

pywinautoでは2つの起動方法があります。
①Appluicationオブジェクトで起動
```application_object.py
from pywinauto.application import Application
app = Application(backend="uia").start('notepad.exe')

# describe the window inside Notepad.exe process
dlg_spec = app.UntitledNotepad
# wait till the window is really open
actionable_dlg = dlg_spec.wait('visible')
```
②Desktopオブジェクトで起動
```desktop_object.py
from subprocess import Popen
from pywinauto import Desktop

Popen('calc.exe', shell=True)
dlg = Desktop(backend="uia").Calculator
dlg.wait('visible')
```
今回は②のDesktopを使って起動しました。
上記の参考スクリプトは英語版のアプリケーション起動時のコードです。
日本語版のアプリケーションの場合には、以下の記述になります。
```py
app['Untitled - Notepad']
# is the same as
app.window(best_match='Untitled - Notepad')
```
### バックエンドの選択

バックエンドをUI Automationにする場合は、
操作対象のアプリを指定時に、バックエンド設定を変更します。
```change_backend.py
# バックエンドをuiaに選択する時のみ、(backend="uia")を記載
dlg = Desktop(backend="uia").Calculator
```
::: note info
【Win32 API or UI Automationの選択基準】
どちらのバックエンドが適しているか調べる場合、
以下のオブジェクト識別ツールで要素の取得具合を比較してください。
・inspect.exe　👈UI Automationベース
・spy++        👈Win32 APIベース
:::

#### ハマったところ

私はアプリの起動方法をどちらで実装すれば良いかわかりませんでした。
最初はApplicationオブジェクトで起動していたのですが、その次の処理で「そんなアプリ見つからないよ」っていうエラーになりました。
アプリの起動方法が原因だと分からず、結構時間とられました。

```
pywinauto.findwindows.ElementNotFoundError: {'best_match': '電卓', 'backend': 'uia', 'process': 14960}
```
どういう基準で起動方法を選択すればよいかがまだわかっていないので、分かり次第更新します。
何かあればコメント御願いします。

とりあえず、起動はいけるけどそのあと動かないな～っていう人は以下の2点を検討してみると良いと思います。
- バックエンドの選択
- アプリの起動方法

### 要素の指定

pywinautoではベストマッチアルゴリズムを使って指定しています。
指定する要素名の調べ方は2通りあります。
1. inspect.exe/Spy++で調べる
1. pywinautoのmainwin.print_control_identifiers()で調べる

ちなみに2の方法を実施すると以下のように各要素にマッチする名前一覧が出力されます。
```
# コマンド
mainwin=Desktop[backend="uia"]("電卓")

#コマンド実施結果（一部抜粋）
Dialog - '電卓'    (L80, T0, R498, B675)
['電卓', '電卓Dialog', 'Dialog', '電卓0', '電卓1', '電卓Dialog0', '電卓Dialog1', 'Dialog0', 'Dialog1']
child_window(title="電卓", control_type="Window")
   | 
   | Dialog - '電卓'    (L256, T1, R489, B41)
   | ['電卓2', '電卓Dialog2', 'Dialog2']
   | child_window(title="電卓", auto_id="TitleBar", control_type="Window")
   |    |
   |    | Menu - 'システム'    (L0, T0, R0, B0)
   |    | ['システム', 'Menu', 'システムMenu', 'システム0', 'システム1']
   |    | child_window(title="システム", auto_id="SystemMenuBar", control_type="MenuBar")
   |    |    |
   |    |    | MenuItem - 'システム'    (L0, T0, R0, B0)
   |    |    | ['MenuItem', 'システムMenuItem', 'システム2']
   |    |    | child_window(title="システム", control_type="MenuItem")
   | Dialog - '電卓'    (L89, T1, R489, B666)
   | ['電卓3', '電卓Dialog3', 'Dialog3']
   | child_window(title="電卓", control_type="Window")
   |    |
   |    | Static - '電卓'    (L104, T11, R134, B31)
   |    | ['電卓4', 'Static', '電卓Static', 'Static0', 'Static1']
   |    | child_window(title="電卓", auto_id="AppName", control_type="Text")
   |    | 
   |    | GroupBox - ''    (L89, T41, R489, B660)
   |    | ['電卓GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1']
   |    |    | 
   |    |    | Static - '式は  です'    (L0, T0, R0, B0)
   |    |    | ['式は  です', '式は  ですStatic', 'Static2']
   |    |    | child_window(title="式は  です", auto_id="CalculatorExpression", control_type="Text")
   |    |    |
   |    |    | Static - '表示は 0 です'    (L89, T118, R489, B214)
   |    |    | ['Static3', '表示は 0 ですStatic', '表示は 0 です']
   |    |    | child_window(title="表示は 0 です", auto_id="CalculatorResults", control_type="Text")
   |    |    | GroupBox - '数字パッド'    (L94, T392, R386, B660)
   |    |    | ['数字パッドGroupBox', 'GroupBox6', '数字パッド']
   |    |    | child_window(title="数字パッド", auto_id="NumberPad", control_type="Group")
   |    |    |    | 
   |    |    |    | Button - '0'    (L192, T595, R287, B660)
   |    |    |    | ['0Button', '0', 'Button23']
   |    |    |    | child_window(title="0", auto_id="num0Button", control_type="Button")
   |    |    |    | 
   |    |    |    | Button - '1'    (L94, T527, R189, B592)
   |    |    |    | ['1Button', '1', 'Button24']
   |    |    |    | child_window(title="1", auto_id="num1Button", control_type="Button")
None
```
実際に要素を指定する場合は、以下の2つの方法で指定します。
- []内に書かれている名前
- child_window
  - auto_idを使っているので、[]の名称が同じものが多い場合はこちらが安全かと思います。

例) ボタン1を指定したいとき
```sample.py
# []の名前で指定
mainwin["1"]
# child_windowで指定
mainwin.child_window(auto_id="num1Button", control_type="Button")
```

### 使用するモジュールについて

pywinautoのモジュールはクリック操作、データ入力、要素名取得やスクリーンショットなどさまざまありますが、対象要素のクラスによって扱えるメソッドが異なります。
そのため、要素のクラスを調べる必要があります。
以下のコマンドで調べることができます。
```
# 電卓ボタン[1]のクラスを調べる
mainwin["1"].wrapper_object().__class__.mro()

# 実施結果
[<class 'pywinauto.controls.uia_controls.ButtonWrapper'>, <class 'pywinauto.controls.uiawrapper.UIAWrapper'>, <class 'pywinauto.base_wrapper.BaseWrapper'>, <class 'object'>]

```
この情報をもとに、[公式サイトのリファレンス](https://pywinauto.readthedocs.io/en/latest/code/code.html#controls-reference)から使用できるモジュールを選択してください。

### スクリーンショット
pywinautoのスクリーンショットモジュールと[Pillowの保存モジュール](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.save)を組み合わせています。
そのため、画像を取得したい場合は[Pillow](https://pillow.readthedocs.io/en/stable/installation.html)もインストールする必要があります。
```
# pywinautoのスクリーンショットモジュール
capture_as_image()

# Pillowの保存モジュール
対象の画像.save("ファイルパス")
```

## やってみた感想

pywinautoでテスト自動化はできるものの、最初の要素時点で苦戦しそうだなと感じました。
それ以外の部分については、公式ドキュメントやQiita記事もあるので実装しやすいと思いました。

## 終わりに

ということで、今回はpywinautoを使ってテスト自動化してみました。
興味があればpywinautoをインストールして、触ってみていただければと思います。

## 参考資料

- [pywinauto - 公式サイト](https://pywinauto.readthedocs.io/en/latest/contents.html)
- [pywinauto - ユニコード文字や特殊記号の実装方法](https://pywinauto.readthedocs.io/en/latest/getting_started.html#attribute-resolution-magic)
- [Pillow - 公式サイト](https://pillow.readthedocs.io/en/stable/index.html)
- [Pythonを利用して簡単にお手製RPAを作成する](https://qiita.com/t-izaki/items/f3f2a99ede24be1f0373)
- [pywinauto の使い方メモ](https://qiita.com/nobu-maple/items/51901b2e27e4448f102b)