これは [Android その2 Advent Calendar]
(https://qiita.com/advent-calendar/2017/android_second) の6日目の記事です。
はじめに
ActivityやFragmentにビジネスロジックがありユニットテストしずらいコードに対して、Android非依存なクラスに抽象化することでテスタブルにする方法の紹介です。
今回はMVPアーキテクチャにすることでActivityに書いていたロジックをPresenterに抽象化してそこにテストを書いていきます。
注意点
実際に業務でアーキテクチャを選定する際は、プロダクトの規模、チームメンバーの技術力、事業の優先度などを考慮して上で決めたほうがいいので、必ずしもこのやり方がベストというわけではありません。
例えばプロダクトの立ち上げ時期はスピード優先の場合が多いので、この内容は適さない場合もあります。
やりたいこと(イメージ)
前提知識
MVPアーキテクチャとは
Model-View-Presenterで分けたアーキテクチャのことです。
Androidでは一般的にPresentation層にViewとなるActivity/FragmentからPresenterを呼び出して、PresenterからRepositoryを呼び出すことが多いです。
処理イメージとしては以下です。
(引用)https://github.com/googlesamples/android-architecture/tree/todo-mvp/
Android非依存なクラスとは
Android Frameworkに依存していないクラスのことです。
具体的にはContextなどに依存してないピュアJava/Kotlinのクラスを指します。
サンプルアプリ
GitHubのリポジトリ一覧を表示するアプリです。
初期状態ではボタンが表示されていて、ボタンを押すと googlesamples のリポジトリ一覧を表示するという非常にシンプルなものです。
また、簡略化のため追加読み込み、ローディングインジケータなどは実装してません。
初期状態 | ボタン押下後 |
---|---|
このアプリのリポジトリはこちらです。
https://github.com/rkowase/android-mvp-sample
今回テストしたいActivity
今後の機能拡張につれてFatになりそうなActivityとして作ってみました。
RetrofitでServiceクラスを作って、ボタンが押されたらGitHubのAPIを叩いて成功したらリスト表示とボタン非表示、失敗したらエラーメッセージ表示をしています。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val service = createService()
button.setOnClickListener({
service.listRepos(getString(R.string.user))
.subscribeOn(Schedulers.newThread())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
if (it.isEmpty()) {
showError()
return@subscribe
}
showList(it)
hideButton()
}, {
showError()
})
})
}
private fun createService(): GitHubService {
val retrofit = Retrofit.Builder()
.baseUrl(getString(R.string.base_url))
.addConverterFactory(MoshiConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build()
return retrofit.create(GitHubService::class.java)
}
private fun hideButton() {
button.visibility = View.GONE
}
private fun showError() {
Toast.makeText(this, getString(R.string.error_message), Toast.LENGTH_LONG).show()
}
private fun showList(it: List<RepoEntity>) {
var list = listOf<String>()
it.forEach { list += it.name }
val adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, list)
listView.adapter = adapter
}
}
interface GitHubService {
@GET("users/{user}/repos")
fun listRepos(@Path("user") user: String): Observable<List<RepoEntity>>
}
テスタブルにしていく工程
Repositoryに分離する
まずはactivityのcreateService()
をRepositoryに切り分けるためにinterfaceを作ります。
interface GitHubRepository {
fun initService()
fun request(user: String): Observable<List<RepoEntity>>
}
次に作ったinterfaceを実装していきます。
具体的には元々View側(MainActivity)に実装していた処理を移していきます。
class GitHubRepositoryImpl(private val mContext: Context): GitHubRepository {
private lateinit var mService: GitHubService
override fun initService() {
val retrofit = Retrofit.Builder()
.baseUrl(mContext.getString(R.string.base_url))
.addConverterFactory(MoshiConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build()
mService = retrofit.create(GitHubService::class.java)
}
override fun request(user: String): Observable<List<RepoEntity>> = mService.listRepos(user)
}
Presenterに分離する
まずはViewとPresenterのinterfaceを作ります。
今後画面が増えていくことを想定してBaseViewとBasePresenterを作っておきます。
MVPアーキテクチャではViewはPresenterを持つのでこのように定義しておきます。
interface BaseView<T> {
var presenter: T
}
interface BasePresenter {
fun start()
}
次に今回作る画面用のinterfaceを作ります。
ViewとPresenterは画面に紐づく(画面と1:1の関係)ので、一つのクラス(GitHubContract
)としてまとめてその中にBaseクラスを継承したViewとPresenterのinterfaceを定義します。
class GitHubContract {
interface View: BaseView<Presenter> {
fun showList(list: List<RepoEntity>)
fun showError()
fun hideButton()
}
interface Presenter: BasePresenter {
fun request(user: String)
}
}
最後にPresenterを実装していきます。
PresenterではViewでやるべき操作を抽象化して書いていきます。
Viewでの操作の詳細(例えばどのようなエラー表示をするかなど)はViewに任せます(移譲します)。
class GitHubPresenter(
private val mRepository: GitHubRepository,
private val mView: GitHubContract.View) : GitHubContract.Presenter {
private lateinit var mService: GitHubService
init {
mView.presenter = this
}
override fun start() {
mService = mRepository.createService()
}
override fun request() {
mService.listRepos(mView.getUser())
.subscribeOn(Schedulers.newThread())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
if (it.isEmpty()) {
mView.showError()
return@subscribe
}
mView.hideButton()
mView.showList(it)
}, {
mView.showError()
})
}
}
分離後のActivity
今までonCreate()
に全てのロジックを書いていたところが分離後はpresenterのインスタンス化、presenter.start()
、ボタンのクリックリスナーでpresenter.request()
を呼び出すだけになりました。
また元々privateで定義していたメソッドはViewのinterfaceを継承しているのでoverrideをつけただけです。
class MainActivity : AppCompatActivity(), GitHubContract.View {
override lateinit var presenter: GitHubContract.Presenter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
presenter = GitHubPresenter(GitHubRepositoryImpl(this), this, SchedulerProvider)
presenter.start()
button.setOnClickListener({
presenter.request(getString(R.string.user))
})
}
override fun hideButton() {
button.visibility = View.GONE
}
override fun showError() {
Toast.makeText(this, getString(R.string.error_message), Toast.LENGTH_LONG).show()
}
override fun showList(it: List<RepoEntity>) {
var list = listOf<String>()
it.forEach { list += it.name }
val adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, list)
listView.adapter = adapter
listView.visibility = View.VISIBLE
}
}
Presenterをテストできるようにする
このままだとRxのところがテストできないのでSchedulerを差し替えれるようにします。
Schedulerのinterface
interface BaseSchedulerProvider {
fun computation(): Scheduler
fun io(): Scheduler
fun ui(): Scheduler
}
実装側で使うScheduler
object SchedulerProvider : BaseSchedulerProvider {
override fun computation(): Scheduler = Schedulers.computation()
override fun io(): Scheduler = Schedulers.io()
override fun ui(): Scheduler = AndroidSchedulers.mainThread()
}
テストで使うScheduler
class ImmediateSchedulerProvider : BaseSchedulerProvider {
override fun computation(): Scheduler = Schedulers.trampoline()
override fun io(): Scheduler = Schedulers.trampoline()
override fun ui(): Scheduler = Schedulers.trampoline()
}
PresenterのコンストラクタにBaseSchedulerProvider
を指定して、RxのsubscribeOn
とobserveOn
に実装側のSchedulerを指定します。
class GitHubPresenter(
private val mRepository: GitHubRepository,
private val mView: GitHubContract.View,
private val mSchedulerProvider: BaseSchedulerProvider) : GitHubContract.Presenter {
// 省略
override fun request() {
mService.listRepos(mView.getUser())
.subscribeOn(mSchedulerProvider.io())
.observeOn(mSchedulerProvider.ui())
.subscribe({
if (it.isEmpty()) {
mView.showError()
return@subscribe
}
mView.hideButton()
mView.showList(it)
}, {
mView.showError()
})
}
}
Presenterのコンストラクタ引数を追加したので、MainActivityのPresenterのインスタンスを作るところで実装側のSchedulerを指定しておきます。
presenter = GitHubPresenter(GitHubRepositoryImpl(this), this, SchedulerProvider)
詳しくは[【Android】RxJavaの非同期処理部分をテストする]
(https://qiita.com/rkowase/items/c69180e541d2a4e37408)を参照してください。
テストを書く
上記の工程でPresenterがAndroid非依存でテスタブルなクラスになったのでテストを書いていきます。
Mockitoを使ってRepositoryとViewをモックします。
また事前に用意したテスト用Scheduler(ImmediateSchedulerProvider
)を使ってPresenterをインスタンス化します。
class GitHubPresenterTest {
@Mock private lateinit var mRepository: GitHubRepository
@Mock private lateinit var mView: GitHubContract.View
private lateinit var mPresenter: GitHubPresenter
private lateinit var mSchedulerProvider: BaseSchedulerProvider
companion object {
const val USER = "test_user"
}
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
mSchedulerProvider = ImmediateSchedulerProvider()
mPresenter = GitHubPresenter(mRepository, mView, mSchedulerProvider)
}
@Test
fun start() {
mPresenter.start()
verify(mRepository).initService()
}
@Test
fun requestError() {
`when`(request()).thenReturn(Observable.error(Exception()))
mPresenter.request(USER)
verify(mView).showError()
}
@Test
fun requestEmpty() {
`when`(request()).thenReturn(Observable.just(listOf()))
mPresenter.request(USER)
verify(mView).showError()
}
@Test
fun requestSuccess() {
val list = listOf(RepoEntity("name"))
`when`(request()).thenReturn(Observable.just(list))
mPresenter.request(USER)
verify(mView).showList(list)
verify(mView).hideButton()
}
private fun request() = mRepository.request(USER)
}
アーキテクチャ
全体のアーキテクチャは以下のようになりました。
パッケージ構成
パッケージ構成はとりあえず以下のようになりました。
サンプルなので簡易的な構成にしましたが、画面が増える毎にActivityやPresenterなどが増えていくので更に階層分けしたほうがいいでしょう。
$ tree app/src/main/java/rkowase/mvpsample/
app/src/main/java/rkowase/mvpsample/
├── data
│ ├── entity
│ │ └── RepoEntity.kt
│ ├── repository
│ │ ├── GitHubRepository.kt
│ │ └── GitHubRepositoryImpl.kt
│ └── service
│ └── GitHubService.kt
├── presentation
│ ├── BasePresenter.kt
│ ├── GitHubContract.kt
│ └── GitHubPresenter.kt
├── ui
│ ├── BaseView.kt
│ └── MainActivity.kt
└── util
└── scheduler
├── BaseSchedulerProvider.kt
├── ImmediateSchedulerProvider.kt
└── SchedulerProvider.kt
8 directories, 12 files
次のステップ
テストできるようになったら次のステップとしては以下があります。
- Domain層追加(Clean ArchitectureでいうUse Caseを置く層)
- Viewに関わらないビジネスロジックはUse Caseにしたほうがよさそうです。
- 今回の例だとAPIのレスポンスからListView表示用Adapterに渡すListに変換する処理をMapperとして切り分けてテストを書くこともできます。
- Repository層をRemoteとLocalに分割
- APIのレスポンスをキャッシュしたりSQLiteから取得するためにRemoteとLocalを分けることもできます。
- DIライブラリ(DaggerやKodeinなど)導入
- DIライブラリを使うとコンストラクタインジェクションなどがシュッとできます。
- AAC(Android Architecture Component)導入
- AACのLifecycleOwnerやLiveDataを使うと画面回転によるライフサイクルの取扱や変更通知の仕組みを楽に実装できます。
- 各層をモジュール化
- モジュール分割してGradleのdependenciesに依存関係を明記することで各モジュールの責務を明確化し制約をつけることができます。
- JUnit以外のテストフレームワーク(Spekやkotlintest)を試してみる
- テストが増えてくるとJUnitの記法だと少し冗長になる場合もあるので、他のテストフレームワークを検討してみてもいいかもしれません。
まとめ
- 各レイヤーをInterfaceで区切る
- ロジック部分をピュアJava/Kotlinにする
- Mockitoを使ってオブジェクトをモックする
- テスト時はSchedulerを
Schedulers.trampoline()
に差し替える
補足
- 今回は第一歩かつあくまでテストがないところにテストをいれることを目的としたためDaggerやAACやマルチモジュールのことまでは踏み込んでいません。
- もしDaggerやAAC未導入のプロダクトでメンバーが未経験の場合は、まずこの記事で紹介したように最小限のテストを書いてからDaggerやAACを入れたりTDDを試してみたりするのがいいかと思います。
- また、これらをやる前にそもそもアーキテクチャを変える必要性やテストをする必要性を正しく理解・共有した上で進めていくのがいいかと思います。
発表スライド
2017/12/08のshibuya.apkで発表したスライドはこちらです。(ご参考まで)
既存コードをAndroid非依存なクラスに抽象化してユニットテストするための第一歩 // Speaker Deck
https://speakerdeck.com/rkowase/ji-cun-kotowoandroidfei-yi-cun-nakurasunichou-xiang-hua-siteyunitutotesutosurutamefalsedi-bu
参考
googlesamples/android-architecture: A collection of samples to discuss and showcase different architectural tools and patterns for Android apps.
https://github.com/googlesamples/android-architecture
bufferapp/android-clean-architecture-boilerplate: An android boilerplate project using clean architecture
https://github.com/bufferapp/android-clean-architecture-boilerplate