‘Appium’のエントリ

Weather Typing 3.6を公開するにあたって、UIテストの自動化を研究していた。今、ウェザタイのリグレッションテストの項目が約2500件、WPFだけで500件くらいある。これだけあるとテストだけで数週間かかってしまい、何か修正してもテストがめんどうで公開をあとまわしにしてしまう。人を増やさずになんとかならないか考え続けた結果、UIテストがそろそろ実用的になっていそうなので、やってみた。

Webの世界ではSeleniumがよく使われているが、それをデスクトップアプリやモバイルアプリに使えるようにしたAppiumというフレームワークがある。バイナリだけでテストができるので、C#+Xamarin.Formsで作ったアプリでも、C#でテストコードを書いて、マルチプラットフォームでテストできる。

で、まあいろいろ苦労はしたが、最終的にWPFのテストは77%の自動化ができた。しかも面倒なテストはほとんど自動化できたので、手動のテストを含めても、早ければ1日でテストが終わる感触。

なお、今回はWPFなので、WindowsAppDriverを使用。Windowsストアアプリも同じものが使えるはずで、Android他はまた違うAppiumのドライバを使うが、テストコードは基本的に共有ができるはず。

Palm OSのアプリを作ってた時代は、グレムリンテスト(モンキーテストの自動化)くらいしかなくて、とてもちゃんとUIのテストはできなかった記憶があるけど、RPAも実用的になっている今の時代、以下のような項目くらいは自動化できるようになっていた。

  • ボタンなどをクリックして画面遷移した先のラベルなどが表示されていることを確認する
  • 入力ボックスの文字数制限やエラー表示が正しいことを確認する
  • 複数のインスタンスを起動して、互いに協調動作(具体的にはネット対戦)することを確認する

具体的には、プログラム上でUI部品にIDを与えておくと、実行時にそのIDからテキストを取得したり、クリックしたりできる。さらにテスト自体はプログラミング言語を使って書くので、ロジックは入れ放題。プラットフォーム依存になるけどファイル操作なんかも普通にできるので、かなり自由度が高い。

逆に、どうしても自動化できなかったのは例えば以下の項目。そりゃそうだろうね、っていう感じ。頑張ればできるかも知れないけど手動でやってもたいしたことはない。

  • 文字の色や背景画像を確認する
  • 音がなる、ならないことを確認する
  • 拡大縮小やスプリッターの位置を確認する
  • マウスカーソルの表示・非表示を確認する

UIテストをやる前は、結局目で見ないとおかしいことに気付かないから意味がないかな、と思ってたけど、Appiumの場合、実際にマウスでクリックしたりキーボードを入力したりするので、手動でテストするのとそんなに変わらない。テストスピードがあまり早くないのもあって、テストの様子を見ていればレイアウトが変になっていたりしても割と気付く。

手動ではどうしても確認にミスが出るので、むしろ自動の方が正確だし、手動の時は全組合せのテストは不可能である程度抜粋にせざるをえなかったが、自動なら何も考えずに全組合せテストしてもたいしたことはないので網羅的になる。

メモ。今回のUIテストでは以下のことを気を付けて作ってみた。

  • 汎用的になるようにする。エラーメッセージの確認なんかは、メッセージそのものを確認するとエラーメッセージの変更に堪えられないので、キーワードを含むかどうかだけ確認するようにしてみた。
  • いつでもテストを実行できるようにする。前提条件がないように、テスト項目開始時に設定ファイルやリプレイファイルなんかを全て削除して、必ず同じ条件で実行できるようにしてみた。

ついでに、WindowsAppDriverでハマったこと。

  • Xamarinからだと直接Accessibility IDを設定できない。Xamlから、Effectを使ってIDを設定し、各プラットフォーム固有コードでIDを設定するようにした。
  • 画面に表示されていない項目をクリックしたりはできないので、スクロールがめんどう。結果的にWindows用に最適化されている状態なので、Androidとかでどうしよう。
  • キーボードが101キーボードになっていて、打てない記号がある。特にバックスラッシュを打つ方法が見つからなかった。
  • Windows用のAppium用クラスとAndroid用のAppium用クラスが別物なので、統一的に扱えない。なので、一個ラップしたクラスを作って同じように使えるようにした。
  • Async/Awaitするとテストが終わってしまう。WaiterのUntilをうまく使って回避するくらいしかない。

ちょっと前からWeather TypingのWindowsデスクトップ版以外の更新をやっているのだが、テストが2300件くらいあって、さすがにテストが大変。てことでUIテストの自動化を本格的に調査。1週間くらいかかってようやく見えてきたのでメモ。ちなみにAndroid/UWP/WPFは実験成功して、Mac/iOSはまだ試せていない。

UIテストはいろいろな方法があると思うけど、マルチプラットフォーム対応だとAppiumがよさそう。Appiumは、WebのUIテスト自動化Seleniumと同様の方法でモバイルアプリのテストをするフレームワーク。サーバクライアントになっていて、クライアントでテストコードを書くと、サーバにJSON形式のコマンドを投げて、サーバからアプリのUIに向かってキーボードやマウスのイベントを投げてくれる。AppiumのサーバだとAndroidとiOSがテストできるが、Windows Application Driverを使えばWindowsのWPFやUWPもテストができる。クライアントについては、Pythonなどでやるのが普通かもしれないけど、Appium.WebDriverを使えばでC#で開発が可能。

まとめると、C#でUIをテストするコードを書いて、Appiumのサーバを起動すればAndroid/iOSのUIテスト、Windows Application Driverを起動すればWPF/UWPのテストが可能ということ。さらに一工夫いるが、Xamarin.Formsのアプリもテスト可能である。

Appiumのインストール
ここは普通にAppiumとAppium-doctorをインストール。Pythonとかnodeとかそのあたり。Appium-doctorを通すためにいろいろインストールする必要がある。だいたいはなんとかなるはずだが、Windowsで「bundletool.jar cannot be found」になるので環境変数「PATHEXT」に「.JAR」を追加する。これ1日はまったけどappium-doctorのソースまで見てようやく分かった。
Windows Application Driverのインストール
普通にインストール。特に困ることはない
Appium.WebDriverのインストール
とりあえずVisual StudioでUnitTestプロジェクトを作り、NuGetからインストールした。UnitTestなの? って思うけど・・・別にコンソールアプリでもいいのだが、テストケースの管理とかでUnitTestがいいかな、ってくらい
UI部品にIDを振る
テストの時にUI部品を特定するため、それぞれIDを振る。Appiumではいくつか特定の仕方があるが、これ実質Accessibility IDしか使えないよね。てことでXamarinのXAMLで、ButtonとかにAutomationId=”hoge”を入れる。だけでよければ話は簡単なんだけど、Android、UWPはうまくいったがWPFはダメだった。Xamarinのソースを見る限りWPFではViewRendererでSetAutomationIdをやっていないみたい。なので、Effectを使って特定の属性を設定すると、各プラットフォーム向けにAccessibility IDに対応する属性を設定するようにしてみた。これもEffectなの? って感じだけど、これ以外にうまい方法は思い浮かばない。

まずはEffectの属性。プラットフォーム共通。

    public static class TestEffect
    {
        public static readonly BindableProperty NameProperty =
          BindableProperty.CreateAttached("Name", typeof(string), typeof(TestEffect), "", propertyChanged: OnNameChanged);

        public static string GetName(BindableObject view)
        {
            return (string)view.GetValue(NameProperty);
        }

        public static void SetName(BindableObject view, string value)
        {
            view.SetValue(NameProperty, value);
        }

        private static void OnNameChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var view = bindable as View;
            if (view == null)
            {
                return;
            }

            var name = (string)newValue;
            view.Effects.Add(new UITestEffect());
        }
    }

    class UITestEffect : RoutingEffect
    {
        public UITestEffect() : base("com.denasu.UITestEffect")
        {
        }
    }

次に各プラットフォーム用のEffect。

[assembly: ResolutionGroupName("company name")]
[assembly: ExportEffect(typeof(App1.WPF.UITestEffect), "UITestEffect")]

namespace App1.WPF
{
    public class UITestEffect : PlatformEffect
    {
        protected override void OnAttached()
        {
            var name = TestEffect.GetName(Element);
            Control.SetValue(System.Windows.FrameworkElement.NameProperty, name);
        }

        protected override void OnDetached()
        {
            Control.SetValue(System.Windows.FrameworkElement.NameProperty, "");
        }
    }
}
[assembly: ResolutionGroupName("company name")]
[assembly: ExportEffect(typeof(App1.UWP.UITestEffect), "UITestEffect")]

namespace App1.UWP
{
    public class UITestEffect : PlatformEffect
    {
        protected override void OnAttached()
        {
            var name = TestEffect.GetName(Element);
            Control.SetValue(Windows.UI.Xaml.Automation.AutomationProperties.AutomationIdProperty, name);
        }

        protected override void OnDetached()
        {
            Control.SetValue(Windows.UI.Xaml.Automation.AutomationProperties.AutomationIdProperty, "");
        }
    }
}
[assembly: ResolutionGroupName("company name")]
[assembly: ExportEffect(typeof(App1.Droid.UITestEffect), "UITestEffect")]

namespace App1.Droid
{
    public class UITestEffect : PlatformEffect
    {
        protected override void OnAttached()
        {
            var name = TestEffect.GetName(Element);
            Control.ContentDescription = name;
        }

        protected override void OnDetached()
        {
            Control.ContentDescription = "";
        }
    }
}

XAMLではEffectを付ける。

        <Entry local:TestEffect.Name="Text1" Text="{Binding TextA}" WidthRequest="100" />
        <Entry local:TestEffect.Name="Text2" Text="{Binding TextB}" WidthRequest="100"/>
        <Label local:TestEffect.Name="Result" Text="{Binding TextC}" WidthRequest="100"/>
        <Button local:TestEffect.Name="Execute" Text="Button" Clicked="Button_Click" WidthRequest="100"/>
テストケースの作成

C#ではこんな感じ。各プラットフォームのセッションを作って、共通のテストを実行するようにしてみた。

[TestClass]
public class UnitTest
{
    [TestMethod]
    public void TestMethod1()
    {
        var session = CreateSessionUWP();

        session.FindElementByAccessibilityId("Text1").Click();
        session.Keyboard.PressKey("111");
        session.FindElementByAccessibilityId("Text2").Click();
        session.Keyboard.PressKey("222");
        session.FindElementByAccessibilityId("Execute").Click();
        Assert.AreEqual(session.FindElementByAccessibilityId("Result").Text , "111222");
    }

    private WindowsDriver<WindowsElement> CreateSessionUWP()
    {
        DesiredCapabilities appCapabilities = new DesiredCapabilities();
        appCapabilities.SetCapability("app", "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXXXXXXXXXXXXXXXX!App");
        return new WindowsDriver<WindowsElement>(new Uri("http://127.0.0.1:4723"), appCapabilities);
    }

    private WindowsDriver<WindowsElement> CreateSessionWPF()
    {
        DesiredCapabilities appCapabilities = new DesiredCapabilities();
        appCapabilities.SetCapability("app", @"c:\xxx\App1.WPF.exe");
        return new WindowsDriver<WindowsElement>(new Uri("http://127.0.0.1:4723"), appCapabilities);
    }

    private AndroidDriver<AndroidElement> CreateSessionAndroid()
    {
        DesiredCapabilities appCapabilities = new DesiredCapabilities();
        appCapabilities.SetCapability(MobileCapabilityType.DeviceName, "emulator-5554");
        appCapabilities.SetCapability(MobileCapabilityType.PlatformVersion, "8.1");
        appCapabilities.SetCapability(AndroidMobileCapabilityType.AppPackage, "com.companyname.App1");
        appCapabilities.SetCapability(MobileCapabilityType.PlatformName, "Android");
        appCapabilities.SetCapability(AndroidMobileCapabilityType.AppActivity, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.MainActivity");
        return new AndroidDriver<AndroidElement>(new Uri("http://127.0.0.1:4723/wd/hub"), appCapabilities);
    }

実行はこんな感じ。静止画じゃ分からないけど、勝手に文字が入力されてボタンが押される。

てことで、単純なUIのテストはこれで自動化できるかな。まあ、UIが崩れるとか、なんか変、っていうバグの方が多いんだけど、画面遷移とかはこれで自動化して、人が見ないと分からないようなテストにリソースを集中できれば成功かな。

そして、まだ試してないけど問題文章も自動で打てるのだろうか。そうすると不正ツールを作れてしまうかも。とりあえず打ち込み速度が遅いからよさそうだけど、設定ができたりすると厄介。