20
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

NSSOLAdvent Calendar 2018

Day 15

Android Architecture Componentをもとにテストしやすいコードを目指す

Last updated at Posted at 2018-12-14

みなさん、モバイルアプリを作っていますか?私は楽しく作ってます!

良いアプリとは何かを。
美しい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つのステップで単体テストが書けるようにします!

  1. 「画面の状態、ビジネスロジック、I/O処理」をActivityから分離
  2. それぞれのクラスの依存を無くす
  3. 単体テスト用のmockを導入する

便利なライブラリの紹介をしつつ、3ステップを順番に説明していきます。

テストを書きやすいアーキテクチャを作る3つのSTEP

ステップ1: 「画面の状態、ビジネスロジック、I/O処理」をActivityから分離

画像は https://developer.android.com/jetpack/docs/guide より引用
AAC.PNG

通称「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そのものについては公式ページに詳しく書いてあります。
また、ViewModelLiveDataの素晴らしさは
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の単体テストは書かなくて良いと考えています。
テストを書かなければならないような処理はRepositoryViewModelに書かれているはずです。

ステップ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を使ってインジェクションしてあげることで、
上記の例と同じように「通信に失敗した時に正しいエラークラスを返す」等のテストを書くことができます。

20
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?