このCodelabは、前半のスライドパートと後半のCodleabパートの二部構成になっています。

前半のスライドパートはこのCodelabからリンクしています。スライドパートで学べる内容は次の通りです。

後半のCodelabパートで学べる内容は次の通りです。

事前知識として必要なもの

ショートカット表記について

Android Studioのショートカットキーを押して何らかのアクションを実行してほしい場合は、

Change Signature (Command + F6)を実行します」

のように、アクション名の後に括弧書きでMacのショートカットキーを表記しています。

Mac以外の環境で、ショートカットキーが分からない場合は、Android Studioのメニュー Help > Find Action にAction名を入力してください。お使いの環境におけるショートカットが表示されます。
また、次節で紹介するスライドパートの最終ページにWindows版のショートカット一覧をまとめています。あわせて参考にしてください。

推奨環境

予め実施しておいていただきたいこと

題材アプリの概要

GoogleがAndroid Jetpackのサンプルアプリとして公開しているAndroid Sunflowerの2019年5月18日時点のソースコードをベースに、少し改修を加えたものを題材として使用します。改修内容としては、JavaからKotlinへの書き換えなどを行いました。

題材アプリの主要な機能は以下のとおりです。

sunflower screenshotssunflower screenshotssunflower screenshots

スライドパートでは、次の内容を解説しています。

  1. UIテストの自動化を始める前に
  2. テストツール選択のポイント
  3. 長くテストコードを利用し続けるには

全て目を通していただくことが望ましいですが、時間が無い場合は次の箇所だけでも目を通してみてください。
以降の理解がスムーズになります。

Android Studioには、Espresso のテスト記録ツール Espresso Test Recorderが備わっています。
Test Recorder を使えば、テスト対象アプリの操作を記録し、Espressoのテストコードとして保存してくれますので、Espressoの知識なしにテストケースを作れます。

Espresso Test Recorderの起動

Recorder でテストの記録を開始するために次のものを用意します。

ここでは、題材アプリをAndroid Studioで開き、エミュレータ・Android端末が認識されている状態にしてください。

その状態でメニューからRun > Record Espresso Testを選ぶと、記録が始まります。
Android端末上でテスト対象アプリが立ち上がり、Record Your Testと書かれたダイアログが表示されれば記録が始まっています。

操作の記録

次の手順で操作をしてみましょう。ひとつ操作するたびにRecord Your Testダイアログに操作した内容が記録されます。記録時の応答速度は遅いため、ダイアログに操作内容が反映されるまで待ってから、次の操作を行うようにしてください。

  1. 左上のハンバーガーボタン(≡の形をしたメニューボタン)をタップする
  2. ナビゲーションドロワーが開くのを待ってPlant listをタップする
  3. Plant list画面に表示されている植物一覧の中から、Avocadoをタップする
  4. Plant Detail画面の右下にあるfavボタン(+ボタン)をタップする
  5. Plant Detail画面左上の戻るボタン(←ボタン)をタップする
  6. Plant list画面左上の戻るボタン(←ボタン)をタップする

操作が終わると、Android端末ではMy Garden画面に「Avocado planted」と表示されてます。この操作が完了したときRecord Your Testダイアログの表示は下記のようになっているはずです。

Record Step

アサーションの記録

画面に表示されている内容を検証するアサーションの記録をしてみましょう。

Recorderでアサーションの手順を記録するにはRecord Your Testダイアログ右下のAdd Assertionボタンを押します。
ボタンを押してしばらく待つと、右半分にテスト対象アプリの画面スナップショットが表示された状態になります。

Record Step

次の手順でアサーションを記録します。

  1. Record Your Testダイアログ右側の画面スナップショットで、「Avocado planted」と表示されている箇所をクリックします。
    Android端末は操作しませんので、間違わないように注意してください
  2. 検証対象のViewが赤い枠で囲まれます。検証したい箇所が意図どおりか確認してください。
  3. Record Your Testダイアログ左下のEdit assertion枠内が、上から順に「com.google.samples.apps.sunflower:id/plant_date」「Text is」「Avocado planted」となっていることを確認します。
  4. Save Assertionボタンをクリックします。
  5. Record Your Testダイアログ左上に検証手順が記録されていることを確認します。

テストコードの生成

テストしたい内容の記録が終わったら、テストコードを生成する手順に移ります。Record Your Testダイアログ右下のOKボタンを押してください。
テストクラス名と言語(Java or Kotlin)を尋ねるダイアログが表示されるので、テストクラス名は「GardenActivityTest」、言語はKotlinを選択してください。
GardenActivityTest.ktにKotlinで書かれたテストコードが生成されます。

テストコードの修正

Espresso Recorderが生成されたコードは残念ながら、最新のAPIに対応していません。(Android Studio3.4時点)
そのため、手動でテストコードを修正する必要があります。

AndroidJUnit4のpackageを変更

はじめに修正をするのはこの箇所です。

@RunWith(AndroidJUnit4::class)
class GardenActivityTest {
    ..
}

@RunWithにdeprecatedになったandroidx.test.runner.AndroidJUnit4が指定されています。
代わりにandroidx.test.ext.junit.runners.AndroidJUnit4を使用するように変更しましょう。

Before

import androidx.test.runner.AndroidJUnit4

After

import androidx.test.ext.junit.runners.AndroidJUnit4

ActivityTestRuleからActivityScenarioRuleへの変更

Activityのテスト用APIとして、新たにActivityScenarioが提供されています。
これにより、既存のActivityTestRuleは今後deprecatedになる予定です。

ActivityTestRuleを使用している箇所を、ActivityScenarioRuleを使うように変更しましょう。

Before

class GardenActivityTest {

    @Rule
    @JvmField
    var mActivityTestRule = ActivityTestRule(GardenActivity::class.java)
    ..
}

After

class GardenActivityTest {

    @get:Rule
    val activityScenarioRule = activityScenarioRule<GardenActivity>()
    ..
}

APIとあわせてvarvalにてread-onlyとし、@JvmField@getに変更しました。

テストコードの実行

生成されたテストコードを実行してみましょう。GardenActivityTest.ktを開き、テストクラスの宣言部(class GardenActivityTestと書かれている箇所)の左側にある緑の矢印をクリックします。

テストコードが問題なく実行できることを確認できたら、この生成されたテストコードを保守性の高いテストコードに変更していきます。

前のセクションではTest Recorderを使って、テストコードの自動生成を行いました。
しかし、この自動生成されたテストコードをそのまま運用するのは、ほとんどのケースではお勧めできません。
次にまたTest Recorderを使って「Apple」を追加するケースを追加したとします。このとき、既存のテストコードと重複が多いテストコードが生成されます。
これを繰り返していくと、テスト対象のアプリのUIが変更されたときの修正作業が膨大になってしまいます。

そこで、PageObjectデザインパターンを適用します。

PageObjectデザインパターンは、対象アプリのUIが変更されたときのテストコード修正コストを小さくすることを目的として考案されました。具体的にはテストコードを共通化 する指針を与えています。

PageObjectデザインパターンの基本的な考え方は、次のとおりです。

  1. テスト対象の画面ごとにクラス(Pageクラス)を定義する
  2. Pageクラスには、その画面が提供するサービスをメソッドとして定義する。典型的に
    はアクションを実行するメソッドと、ページの情報を取得するメソッドを定義する
  3. アクションを実行するメソッドは戻り値として、遷移先の画面に対応する Page オブ
    ジェクト(Page クラスのインスタンス)を返す
  4. 各テストケースを実装するときはPageクラスのメソッドだけを使う

SunflowerにPageObjectデザインパターンを適用させる

この考え方をさきほど記録したテストに当てはめてみると、次のようになります。

My Garden Pageクラス

object MyGardenPage {

    // Plant list画面に遷移する
    fun goPlantList(): PlantListPage

    // plantNameが植えられていることを確認する
    fun assertPlanted(plantName: String): MyGardenPage
}

Plant List Pageクラス

object PlantListPage {

    // 指定したplantNameのPlant Detail画面に遷移する
    fun showPlantDetail(plantName: String): PlantDetailPage

    // My Garden画面に遷移する
    fun goBackMyGarden(): MyGardenPage
}

Plant Detail Pageクラス

object PlantDetailPage {

    // 表示されている植物を植える
    fun addToMyGarden(): PlantDetailPage

    // Plant list画面に遷移する
    fun goBackPlantList(): PlantListPage
}

そして、これらのPageクラスを使用したテストコードの完成イメージは下記のようになります。

GardenActivityTest

@RunWith(AndroidJUnit4::class)
class GardenActivityTest {

    @get:Rule
    val activityScenarioRule = activityScenarioRule<GardenActivity>()

    @Test
    fun gardenActivityTest() {

        MyGardenPage.goPlantList()
                .showPlantDetail("Avocado")
                .addToMyGarden()
                .goBackPlantList()
                .goBackMyGarden()
                .assertPlanted("Avocado")
    }
}

Test Recoderが生成したテストコードと比較すると、どのような操作をしているかがわかりやすくなっています。
また、新たに「Apple」を植えるシナリオを追加するときも、同じPageクラスを再利用することができます。

PageObjectデザインパターンの実装イメージができましたか?
次は、実際にPageObjectデザインパターンを適用させ、既存のテストコードを書き換えていきます。
ここでは、IDEの機能を使ってテストコードを壊さないように安全に進めていきます。

書き換えは下記のstepで行います。

  1. メソッドを抽出する
  2. 抽出したメソッドをそれぞれのPageクラスに移動する
  3. 移動したメソッドの戻り値を遷移先画面のPageクラスにする

Test Recorderが生成したテストコードには、各処理で使われているprivateの共通メソッドchildAtPositionが含まれています。
まずはこれをglobalなヘルパー関数にし、残りのstepをスムーズに行えるようにしましょう。

childAtPositionはテストクラスの一番下に定義されています。

class GardenActivityTest {

    ..
    //ここから
    private fun childAtPosition(
            parentMatcher: Matcher<View>, position: Int): Matcher<View> {

        return object : TypeSafeMatcher<View>() {
            override fun describeTo(description: Description) {
                description.appendText("Child at position $position in parent ")
                parentMatcher.describeTo(description)
            }

            public override fun matchesSafely(view: View): Boolean {
                val parent = view.parent
                return parent is ViewGroup && parentMatcher.matches(parent)
                        && view == parent.getChildAt(position)
            }
        }
    }
    //ここまで
}

globalな関数への変更

メソッド全体を範囲選択し、選択範囲がクラスの外に移動するまでMove Line Down (Option + Shift + ↓)を複数回実行します。

次にメソッド名childAtPositionにカーソルをあて、Change Signature (Command + F6)を実行します。

ダイアログがでてきますので、Visibilityを「public」に変更します。これで、childAtPositionはglobalな関数になりました。

Change Signature

TestHelperへの移動

最後に、Test用のヘルパー関数を持つファイルを作り、そこにchildAtPositionを移動させます。

再度メソッド名childAtPositionにカーソルをあて、Move (F6)を実行します。

ポップアップがでてきますので、To package:File name:にチェックを入れ、ファイル名に「TestHelper.kt」と入力します。

Move

Moveの実行が完了すると、テストコードと同じディレクトリにTestHelper.ktが作成され、その中にchildAtPositionメソッドが配置されています。

これで事前準備は終わりです。次からのセクションでは、同じ用にIDEのリファクタリング機能を活用しながらPage Objectクラスの作成していきます。

書き換えのステップ

  1. メソッドを抽出する
  2. 抽出したメソッドをそれぞれのPageクラスに移動する
  3. 移動したメソッドの戻り値を遷移先画面のPageクラスにする

書き換えのステップを確認しましょう。まずは、メソッドの抽出を行います。

これまでのセクションで、各Pageクラスとメソッドを設計しました。
Test Recorderで記録した操作とPageクラスのメソッドを対応付けると、以下のようになります。

操作

Pageクラスのメソッド

1. 左上のハンバーガーボタン(≡の形をしたメニューボタン)をタップする
2. ナビゲーションドロワーが開くのを待って`Plant list`をタップする

MyGardenPage#goPlantList()

3. Plant list画面に表示されている植物一覧の中から、`Avocado`をタップする

PlantListPage#showPlantDetail(plantName: String)

4. Plant Detail画面の右下にあるfavボタン(+ボタン)をタップする

PlantDetailPage#addToMyGarden()

5. Plant Detail画面左上の戻るボタン(←ボタン)をタップする

PlantDetailPage#goBackPlantList()

6. Plant list画面左上の戻るボタン(←ボタン)をタップする

PlantListPage#goBackMyGarden()

7. My Garden画面に「Avocado planted」と表示されていること

MyGardenPage#assertPlanted(plantName: String)

このセクションでは、まずすべてのメソッドをトップレベルの関数として抽出します。
メソッド抽出が完了すると、次の6つのトップレベル関数が作成されます。

MyGardenPage#goPlantListの抽出

まずは、MyGardenPageのgoPlantList()を抽出しましょう。
該当するテストコードはこの範囲です。

class GardenActivityTest {

    @Test
    fun gardenActivityTest() {

        // goPlantListメソッドの抽出 ここから
        val appCompatImageButton = onView(
                allOf(withContentDescription("Open navigation drawer"),
                        childAtPosition(
                                allOf(withId(R.id.toolbar),
                                        childAtPosition(
                                                withId(R.id.appbar),
                                                0)),
                                1),
                        isDisplayed()))
        appCompatImageButton.perform(click())

        val navigationMenuItemView = onView(
                allOf(childAtPosition(
                        allOf(withId(R.id.design_navigation_view),
                                childAtPosition(
                                        withId(R.id.navigation_view),
                                        0)),
                        2),
                        isDisplayed()))
        navigationMenuItemView.perform(click())
        //ここまで
        ..
    }
}

これらの行を範囲選択し、Android StudioでExtract Function to Scope (Option + Shift + Command + M)を実行します。
名前のとおり、選択した範囲をメソッドとして抽出します。

Select target code blockのダイアログが出ますので、「GardenActivityTest.kt」を選択します。これにより、GardenActivityTest.kt内のトップレベル関数として抽出することができます。

次のダイアログでは、visibilityに「public」、Nameに「goPlantList」を入力します。テストコードは、下記のように変更されました。

class GardenActivityTest {

    @Test
    fun gardenActivityTest() {

        goPlantList()

        ..
    }
}


fun goPlantList() {
    ...
}

MyGardenPage#assertPlanted(plantName: String)の抽出

次に、同じくMyGardenPageのassertPlanted(plantName: String)を抽出します。
ここではメソッドだけでなく、引数のplantNameもIDEの機能を使って安全に抽出します。

class GardenActivityTest {

    @Test
    fun gardenActivityTest() {
        goPlantList()

        ...

        // assertPlanted(plantName: String)の抽出 ここから
        val textView = onView(
                allOf(withId(R.id.plant_date), withText("Avocado planted"),
                        childAtPosition(
                                childAtPosition(
                                        withId(R.id.garden_list),
                                        0),
                                1),
                        isDisplayed()))
        textView.check(matches(withText("Avocado planted")))
        //ここまで
    }
}

これらの行を範囲選択し、Extract Function to Scope (Option + Shift + Command + M)を実行します。先程と同様に、Scopeには「GardenActivityTest.kt」を選択してください。
visibilityに「public」、Nameに「assertPlanted」を入力します。テストコードは、下記のようになります。

class GardenActivityTest {

    @Test
    fun gardenActivityTest() {

        goPlantList()

        ...

        assertPlanted()
    }
}

...

fun assertPlanted() {
    val textView = onView(
            allOf(withId(R.id.plant_date), withText("Avocado planted"),
                    childAtPosition(
                            childAtPosition(
                                    withId(R.id.garden_list),
                                    0),
                            1),
                    isDisplayed()))
    textView.check(matches(withText("Avocado planted")))
}

引数の抽出

次は、抽出したassertPlantedメソッドの引数に、植物名(plantName)を取るように修正しましょう。

assertPlantedメソッド内の文字列リテラル"Avocado planted"のうちAvocadoのみを選択し、Extract Parameter (Option + Command + P)を実行します。

Introduce patameterダイアログのParameter Nameには「plantName」と指定します。
抽出が完了すると、テストコードはこのようになっています。自動的に引数に"Avocado"を渡すように修正されました。

class GardenActivityTest {

    @Test
    fun gardenActivityTest() {

        goPlantList()

        ...

        assertPlanted("Avocado")
    }
}

...

fun assertPlanted(plantName: String) {
    val textView = onView(
            allOf(withId(R.id.plant_date), withText("$plantName planted"),
                    childAtPosition(
                            childAtPosition(
                                    withId(R.id.garden_list),
                                    0),
                            1),
                    isDisplayed()))
    textView.check(matches(withText("$plantName planted")))
}

その他のメソッドの抽出

残りの操作も、同じ手順でメソッド化していきましょう。

すべてのメソッドの抽出が完了すると、テストコードはこのようになっているはずです。

class GardenActivityTest {

    @Test
    fun gardenActivityTest() {
        goPlantList()
        showPlantDetail("Avocado")
        addToMyGarden()
        goBackPlantList()
        goBackMyGarden()
        assertPlanted("Avocado")
    }
}

fun goBackMyGarden() { ... }

fun goBackPlantList() { ... }

fun addToMyGarden() { ... }

fun showPlantDetail(plantName: String) { ... }

fun assertPlanted(plantName: String) { ... }

fun goPlantList() { ... }

書き換えのステップ

  1. メソッドを抽出する
  2. 抽出したメソッドをそれぞれのPageクラスに移動する
  3. 移動したメソッドの戻り値を遷移先画面のPageクラスにする

次は、さきほど抽出したメソッドをPageクラスに移動させます。
Pageクラスとメソッドの対応づけを確認しましょう。

Pageクラス

メソッド

MyGardenPage

goPlantList()
assertPlanted(plantName: String)

PlantListPage

showPlantDetail(plantName: String)
goBackMyGarden()

PlantDetailPage

addToMyGarden()
goBackPlantList()

MyGardenPageのメソッドの移動

MyGardenPageにひもづくメソッドはgoPlantListassertPlanted(plantName: String)です。

まず、メソッド名goPlantListにカーソルをあて、Move (F6)を実行します。

ダイアログが表示されますので、次のように設定します。

MoveMyGarden

Moveの実行が完了すると、テストコードと同じディレクトリにMyGardenPage.ktが作成され、その中にgoPlantListassertPlanted(plantName: String)が移動されています。

MyGardenPage.kt

fun goPlantList() { .. }

fun assertPlanted(plantName: String) { .. }

MyGardenPageクラスの作成

次に、MyGardenPageクラスを作成します。MyGardenPage.kt内の2つのメソッドをobject MyGardenPageブロックで囲みます。

object MyGardenPage {

    fun goPlantList() { .. }

    fun assertPlanted(plantName: String) { .. }
}

GardenActivityTest.ktファイルに戻ると、コンパイルエラーになっています。
エラーになっているコードにカーソルをあてた状態でShow Intention Actions (Option + Enter)を実行し、import文を修正します。

これで、MyGardenPageクラスにメソッドを移動させるステップが完了しました。

その他のメソッドの移植

その他のメソッドも、同様の手順でPageクラスに移動させましょう。すべてのメソッドの移植が完了すると、それぞれのPageクラスは以下のようになります。

MyGardenPage.kt

object MyGardenPage {

    fun goPlantList() { .. }

    fun assertPlanted(plantName: String) { .. }
}

PlantListPage.kt

object PlantListPage {

    fun showPlantDetail(plantName: String) { .. }

    fun goBackMyGarden() { .. }
}

PlantDetailPage.kt

object PlantDetailPage {

    fun addToMyGarden() { .. }

    fun goBackPlantList() { .. }
}

書き換えのステップ

  1. メソッドを抽出する
  2. 抽出したメソッドをそれぞれのPageクラスに移動する
  3. 移動したメソッドの戻り値を遷移先画面のPageクラスにする

Page Objectデザインパターンでは、アクションを実行するメソッドは戻り値として遷移先の画面に対応するPage オブ
ジェクト(Page クラスのインスタンス)を返すようにします。

MyGardenPageクラス内メソッドの戻り値を修正する

MyGardenPageクラス内メソッドの遷移先は以下の通りです。

メソッド

遷移先画面

遷移先画面

goPlantList()

Plant List画面

PlantListPage

assertPlanted(plantName: String)

遷移なし

MyGardenPage

まずは、goPlantListメソッドの戻り値を変更しましょう。

goPlantListの末尾にreturn PlantListPageを追加します。
現在のgoPlantListは戻り値を設定していませんので、コンパイルエラーになります。
そこでエラーになっている箇所にカーソルをあててShow Intention Actions (Option + Enter)を実行します。

Change return type of enclosing function..というアクションがサジェストされるので実行します。
自動的にgoPlanListの戻り値がPlantListPageに変更されます。

object MyGardenPage {

    fun goPlantList(): PlantListPage { .. }
    ..
}

次はassertPlanted(plantName: String)の戻り値を変更します。このメソッドは画面遷移をしません。
その場合、戻り値はthis、自分自身を返すようにします。

assertPlantedの末尾にreturn thisを追加します。
コンパイルエラーになったらShow Intention Actions (Option + Enter)を実行して、先程と同様のアクションを実行します。

object MyGardenPage {

    fun assertPlanted(plantName: String): MyGardenPage { .. }
    ..
}

MyGardenPageクラスの修正は、これで完了です。

その他の戻り値の変更

その他のメソッドも、同様の手順で戻り値を変更しましょう。すべて変更が完了すると、それぞれのPageクラスは以下のようになります。

MyGardenPage.kt

object MyGardenPage {

    fun goPlantList(): PlantListPage { .. }

    fun assertPlanted(plantName: String): MyGardenPage { .. }
}

PlantListPage.kt

object PlantListPage {

    fun showPlantDetail(plantName: String): PlantDetailPage { .. }

    fun goBackMyGarden(): MyGardenPage { .. }
}

Plant Detail Pageクラス

object PlantDetailPage {

    fun addToMyGarden(): PlantDetailPage { .. }

    fun goBackPlantList(): PlantListPage { .. }
}

現在のテストコードはこのようになっているはずです。

class GardenActivityTest {

    @Test
    fun gardenActivityTest() {

        goPlantList()
        showPlantDetail("Avocado")
        addToMyGarden()
        goBackPlantList()
        goBackMyGarden()
        assertPlanted("Avocado")
    }
}

これを次のように修正してください。

class GardenActivityTest {

    @Test
    fun gardenActivityTest() {

        MyGardenPage.goPlantList()
                .showPlantDetail("Avocado")
                .addToMyGarden()
                .goBackPlantList()
                .goBackMyGarden()
                .assertPlanted("Avocado")
    }
}

修正のポイントはの2点です。

  1. 起動時のPageクラスから始まる
  2. 各メソッドをメソッドチェーンでつなげる

各メソッドは戻り値として遷移先のPageクラスを返すようにしているので、遷移先のアクションをつなげて書くことができるのです。
また、呼び出しメソッドの補完も効くようになります。

PageObjectデザインパターンの基本的な考えをおさらいします。

  1. テスト対象の画面ごとにクラス(Pageクラス)を定義する
  2. Pageクラスには、その画面が提供するサービスをメソッドとして定義する。典型的に
    はアクションを実行するメソッドと、ページの情報を取得するメソッドを定義する
  3. アクションを実行するメソッドは戻り値として、遷移先の画面に対応する Page オブ
    ジェクト(Page クラスのインスタンス)を返す
  4. 各テストケースを実装するときはPageクラスのメソッドだけを使う

PageObjectデザインパターンを適用することで、可読性が高く、操作の流れもわかりやすいテストコードを作成することができました。

EspressoのTest Recorderの使い方と、Page Objectデザインパターンの実装方法を学びました。
今後のあなたの仕事に役立てていただけるのであれば幸いです。

このCodelabについて

このCodelabはDeNAのSWETグループが作成しました。
swet logo
もしご不明点や間違い等あれば、トップページの「このページについて」に記載されている手順でIssueを起票していただければと思います。

このCodelabの一部は、関係者の許諾の元、書籍「Androidテスト全書」の4章「UI テスト(概要編)」と5章「UI テスト(Espresso編)」の内容を一部流用しています。

「Androidテスト全書」にはここで紹介する内容のほか、役立つ情報が満載です。気になる方は読んでみてください。

Androidテスト全書

Androidテスト全書

  • 著者:白山 文彦,外山 純生,平田 敏之,菊池 紘,堀江 亮介,
  • 製本版,電子版
  • PEAKSで購入する

フィードバックのお願い

最後に、お手数ですが、次ページで本Codelabに対するフィードバックをいただけますと嬉しいです。
今後の私たちの活動に活かしていきたいと思います。