kubell Creator's Note

ビジネスチャット「Chatwork」のエンジニアのブログです。

ビジネスチャット「Chatwork」のエンジニアのブログです。

読者になる

モバイルアプリのビルドをおこなうために日々動いている自動テストのお話

こんにちはー。この季節は農業日和ですね。 このゴールデンウィークは米づくりの時期です。トラクターで田起こしと田植えに励んだ日々でした。

f:id:shige0501:20210510160417p:plain

そんな充実した農業ライフの一端を紹介しようかと思いましたが、私もまだまだ修行の身。 皆さまに紹介するのはもう少し精進してから・・・ということで、今日は別のお話をしたいと思います。

改めまして、グロースエンジニアリング部のしげむら (@shige0501) です。 現在はサービスを成長させるための施策や機能改善を中心にクライアント・サーバー問わずに担当してますが、元々はAndroidアプリ開発を主としたモバイルエンジニアです。

今回はAndroidアプリの自動テストを夜な夜な動くように設定したお話をします。

自動テストが必要になったワケ

まずは、なぜ自動テストの環境を構築しようとしたのか、その背景から。

日々モバイルの開発を行い実装コードが増えていくと、ライブラリの更新などで意図せぬところで不具合が発生する問題が出てきました。 リリース前に網羅テストをすることで改修箇所に不具合がないことを確認しているものの、特定のOSバージョンのみで問題が発生するケースも有り、抜け漏れが出ないようにするのは大変です。 また、チームの人数が増える中で、各自がレビューを通してマージした修正でビルドが通らないようなこともないようにしておくと更に安心感が増します。

まとめると、以下の要件を実現することが自動テストの仕組み化の目的でした。

  • 基本的な挙動のスモークテストが実施したい
  • 複数のOSバージョンの環境で毎日テスト実行して、もしも不具合でビルド失敗するようなことがあれば翌日には気付けるようにしたい

ちなみに、他にも必要なLintチェックやユニットテストもありますが、それらについては別途GitHubへのpushをトリガーとして実施しているため、 今回の記事の対象外としました。別の場で紹介する機会があれば、、、ということで、今回はご容赦を。

自動テストの構成

では、どのようにして自動テストを実現したのか?、その構成を見ていきます。

Androidの自動テストとありますが、実現に必要なのはAndroidをビルドするCI環境です。モバイルアプリのCI環境としては、弊社ではBitriseを利用しているため、そちらを元に実現を行いました。 また、実行結果は日々の業務の中で気軽に確認できることが望ましいです。そのため、ビルドの実行結果は弊社のChatworkに通知して確認できる仕組みとしています。

f:id:shige0501:20210510174203p:plain
自動テストの構成

この仕組みはBitriseのワークフローで比較的わかりやすく実現ができています。 以下はAndorid 9 (API Level 28) のテストをエミュレーターで実施する場合のワークフローです。 同じようなワークフローを弊社のアプリがサポートしているAndroid 6 (API Level 23) 以降で一式用意しています。

f:id:shige0501:20210510181949p:plain
Bitriseのワークフロー

スモークテストの構築

実際の挙動面の動作確認として、スモークテストを採用しています。 Android アプリの基本的な動作をすべて網羅することができればよいのですが、そこまでの工数は現状割けていないため、基本的な画面の呼び出しや、ライブラリ更新でクラッシュの原因となった操作など、網羅テストで抜けがちな操作を重点的にテストするようにしています。

テストの実装はE2EテストのためにAndroid SDK標準で用意されているEspressoを用いて、Pageパターンを採用して各画面ごとに再利用可能となるような構成にしています。 ここではチャット一覧画面からコンタクト一覧画面を呼び出してコンタクト検索をおこなうようなケースを紹介します。

まず、チャット一覧画面からコンタクト一覧画面を呼び出す showContact を用意します。

object TopPage {
    ...

    /**
     * コンタクト一覧画面を呼び出す
     *
     * @return [ContactPage]
     */
    fun showContact(): ContactPage {
        Espresso.onView(
            Matchers.allOf(
                ViewMatchers.withId(R.id.bottom_navigation_menu_contact),
                ViewMatchers.withContentDescription(R.string.contact),
                ViewMatchers.isDisplayed()
            )
        ).perform(ViewActions.click())

        waitViewShownById(R.id.action_add_contact)

        return ContactPage
    }

    ...
}

弊社のAndroidアプリではアプリ起動直後に表示されるチャット一覧画面は BottomNavigation 上に表示されているため、タブを切り替える操作を行ってコンタクト一覧画面に遷移させています。

Espresso.onView(
    Matchers.allOf(
        ViewMatchers.withId(R.id.bottom_navigation_menu_contact),
        ViewMatchers.withContentDescription(R.string.contact),
        ViewMatchers.isDisplayed()
    )
).perform(ViewActions.click())

上記のコードで以下の画面中の枠線内にある コンタクト を画面上から探し、クリックする処理を行います。 f:id:shige0501:20210512170720p:plain:w300

画面遷移時は、一瞬画面の描画処理が走る関係で少しだけ待ち処理が必要です。 以下のwaitViewShownById メソッドで画面上に指定したIDのコンポーネントが表示されるまで待つようにしています。

/**
 * [resId]に指定したViewが表示されるまで待つ
 * [timeout]はデフォルトで7秒
 * Viewが表示された場合はtrue、タイムアウトした場合はfalseを返却する
 */
fun waitViewShownById(@IdRes resId: Int, timeout: Long = UI_TEST_TIMEOUT): Boolean {
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    val resources = context.resources

    val result = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
        .wait(
            Until.findObject(
                By.res(context.packageName, resources.getResourceEntryName(resId))
            ),
            timeout
        )
    return result != null
}

次に、コンタクト一覧画面のテストを用意します。 コンタクト一覧画面では複数のコンタクトが表示されているため、まずはコンタクト検索をおこない、対象のユーザーを絞り込みます。
まずは検索ボタンのクリックを行います。

// 検索ボタンをクリック
onView(
    Matchers.allOf(
       withId(R.id.action_contact_search),
        isDisplayed()
    )
).perform(click())

上記のコードで画面上の検索ボタンに対してクリック処理をおこないます。 f:id:shige0501:20210512180309p:plain:w300

続いて検索キーワードの検索処理です。

// 検索ボックスに 検索キーワードを設定する
onView(
    Matchers.allOf(
        withId(R.id.search_src_text)
    )
).perform(replaceText(SEARCH_KEYWORD), pressKey(KeyEvent.KEYCODE_ENTER))
Thread.sleep(CONTACT_SEARCH_DELAY_MILLIS)

クリックして表示されたキーワード入力欄に replaceText で指定したテキストを設定し、 pressKey でEnterキーの押下イベントを送ってやって、コンタクト検索をおこないます。 Thread.sleep は検索結果が画面に反映されるまでの待ち処理です。Thread.sleep以外にも待ち処理として適切なものがあるかもしれませんが、プロダクションコードに影響が無い箇所でシンプルさを維持しておきたい考えから、現状は本実装を採用しています。

ここまでの処理の全体の流れは以下となります。

/**
 * 検索キーワードを指定してコンタクト検索をおこなう
 *
 * @return [ContactPage]
 */
fun searchContact(): ContactPage {
    // 検索ボタンをタップ
    onView(
        Matchers.allOf(
            withId(R.id.action_contact_search),
            isDisplayed()
        )
    ).perform(click())

    // 検索ボックスに 検索キーワードを設定する
    onView(
        Matchers.allOf(
            withId(R.id.search_src_text)
        )
    ).perform(replaceText(SEARCH_KEYWORD), pressKey(KeyEvent.KEYCODE_ENTER))
    Thread.sleep(CONTACT_SEARCH_DELAY_MILLIS)

    return ContactPage
}

コンタクト検索で以下の画面にまで自動遷移させる事ができました。 f:id:shige0501:20210512181710p:plain:w300

検索結果が出てきたので、続いてコンタクトを開く処理を行います。 コンタクトの一覧部分は RecyclerView で実装しているため、 RecyclerViewActions.actionOnItemAtPosition を使って先頭のアイテムをクリックし、つながっているユーザーのプロフィール画面を呼び出します。

/**
 * コンタクトを開く
 *
 * @return [ProfilePage]
 */
fun tapContact(): ProfilePage {
    onView(
        Matchers.allOf(
            withId(R.id.contact_list_recycler_view)
        )
    ).perform(
        RecyclerViewActions
            .actionOnItemAtPosition<ContactListAdapter.ContactListRowViewHolder>(
                0,
                click()
            )
    )

    waitViewShownById(R.id.profile_toolbar_layout)

    return ProfilePage
}

プロフィール画面が開いたら、画面上に表示されている Chatwork ID を探し、想定したユーザーのプロフィールが開かれているかを ViewAssertions.matches メソッドでチェックします。

/**
 * "ビジネス検証用(管理者)" のChatwork IDが指定されているかどうかチェック
 *
 * @return [SettingsPage]
 */
fun assertProfileChatworkId(): SettingsPage {
    Espresso.onView(
        Matchers.allOf(
            ViewMatchers.withId(R.id.profile_chatwork_id),
            ViewMatchers.isDisplayed()
        )
    ).check(ViewAssertions.matches(ViewMatchers.withText(PROFILE_CHATWORK_ID)))

    return SettingsPage
}

f:id:shige0501:20210512182451p:plain:w300

ここまでで画面上の操作は準備できました。 実際のテストを組む際には、シナリオ用のクラスを別途用意しています。 今回のコンタクト検索をおこなうテストでしたら、以下のようにメソッドチェーンの形式にできます。 以下のように呼び出すことで、コンタクト一覧画面から遷移先のプロフィール画面で対象のユーザーを開いているかどうかのテストまで実施しています。

/**
 * コンタクトを検索するテストをおこなう
 */
@Test
fun contactSearch() {
    TopPage.showContact()
        .searchContact()
        .tapContact()
        .assertProfileChatworkId()
}

上記のスモークテストの実装はAndroid テスト全書が大変参考になりました。 peaks.cc

ビルドをいつ動かすのか

前項で構築した自動テストのワークフローですが、手動での実行では実行漏れが出てしまい、要件を満たすことはできません。 Bitriseにはスケジュール実行の機能があるため、そちらを有効活用しています。

f:id:shige0501:20210510164950p:plain
スケジュール実行

現在弊社では、社員が退社している深夜2時から分散させて複数のAndroid OSバージョンで自動テストを動かしています。 結果は翌日の始業時にChatworkで確認が可能なため、特定のAndroid OSバージョン下でビルドできないような修正が取り込まれたようなときには遅くとも翌日までには気付けるように仕組み化しています。

まとめ

以上が現在弊社で実施している自動テストの仕組みです。 普段の開発時もブランチへのPushをトリガーにしてLintチェックとユニットテストも動いていることから、ビルドで失敗するようなケースはほぼカバーしていますが、 夜間にビルドとテストを走らせることで、網羅的なOSバージョンの動作確認も担保できるようになりました。 Bitriseのワークフローは保守・メンテもやりやすいですし、スケジュール機能で分散させることで効率的に自動テストが走らせられるのでおすすめです。

よかったら是非お試しください〜!