Androidのテスト始め

Android開発の速度と品質を保つためにどうしたら良いか考えた結果、テストを書くと速度も品質も上がりそうなので今回はテスト入門のためにテストを書いてみたいと思います。
はじめに
この記事ではIntelliJIDEA(version 2018.2.7)でKotlinを有効にして作成したプロジェクトに含まれていたテストフレームワークのJUnitとEspressoでテストを書いてみるという内容です。
テストを書くって少し面倒に思えたりもしますが、Android開発においてはWeb開発よりも更に効果的な面もありそうなので、まずは自分に言い聞かせる意味も込めてメリットを上げてみます。
コード変更後の確認までの時間短縮
現状テストを書かずに開発をする場合、コードを変更したあと、動作確認のためにBuild→Launch→Reviewのステップを踏む必要がありますが、テストを書くことで確認ステップがBuild→Testingとなり、短縮することができそう。
品質があがる、無意識に変更できる。
JavaもKotlinも型によってある程度の整合性の担保はされていますが、脳内にあるパターンをコードに書き留める、テストコードを走らせることで、変更後の仕様漏れを防げます。
書いていく中でメリット/デメリットは見えてくると思いますが今回は書き始めということで・・・
次はテストの種類についてです。
Androidのテストの種類
Local unit test
{ModuleName}/src/test/java/
に配置されている。
実行したマシンのローカルJVM上で実行され、Androidフレームワークと依存関係がないもの、または依存をモックで代用できる場合はこちらですばやくテストを行える。
Instrumented test
{module-name}/src/androidTest/java/
に配置されている。
ハードウェア端末またはエミュレータ上で実行されます。Instrumentation
APIを通してテストしているアプリのContext
情報へのアクセスを行いテストコードからアプリを制御できるようにする。主にUIのテストやモックで代用出来ないAndroidフレームワークへの依存関係がある場合に使用。リリース用APKファイルとは別にAPKをビルドするためAndroidManifest.xml
が必要になるが、Gradleがビルド時に自動生成するため必須ではない、minSdkVersion
など書き換えが必要な場合は適宜作成する。
Local unit test
を楽に書けるような設計をすることで、テストのダウンタイムを減らすことができそうです。それでは実際に書いてみます。
Local unit testの書き方
今回はIntelliJIDEAで作成したプロジェクトに含まれていたテストフレームワークのJUnit で書いていきます。
書き始める前にJUnitのベーシックなAPIを紹介します。
便利系アノテーション
@Test
: 付与することでJUnitテストメソッドとして登録される。@Setup/@Before
:@Test
が付与されたメソッドの実行前/後で行う処理として登録することが出来る。
アサーション
assert
: 引数がtrue
か判定assertEquals
: (第1引数) == (第2引数)を行う、また第一引数に文字列を指定することで、失敗時にメッセージを出力できる。assertNotEquals
: (第1引数) != (第2引数)を行う、その他はassertEquals
と同様
今回使用した物のみですが、これでも十分に書くことができそうです。さらに詳しいリファレンスはJUnitを参照してください。
それではこれらを使ってテストを書いてみます。
ここから書いてみたコード
// ユニットテスト対象のクラス
class DataSource {
private val cache: HashMap<String, ArrayList> = HashMap()
fun getMembers(): ArrayList {
return cache.get("Members") ?: ArrayList()
}
fun getMember(index: Int): String? {
val members = getMembers()
if (index >= members.size) {
return null
}
return members.get(index)
}
fun getLatestMember(): String? {
return cache.get("Members")?.last()
}
fun addMember(name: String) {
var members = cache.get("Members")
if (members == null) {
members = ArrayList()
}
members.add(name)
cache.set("Members", members)
}
}
このクラスに対するテストは以下のように書けました。
※AndroidStudioやIntelliJIDEAでは、Cmd+Shift+T
でテストクラスを作成することができます。
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
// テストクラス
class DataSourceTest {
private lateinit var dataSource: DataSource
@Before
fun setup() {
println("---run before---")
dataSource = DataSource()
}
@After
fun tearDown() {
println("---run after---")
}
@Test
fun getMember_empty() {
val members = dataSource.getMembers()
assert(members.size == 0)
assertEquals(dataSource.getMember(0), null)
}
@Test
fun addMember() {
val newMember = "TestingMember"
val initialMembers = dataSource.getMembers()
dataSource.addMember(newMember)
assertEquals(
"追加されたメンバーをgetLatestMemberメソッドで取得",
dataSource.getLatestMember(),
newMember
)
val newMemberAddedMembers = dataSource.getMembers()
assertNotEquals(
"初期メンバーリストと今のメンバーリストが異なっている",
initialMembers,
newMemberAddedMembers
)
assertEquals(
"初期メンバー数と今のメンバー数の差が1",
newMemberAddedMembers.size - initialMembers.size,
1
)
val lastIndexMember = dataSource.getMember(newMemberAddedMembers.size - 1)
assertEquals(
"追加されたメンバーをgetMemberメソッドで取得",
lastIndexMember,
newMember
)
}
}
作成後のディレクトリ構成です。AndroidStudioやIntelliJIDEAでテストファイルを作成した場合、main
ディレクトリからのテスト対象ファイルのパスとtest
ディレクトリからのテストファイルへのパスが一致するように設置されます。
Local unit testの実行
各IDEのエディタ左側に表示されるボタンをクリックすることでテストを実行出来ます、以下はIntelliJIDEAをスクリーンショットしたものです。
上の方に示したのテストコードを実行した結果です。
参考にアサーション失敗例です、発生箇所やメッセージ、比較したオブジェクトの詳細などが表示されます。
次にAndroidAPIへの依存がある場合に行うInstrumented test
を書いてみます。
Instrumented testの書き方
こちらは主にJUnitとUI操作のためのEspressoを使用します。
またこちらではJUnitのアサーションではなく、Espressoのアサーションを使用してテストを行います。
手順としては以下の段階を踏みます。
- ビューから要素を取得
- 要素に対する操作を行う
- 要素の検証を行う
これをEspressoのAPIを使って書くと
- onViewメソッドでViewInteractionを取得
- ViewInteractionのperformメソッドで操作を行う
- ViewInteractionのcheckメソッドで検証を行う
と言った感じです。
また、今回はActivity単独のテストとして書くためにcom.android.support.test:rules
を追加しました。
// build.gradle(app)からの抜き出し
dependencies {
...
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
+ androidTestImplementation 'com.android.support.test:rules:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}
それでは書いた結果です。
ここから書いてみたコード
テスト対象のActivityクラスです。
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
hello.setOnClickListener {
hello.text = "Tapped!"
}
}
}
レイアウトファイルです。
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/hello"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>
テストクラスです。
import android.support.test.espresso.Espresso.onView
import android.support.test.espresso.action.ViewActions.click
import android.support.test.espresso.assertion.ViewAssertions.matches
import android.support.test.espresso.matcher.ViewMatchers.withId
import android.support.test.espresso.matcher.ViewMatchers.withText
import android.support.test.rule.ActivityTestRule
import android.support.test.runner.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
val activity = ActivityTestRule(MainActivity::class.java)
@Test
fun TapText() {
onView(withId(R.id.hello))
.check(matches(withText("Hello World!")))
.perform(click())
.check(matches(withText("Tapped!")))
}
}
Instrumented testの実行
Local unit test
と同様にエディタ左に表示された再生ボタンをクリックするとテストを実行することができます。
上記コードの実行結果です。Local unit test
とは違い、adb
コマンドが実行されていくログが表示されます。
テストクラスに複数メソッドがある場合はログの方に出力されるようです。
こちらも試しに失敗パターンを作りました。アサーション失敗するとスタックトレースが表示されました。
終わりに
なるべくLocal unit test
を行えるようにアプリを構築していくことが肝になりそうだなと思いました。
あと、この記事を書いている最中に、いろいろ調べた結果、モックの作り方やテスト自動化、IDEによるテストの自動生成などちらほらみかけたのでそっちの方も少し掘りたいなと思いました。