このCodelabは、前半のスライドパートと後半のCodleabパートの二部構成になっています。
前半のスライドパートはこのCodelabからリンクしています。スライドパートで学べる内容は次の通りです。
後半のCodelabパートで学べる内容は次の通りです。
Android Studioのショートカットキーを押して何らかのアクションを実行してほしい場合は、
「Change Signature (Command + F6)
を実行します」
のように、アクション名の後に括弧書きでMacのショートカットキーを表記しています。
Mac以外の環境で、ショートカットキーが分からない場合は、Android Studioのメニュー Help > Find Action
にAction名を入力してください。お使いの環境におけるショートカットが表示されます。
また、次節で紹介するスライドパートの最終ページにWindows版のショートカット一覧をまとめています。あわせて参考にしてください。
Settings > System > Language & input > Language
をEnglish (United States)
にするSetting > System > About emulator device > Build number
を何回もタップし、Developer Optionsメニューを表示させるSettings > System > Advanced > Developer Options
を表示し、以下の設定を全てOFFにする$ git clone git@github.com:DeNA/codelabs.git
codelabs/sources/android-ui-tests-basic
ディレクトリに題材アプリがありますので、それをAndroid Studioで開きますGoogleがAndroid Jetpackのサンプルアプリとして公開しているAndroid Sunflowerの2019年5月18日時点のソースコードをベースに、少し改修を加えたものを題材として使用します。改修内容としては、JavaからKotlinへの書き換えなどを行いました。
題材アプリの主要な機能は以下のとおりです。
スライドパートでは、次の内容を解説しています。
全て目を通していただくことが望ましいですが、時間が無い場合は次の箇所だけでも目を通してみてください。
以降の理解がスムーズになります。
Android Studioには、Espresso のテスト記録ツール Espresso Test Recorderが備わっています。
Test Recorder を使えば、テスト対象アプリの操作を記録し、Espressoのテストコードとして保存してくれますので、Espressoの知識なしにテストケースを作れます。
Recorder でテストの記録を開始するために次のものを用意します。
ここでは、題材アプリをAndroid Studioで開き、エミュレータ・Android端末が認識されている状態にしてください。
その状態でメニューからRun > Record Espresso Test
を選ぶと、記録が始まります。
Android端末上でテスト対象アプリが立ち上がり、Record Your Test
と書かれたダイアログが表示されれば記録が始まっています。
次の手順で操作をしてみましょう。ひとつ操作するたびにRecord Your Test
ダイアログに操作した内容が記録されます。記録時の応答速度は遅いため、ダイアログに操作内容が反映されるまで待ってから、次の操作を行うようにしてください。
Plant list
をタップするAvocado
をタップする操作が終わると、Android端末ではMy Garden画面に「Avocado planted」と表示されてます。この操作が完了したときRecord Your Test
ダイアログの表示は下記のようになっているはずです。
画面に表示されている内容を検証するアサーションの記録をしてみましょう。
Recorderでアサーションの手順を記録するにはRecord Your Test
ダイアログ右下のAdd Assertion
ボタンを押します。
ボタンを押してしばらく待つと、右半分にテスト対象アプリの画面スナップショットが表示された状態になります。
次の手順でアサーションを記録します。
Record Your Test
ダイアログ右側の画面スナップショットで、「Avocado planted」と表示されている箇所をクリックします。Record Your Test
ダイアログ左下のEdit assertion
枠内が、上から順に「com.google.samples.apps.sunflower:id/plant_date」「Text is」「Avocado planted」となっていることを確認します。Record Your Test
ダイアログ左上に検証手順が記録されていることを確認します。テストしたい内容の記録が終わったら、テストコードを生成する手順に移ります。Record Your Test
ダイアログ右下のOKボタンを押してください。
テストクラス名と言語(Java or Kotlin)を尋ねるダイアログが表示されるので、テストクラス名は「GardenActivityTest」、言語はKotlinを選択してください。
GardenActivityTest.ktにKotlinで書かれたテストコードが生成されます。
Espresso Recorderが生成されたコードは残念ながら、最新のAPIに対応していません。(Android Studio3.4時点)
そのため、手動でテストコードを修正する必要があります。
はじめに修正をするのはこの箇所です。
@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
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とあわせてvar
をval
にてread-onlyとし、@JvmField
も@get
に変更しました。
生成されたテストコードを実行してみましょう。GardenActivityTest.ktを開き、テストクラスの宣言部(class GardenActivityTest
と書かれている箇所)の左側にある緑の矢印をクリックします。
テストコードが問題なく実行できることを確認できたら、この生成されたテストコードを保守性の高いテストコードに変更していきます。
前のセクションではTest Recorderを使って、テストコードの自動生成を行いました。
しかし、この自動生成されたテストコードをそのまま運用するのは、ほとんどのケースではお勧めできません。
次にまたTest Recorderを使って「Apple」を追加するケースを追加したとします。このとき、既存のテストコードと重複が多いテストコードが生成されます。
これを繰り返していくと、テスト対象のアプリのUIが変更されたときの修正作業が膨大になってしまいます。
そこで、PageObjectデザインパターンを適用します。
PageObjectデザインパターンは、対象アプリのUIが変更されたときのテストコード修正コストを小さくすることを目的として考案されました。具体的にはテストコードを共通化 する指針を与えています。
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で行います。
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)
}
}
}
//ここまで
}
メソッド全体を範囲選択し、選択範囲がクラスの外に移動するまでMove Line Down (Option + Shift + ↓)
を複数回実行します。
次にメソッド名childAtPosition
にカーソルをあて、Change Signature (Command + F6)
を実行します。
ダイアログがでてきますので、Visibility
を「public」に変更します。これで、childAtPosition
はglobalな関数になりました。
最後に、Test用のヘルパー関数を持つファイルを作り、そこにchildAtPosition
を移動させます。
再度メソッド名childAtPosition
にカーソルをあて、Move (F6)
を実行します。
ポップアップがでてきますので、To package:
とFile name:
にチェックを入れ、ファイル名に「TestHelper.kt」と入力します。
Moveの実行が完了すると、テストコードと同じディレクトリにTestHelper.ktが作成され、その中にchildAtPosition
メソッドが配置されています。
これで事前準備は終わりです。次からのセクションでは、同じ用にIDEのリファクタリング機能を活用しながらPage Objectクラスの作成していきます。
書き換えのステップを確認しましょう。まずは、メソッドの抽出を行います。
これまでのセクションで、各Pageクラスとメソッドを設計しました。
Test Recorderで記録した操作とPageクラスのメソッドを対応付けると、以下のようになります。
操作 | Pageクラスのメソッド |
1. 左上のハンバーガーボタン(≡の形をしたメニューボタン)をタップする | 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()を抽出しましょう。
該当するテストコードはこの範囲です。
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)を抽出します。
ここではメソッドだけでなく、引数の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() { ... }
次は、さきほど抽出したメソッドをPageクラスに移動させます。
Pageクラスとメソッドの対応づけを確認しましょう。
Pageクラス | メソッド |
MyGardenPage | goPlantList() |
PlantListPage | showPlantDetail(plantName: String) |
PlantDetailPage | addToMyGarden() |
MyGardenPageにひもづくメソッドはgoPlantList
とassertPlanted(plantName: String)
です。
まず、メソッド名goPlantList
にカーソルをあて、Move (F6)
を実行します。
ダイアログが表示されますので、次のように設定します。
To package:
とFile name:
にチェックを入れ、ファイル名に「MyGardenPage.kt」と入力するMoveの実行が完了すると、テストコードと同じディレクトリにMyGardenPage.ktが作成され、その中にgoPlantList
とassertPlanted(plantName: String)
が移動されています。
MyGardenPage.kt
fun goPlantList() { .. }
fun assertPlanted(plantName: String) { .. }
次に、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() { .. }
}
Page Objectデザインパターンでは、アクションを実行するメソッドは戻り値として遷移先の画面に対応するPage オブ
ジェクト(Page クラスのインスタンス)を返すようにします。
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点です。
各メソッドは戻り値として遷移先のPageクラスを返すようにしているので、遷移先のアクションをつなげて書くことができるのです。
また、呼び出しメソッドの補完も効くようになります。
PageObjectデザインパターンの基本的な考えをおさらいします。
PageObjectデザインパターンを適用することで、可読性が高く、操作の流れもわかりやすいテストコードを作成することができました。
EspressoのTest Recorderの使い方と、Page Objectデザインパターンの実装方法を学びました。
今後のあなたの仕事に役立てていただけるのであれば幸いです。
このCodelabはDeNAのSWETグループが作成しました。
もしご不明点や間違い等あれば、トップページの「このページについて」に記載されている手順でIssueを起票していただければと思います。
このCodelabの一部は、関係者の許諾の元、書籍「Androidテスト全書」の4章「UI テスト(概要編)」と5章「UI テスト(Espresso編)」の内容を一部流用しています。
「Androidテスト全書」にはここで紹介する内容のほか、役立つ情報が満載です。気になる方は読んでみてください。
最後に、お手数ですが、次ページで本Codelabに対するフィードバックをいただけますと嬉しいです。
今後の私たちの活動に活かしていきたいと思います。