みなさん、モバイルアプリを作っていますか?私は楽しく作ってます!
良いアプリとは何かを。
美しいUI、素晴らしい機能、動作が速い・・・
今回はあえて地味な**「どれだけ単体テストが書けているか。」**に着目して、
Android Architecture Componentを紹介しつつ、テストを書きやすいアーキテクチャを目指します。
Android Architecture Component
タイトルにもなっていますが、通称「AAC」です。
こちらにはGoogleが提案するAndroidのアーキテクチャと、それをサポートするフレームワークが公開されてます。
実践的で今までやりたかったことが簡単にできるようになる素晴らしいフレームワークです。
※もし、あなたがiOSアプリ開発者でも、ざっと眺めておくことをお勧めします。
このアーキテクチャはiOS開発にも役に立つはずです。(実体験)
※Android Studio3.2以上であればAndroid Jetpackが利用できます。
これを利用すると、AACに基づいたプロジェクトを生成できます。
この記事で説明しないこと
- Androidアプリの作り方
- こちらの記事が参考になります
- 各ライブラリの使用方法
- この記事には様々なライブラリが名前だけ出てきます。出てくる度にリンクを張るようにします。
- kotlinの言語仕様
- Javaで書く方法
- 今すぐkotlinで書くことをお勧めします
モバイルアプリのテストは特別難しい?
モバイルアプリのテストの記事を書こうと思ったのは、テストが難しいなーと感じたからです。
何も考えずに作っていたころのアプリは、「実際に動かしてみるテスト」以外のテストは実行できませんでした。
アプリのコードがAndroidのライフサイクルに依存しており、JUnit等での単体テストが動かせない状態だったのです!
良くないコード例:
class SampleActivity : AppCompatActivity() {
private var userId: String? = null
private var password: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_sample)
sample_button.setOnClickListener {
// ボタンが押されたらviewからユーザ情報を取得
this.userId = user_id_textview.text.toString()
this.password = password_textview.text.toString()
this.saveUserId(this.userId)
this.sendAuthRequest(this.userId, this.password)
// もし、認証に成功したら画面遷移
}
}
private fun sendAuthRequest(userId: String?, password: String?) {
// サーバに認証を問い合わせる処理
}
private fun saveUserId(userId: String?) {
// ユーザ情報を保存する処理
}
}
何故悪いのか
- ActivityはAndroidのライフサイクルに依存している。
- 画面操作、ビジネスロジック、I/O処理が一つのクラスに詰まっている。
どうなってしまうのか
- Android上でアプリを起動しないとActivityを動かせない。
- 単体テストができずアプリを動かしてみるしか挙動の確認ができない。
- 再現が難しいテストができない!(ネットワークのレスポンスが遅い場合など)
ではどうすれば良いのか
3つのステップで単体テストが書けるようにします!
- 「画面の状態、ビジネスロジック、I/O処理」をActivityから分離
- それぞれのクラスの依存を無くす
- 単体テスト用のmockを導入する
便利なライブラリの紹介をしつつ、3ステップを順番に説明していきます。
テストを書きやすいアーキテクチャを作る3つのSTEP
ステップ1: 「画面の状態、ビジネスロジック、I/O処理」をActivityから分離
画像は https://developer.android.com/jetpack/docs/guide より引用
通称「MVVM」アーキテクチャです。
この図が示していることは、
- 画面の状態はViewModelが持つべし
- ビジネスロジックはRepositoryに実装すべし
- **外とのやり取り(I/O処理)**はViewModel, Repositoryと分離すべし
(外とのやり取りを行う物を「dataStore」と私は読んでいます)
それぞれの責務を軽く紹介します。
ViewModel(Viewの状態を持つクラス)
/**
* ログイン画面をイメージしてください。
* email入力欄 + password入力欄 + サインインボタンの画面
**/
class SampleViewModel: ViewModel() {
// ビジネスロジッククラス。実装はあとで
private val sampleRepository: SampleRepository
val email: MutableLiveData<String>
val password: MutableLiveData<String>
init {
this.email = MutableLiveData()
this.password = MutableLiveData()
this.sampleRepository = SampleRepository()
}
// emailが変更されたときに呼ばれるメソッド
fun onEmailChanged(newVal: String) {
this.email.postValue(newVal)
}
// passwordが変更されたときに呼ばれるメソッド
fun onPasswordChanged(newVal: String) {
this.password.postValue(newVal)
}
fun onsigninButtonClicked() {
// サインインボタンがクリックされたときの処理
}
}
このクラスは画面上
- email入力欄の値
- password入力欄の値
を持つModelクラスです。
emailの変数がemailアドレス欄の状態を持ち、password変数がpassword欄の状態を持っています。
ViewModel
そのものについては公式ページに詳しく書いてあります。
また、ViewModel
とLiveData
の素晴らしさは
Android Architecture ComponentsのViewModelとLiveDataを使えば画面回転も怖くないが参考になります。
そして、このViewModel
を画面とバインドするライブラリがAACに用意されています。
(ViewModelを画面とバインドする方法はこちらを参照。)
バインドすることで、画面の状態が常にViewModel
に適用されます。
例:email欄にhoge@fuga.com
と入力すると、email変数の値がhoge@fuga.com
となります。
(画面の状態が変わると変数の値が変わる。つまりリアクティブ!実はReactiveXとの相性が抜群です!がその話は別の機会で)
ViewModel
は画面の状態を管理する責務を負います。
例えば、下記のようなテストを書きたくなります。
- 不正なemail, passwordの場合エラー文を出す
- email, passwordが入力されないと、signinボタンが押せない
ここで大切なことはLiveDataクラスとViewModelクラス以外はAndroidに依存していないことです!
(LiveDataはAACのテスト用ライブラリのサポートがあればJUnitで動くのでokです)
Repository(ビジネスロジック)
/**
* サインイン処理などのビジネスロジックを記述します
**/
class SampleRepository() {
// I/Oを司るクラスです。実装はあとで
private val sampleDBDataStore: SampleDBDataStore
private val sampleAuthDataStore: SampleAuthDataStore
init {
this.sampleDBDataStore = SampleDBDataStore()
this.sampleAuthDataStore = SampleAuthDataStore()
}
fun signIn(email: String, password: String): signle<Unit> {
// 1. ローカルDBにemailを保存するdataStoreを呼ぶ
// 2. postリクエストでサーバ認証を行うdataStoreを呼ぶ
// 3. 結果をreturnする
// signleはRxKotlinのクラスです。Rxって便利ー
}
}
signInのビジネスロジックをRepositoryに書きます。今回の例では、emailをローカルに保存しつつ、認証を行うというロジックです。
例えば、下記のようなテストを書きたくなります。
- 正常系
- ローカルDBの書き込みに失敗した時のハンドリング
- 認証失敗時のハンドリング
ただし、ここでDBを操作するコード、httpリクエストを送るコードを書いてはいけません!
それらはdataStoreの責務です。
戻り値のsingle
クラスはRxKotlin(もしくはRxJava)のクラスです。今回は無視して構いません。
(ReactiveXは便利なので、使ってみることをお勧めします!)
dataStore(I/O処理)
class SampleDBDataStore {
fun writeEmail(email: String): Single<Unit> {
// emailをローカルDBに保存する
}
}
class SampleAuthDataStore {
fun authenticate(email: String, password: String): Single<Boolean> {
// emailとpasswordを使ってサーバにhttpリクエストを発行する
}
}
dataStoreでは実際にjsonを組み立ててpostリクエストを送ったり、ローカルDBにデータを保存したりします。
いわゆるDAOというやつです。
dataStore
には複雑な処理は書かず、外部データソースにアクセスするコードだけ書きます。
なので、dataStore
の単体テストは書かなくて良いと考えています。
テストを書かなければならないような処理はRepository
やViewModel
に書かれているはずです。
ステップ2: それぞれのクラスの依存を無くす
依存とDIについて
依存とDIについては様々な記事で紹介されています。
猿でも分かる! Dependency Injection: 依存性の注入
要するに DI って何なのという話
spring-frameworkに馴染み深い人に説明すると、
@Component
と@Autowired
です!
AndroidでDIをやるには
Dagger 2
非常に便利なフレームワークですが、今回は名前の紹介だけとさせてください。
アプリケーションのビルド時にインジェクションしてくれるので、アプリの挙動に影響を与えません。
Kodein-DI
実は触ったのことないのですが、kotlin-nativeから動くようなので触ってみたい
コンストラクタインジェクション、セッターインジェクション
今回はこちらを採用します!
Kotlinでは非常に簡単に、かつ実用的にインジェクションできます。
DIに慣れてきて、アプリの規模が大きくなってきたらDaggerの採用を検討しましょう。
実装クラスとインタフェースを作る
DIでは、振る舞いと実装を分ける必要があります。
そのために、インタフェースを定義します。
interface SampleRepository {
fun signIn(email: String, password: String): Signle<Unit>
}
interface SampleDBDataStore {
fun writeEmail(email: String): Single<Unit>
}
interface SampleAuthDataStore {
fun authenticate(email: String, password: String): Single<Boolean>
}
このインタフェースの実装クラスを注入していきます。
ViewModelにDIを採用したコード
ViewModelではセッターインジェクションを使っていきます。
理由として、Activity
からViewModel
のインスタンスを得るときは、
専用のFactoryメソッドを使うことになっており、コンストラクタへのインジェクションが
難しいためです。
(Daggerを用いればコンストラクタインジェクション可能です。Dagger最高!)
/**
* ログイン画面をイメージしてください。
* email入力欄 + password入力欄 + サインインボタンの画面
**/
class SampleViewModel: ViewModel() {
// ここでは初期化しない
private lateinit var sampleRepository: SampleRepository
val email: MutableLiveData<String>
val password: MutableLiveData<String>
init {
this.email = MutableLiveData()
this.password = MutableLiveData()
// セッターを呼び出して初期化
this.setSampleRepository()
}
// emailが変更されたときに呼ばれるメソッド
fun onEmailChanged(newVal: String) {
this.email.postValue(newVal)
}
// passwordが変更されたときに呼ばれるメソッド
fun onPasswordChanged(newVal: String) {
this.password.postValue(newVal)
}
fun onsigninButtonClicked() {
// サインインボタンがクリックされたときの処理
}
// 実装を外から差し込む
// デフォルト引数を使うことで、通常時利用する実装クラスを差し込む
fun setSampleRepository(repository: SampleRepository = SampleRepositoryImpl()) {
this.sampleRepository = repository
}
}
こちらのコードではセッターを用いて外からインジェクションできるようにしていると同時に、
init
でセッターを呼ぶことでインジェクションをしています。
kotlinではデフォルト引数が使えるので、アプリを動かすときに使用する実装クラスをデフォルト引数に指定しておき、
テスト時にはモックに差し替えるといったことが簡単にできます。
注意点として、viewModel
の生成後にセッターを呼ぶと実装が差し替わってしまいます。
気になる場合はDIフレームワークを使用することをお勧めします。
RepositoryにDIを採用したコード
/**
* サインイン処理などのビジネスロジックを記述します
**/
class SampleRepositoryImpl(
// コンストラクタインジェクション
private val sampleDBDataStore: SampleDBDataStore = SampleDBDataStore(),
private val sampleAuthDataStore: SampleAuthDataStore = SampleAuthDataStore()): SampleRepository {
override fun signIn(email: String, password: String): signle<Unit> {
// 1. ローカルDBにemailを保存するdataStoreを呼ぶ
// 2. postリクエストでサーバ認証を行うdataStoreを呼ぶ
// 3. 結果をreturnする
// signleはRxKotlinのクラスです。Rxって便利ー
}
}
Repositoryはピュアなkotlinですので、コンストラクタインジェクションを行います。
ステップ3: 単体テストを書く
Activity
からコードを分離し、依存も排除し、ついに単体テストを書く準備が整いました!
思う存分テストを書きましょう!
と言いつつ、単体テストで便利なライブラリと共に、書き方も説明していきます。
AndroidでのJUnit
Androidはサーバサイドと同じようにJUnitでテストを書くことができます。
が、ViewModel
で利用しているLiveData
だけは特定のモジュールを組み込む必要があります。
下記のリンクに詳しく書いてあります。
LiveDataのユニットテストを書く際に参考になる記事
kotlinでのユニットテストではmockito-kotlinを使うと簡単にモックを作れます。
Javaでは有名なMockitoをkotlinから便利に使うためのツールです。
kotlinのクールな文法と相まって最高です。
実際にmockを差し込むコード例を出します。
class SampleRepositoryImplTest {
@Test
@DisplayName("signInを実行する_正常系")
fun signIn() {
// DBへの書き込みが正常終了したことをmockで表現
private val sampleDBDataStore: SampleDBDataStore = mock {
on { writeEmail(ArgumentMatchers.anyString()) } doReturn Single.just(Unit)
}
// 認証に成功したことをmockで表現
private val sampleAuthDataStore: SampleAuthDataStore = mock {
on { authenticate(ArgumentMatchers.anyString(), ArgumentMatchers.anyString()) } doReturn Single.just(true)
}
// mockをインジェクションしてRepositoryインスタンスを得る
val target: SampleRepository = SampleRepositoryImpl(
this.sampleDBDataStore,
this.sampleAuthDataStore,
)
// RxKotlinのテスト方法例
TestObserver<Unit>().apply {
target.signIn("hoge@fuga.com", "password").toObservable().subscribe(this)
this.awaitTerminalEvent()
this.assertComplete()
this.assertEmpty()
}
}
}
ViewModelに関しても同じようにmock
を生成し、setterを使ってインジェクションしてあげることで、
上記の例と同じように「通信に失敗した時に正しいエラークラスを返す」等のテストを書くことができます。