kamulog

xamarin.formsのネタなど

Test Cloudを使わずにXamarin.UITestだけを使ってのローカルUIテスト(実行編)

前回 Test Cloudを使わずにXamarin.UITestだけを使ってのローカルUIテスト(準備編)の続きです。

今回は実際にテストコードを書いてテストを実行させるサンプルをご紹介します。

素材・完成品

今回は以前作成したTheme切り替えサンプル用のBMI計算アプリをテスト対象にして説明していきます。

github.com

前回終了時コミットはこちら

完成コミットはこちらです。

テストシナリオ

こんな感じのテストを行います。

  1. BMI初期表示「0.0」を確認
  2. 指標初期表示 空文字確認
  3. 身長スライダーを最小・最大に動かしてテキスト確認
  4. 体重スライダーを最小・最大に動かしてテキスト確認
  5. 「指標を表示する」スイッチをON・OFFしてみる
  6. 計算するタップで計算結果と指標テキストが表示されているか
  7. スクリーンショットを撮る〜切替実行〜スクリーンショットを撮る
  8. スクリーンショットを目視で確認

f:id:kamusoft:20170117151646p:plain:w240

Xaml上でAutomationIdをつけていく

Xamarin.UITestでは

app.Tap(x=>x.hoge)

みたいな感じで要素を特定するクエリを書いていくのですが、 あらかじめ要素のAutomationIdを振っておくと

app.Tap("AutomationId")

で簡単に記述できるようになります。

なのでタップしたいViewや調べたいViewにXaml上でAutomationIdを振っていきます。

<?xml version="1.0" encoding="utf-8"?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
        xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
        xmlns:ef="clr-namespace:ThemeSample.Effects;assembly=ThemeSample"
        xmlns:prism="clr-namespace:Prism.Mvvm;assembly=Prism.Forms" 
        prism:ViewModelLocator.AutowireViewModel="True" 
        x:Class="ThemeSample.Views.MainPage" Title="BMI測定"
        BackgroundColor="{DynamicResource AppBackground}" 
        AutomationId="MainPage"
        >
    <ContentPage.ToolbarItems>
        <!-- テスト対象 -->
        <ToolbarItem AutomationId="AlterThemeButton" Priority="1" Command="{Binding AlterThemeCommand}" Text="切替" />
    </ContentPage.ToolbarItems>
    
    <StackLayout Spacing="10" Margin="10">
        <Label Text="あなたのBMI" HorizontalOptions="Center" Style="{DynamicResource LargeLabel}" />
        <!-- テスト対象 -->
        <Label AutomationId="BmiLabel" Text="{Binding BmiValue,StringFormat='{0:0.0}'}" HorizontalOptions="FillAndExpand" Style="{DynamicResource ObjectLabel}" />
        <!-- テスト対象 -->
        <Label AutomationId="IndexLabel" Text="{Binding IndexText}" IsVisible="{Binding ShowIndex}" HorizontalOptions="FillAndExpand" Style="{DynamicResource ObjectLabel}" /> 
        <BoxView Color="Transparent" VerticalOptions="FillAndExpand" />
        <Grid VerticalOptions="End" Margin="0,0,0,20">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="36" />
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="50" />
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="50" />
                <RowDefinition Height="50" />
            </Grid.RowDefinitions>
            <Label Grid.Column="0" Grid.Row="0" Text="身長" VerticalTextAlignment="Center" HorizontalTextAlignment="End" Style="{DynamicResource NormalLabel}" />
            <!-- テスト対象 -->
            <Slider AutomationId="HeightSlider" Grid.Column="1" Grid.Row="0" Value="{Binding Height}" />
            <Label  AutomationId="HeightText" Grid.Column="2" Grid.Row="0" Text="{Binding RealHeight,StringFormat='{0:0}cm'}" VerticalTextAlignment="Center" HorizontalTextAlignment="Start" Style="{DynamicResource NormalLabel}" />
            <Label Grid.Column="0" Grid.Row="1" Text="体重" VerticalTextAlignment="Center" HorizontalTextAlignment="End" Style="{DynamicResource NormalLabel}" />
            <!-- テスト対象 -->
            <Slider AutomationId="WeightSlider" Grid.Column="1" Grid.Row="1" Value="{Binding Weight}" />
            <Label  AutomationId="WeightText" Grid.Column="2" Grid.Row="1" Text="{Binding RealWeight,StringFormat='{0:0}kg'}" VerticalTextAlignment="Center" HorizontalTextAlignment="Start" Style="{DynamicResource NormalLabel}" />
        </Grid>
        <StackLayout Orientation="Horizontal" Spacing="10" HorizontalOptions="End" Margin="0,0,10,0">
            <Label Text="指標を表示する" VerticalTextAlignment="Center" Style="{DynamicResource NormalLabel}" />
            <!-- テスト対象 -->
            <Switch AutomationId="IndexToggleSwitch" IsToggled="{Binding ShowIndex}" />
        </StackLayout>
        <!-- テスト対象 -->
        <Button AutomationId="CalculateButton" Text="計算する" VerticalOptions="End" Command="{Binding CalculateCommand}" />
    </StackLayout>
</ContentPage>

テスト用をクラスを作成

テンプレートが生成したTests.csを使用しても良いんですが、今回は専用の以下のようなテストクラスを作成します。

namespace ThemeSample.UITests
{
    [TestFixture(Platform.Android)]
    [TestFixture(Platform.iOS)]
    public class MainPageFixture
    {
        IApp app;
        Platform platform;

        public MainPageFixture(Platform platform)
        {
            this.platform = platform;
        }

        [TestFixtureSetUp]
        public void AppStart()
        {
            app = AppInitializer.StartApp(platform);
        }

    }
}

UITest用のクラスのコンストラクタは必ずPlatformを引数にとるものでないとダメみたいです。

TestFixtureSetUpという属性をつけると、このクラスのテストを開始する時に1回だけ実行するようになり、 ここでアプリの起動を行うようにします。

テンプレートで使用していたSetUp属性だとテストメソッドのたびに呼ばれてしまって 各テストを実行する度にアプリが起動しなおすようになってしまいます。 そうなると途中経過が把握しずらくなるので、クラス単位でのアプリ実行する動きにしました。

元のTests.csのAppLanches方は

[Test]
public void AppLaunches()
{
    app.Repl();
}

こんな感じにしてRepl専用にしています。要素を調べる必要があればこのテストを実行して、 表示されるコンソールからtreeと打って要素を調べたり、app.Flashで確認したりします。

テストコードの記述

あとはシナリオ1〜7に対応するテストメソッドを記述していきます。8は目視なのでコードにしません(できません)。

[TestFixtureSetUp]
public void AppStart()
{
    app = AppInitializer.StartApp(platform);
    app.WaitForElement("MainPage", "Timeout", TimeSpan.FromSeconds(10));
}

[Test]
public void T01_DefaultBmi()
{
    var ret = app.Query("BmiLabel")[0];
    Assert.AreEqual("0.0",ret.Text);
}

[Test]
public void T02_DefaultIndex()
{
    var ret = app.Query("IndexLabel")[0];
    Assert.IsNullOrEmpty(ret.Text);
}

[Test]
public async Task T03_HeightSliderTest()
{
    app.ScrollDownTo("HeightSlider");
    await Task.Delay(250);
    app.SetSliderValue("HeightSlider",0d);
    Assert.AreEqual("30cm",app.Query("HeightText")[0].Text);
    app.SetSliderValue("HeightSlider", OnPlatform(1.0d,1000d));
    Assert.AreEqual("220cm", app.Query("HeightText")[0].Text);
}

[Test]
public void T04_WeightSliderTest()
{
    app.ScrollDownTo("WeightSlider");
    app.SetSliderValue("WeightSlider", 0d);
    Assert.AreEqual("5kg", app.Query("WeightText")[0].Text);
    app.SetSliderValue("WeightSlider", OnPlatform(1.0d, 1000d));
    Assert.AreEqual("150kg", app.Query("WeightText")[0].Text);
}

[Test]
public async Task T05_IndexSwitchIsOn()
{
    //ON->OFF
    app.ScrollDownTo("IndexToggleSwitch");
    app.Tap("IndexToggleSwitch");
    app.ScrollUpTo("BmiLabel");
    await Task.Delay(250);
    Assert.AreEqual(0,app.Query("IndexLabel").Count());

    //OFF->ON
    app.ScrollDownTo("IndexToggleSwitch");
    app.Tap("IndexToggleSwitch");
    app.ScrollUpTo("BmiLabel");
    await Task.Delay(250);
    Assert.AreEqual(1, app.Query("IndexLabel").Count());
}

[Test]
public async Task T06_CalculateTest()
{
    app.ScrollDownTo("CalculateButton");
    app.Tap("CalculateButton");
    await Task.Delay(250);

    Assert.AreNotEqual("0.0",app.Query("BmiLabel")[0].Text);
    Assert.IsNotNullOrEmpty(app.Query("IndexLabel")[0].Text);
}

[Test]
public async Task T07_ThemeChangeTest()
{
    app.Screenshot("Before");
    app.Tap(OnPlatform("AlterThemeButton","切替"));
    await Task.Delay(250);
    app.Screenshot("After");
}

T OnPlatform<T>(T iOS, T android)
{
    if (platform == Platform.iOS) {
        return iOS;
    }
    else {
        return android;
    }
}

メソッドに連番つけてるのがダサいですが、こうするしか順番を保証できないので…。

以下コード中の特記事項です。

IApp.WaitForElement

TestFixtureSetUpにMainPageが表示されるまで待機する処理を入れています。 何かが表示されるまで待つ時はこのメソッドを使うと良いと思います。

IApp.Query

要素を探してその結果をAppResult[]で返してくれるメソッドです。 一意のAutomationIdの場合は[0]でとってこれば良いかと。

AppResult型
AppResult Class - Xamarin

TextやEnabledやDescriptionプロパティあたりで要素の内容をある程度確認できます。 基本的にはTextで確認できることが多いですが、Descriptionでしか確認できないものがあった場合は正規表現等でがんばってとるしかないです。

IApp.ScrollDownTo IApp.ScrollUpTo

指定の要素が表示されるようにスクロールさせるメソッドです。 今回のアプリは一画面に収まるように作っているので無くても問題はないんですが、 Xamarin.UITestでは画面に表示されているものが全てで、表示されているものしか要素探索の対象になりません。 なので画面外にある可能性がある場合はスクロールさせてから処理する必要があります。

IsVisible=fasleの場合も例外では無く画面から消えるのでその時点でアクセスできなくなります。その条件を利用して見つからなかったらIsVisible=falseは成功したと判断させることもできそうです。

スクロールしない(ScrollViewが存在しない)画面に対してScroll系のメソッドを使うとiOSでは問題ないのですが、Androidで激重になるという問題に遭遇しましたので、Scroll系を使う場合はScrollViewの有無を確認した方が良いと思います。

IApp.SetSliderValue

Sliderの値を変える専用のメソッドです。 が、バグくさい挙動があってvalueの範囲がiOSは0〜1dでAndroidが0〜1000dになってるようです。

それぞれのPlatformに合うようにOnPlatformというジェネリックメソッドを使って対応しました。

IApp.Tap

指定の要素をタップするメソッドです。 タップした後に何かが変化してから次の処理をしたい場合はTask.Delayを入れておくのが無難です。

ToolbarItemのAutomationIDについて

AndroidではToolbarItemに指定したAutomationIdが反映されなかったので、そこはボタンのTextで探すようにしました。 この場合だと、一意にならない可能性があるので本来は事前にReplで調べたId等でアクセスさせる方が良いと思います。

Assertについて

今回ほとんどのメソッドで何らかのAssertをしてますが、単純に正常に動作したかの確認だけだったら必須ではありません。

テスト実行

対象のテストクラスを右クリック実行でまとめてテスト実行できます。

f:id:kamusoft:20170117210237p:plain

失敗したところは赤くなるのでどこでコケたのかもわかりやすいです。

twitterリンクですが、実行の様子はこんな感じになります。

リファレンス

このあたりを見ておくとよりわかりやすいかも。

まとめ

  • Test Cloudを使わなくてもXamarin.UITestは非常に便利
  • AutomationIdを振って簡単にアクセスできるようになる
  • メソッドを小分けにしてダメな箇所を探しやすく
  • どうやって要素を特定するかわからない時はReplで調べる
  • 見えている表示されている要素が全て、画面外をTapしたりQueryで取得したりはできない
  • Scroll系メソッドをしかるべきタイミングで使用する
  • よくわからないけど何故かTestが動かなくなったらとりあえずシミュレータ再起動
  • ScreenShotはなんとかならなかったのだろうか
  • 思ったよりテスト作成に時間がかかる
  • Test Recorderも試してみたい