みんからきりまで

きりみんです。

PresenterとMockitoで今度こそAndroidのUIロジックにテストが書きたい!!

いろいろあって最近また設計について考えています。
AndroidでMVPを使用した設計は色々な人が紹介していますが、記事によって定義がそれぞれ異なっていたり、具体的にどうやってテストコードを書けばいいのかイメージ出来なかったりしてモヤモヤしていました。
以前自分で試したものもUIロジックへの厳密なテストを書くのを目的にしていましたが、実装が面倒過ぎて全然現実的ではありませんでした。

しかし、仕事で実際にMVPベースの汎用的な設計を考える必要があったため、今更ながらMVPをベースにどう実装すれば負担が大きくならずちゃんとテストが書ける設計になるのかを改めて色々試行錯誤しています。
その結果、それなりに実用的な設計になってきたんじゃないかなと思うので一旦進捗を共有します。

クラス構成

基本的には android10/Android-CleanArchitecture · GitHub を参考にしています。

クラス構成のイメージは以下の図のような形になります。
オレンジの部分が画面と一体一で作られるクラスで、緑の部分がその他のクラスです。

f:id:kirimin:20151116214304p:plain

特徴としてはViewをInterfaceとして定義する事と、Viewと一対一になるクラスを各レイヤーごとに定義しハブとする事でモック化しやすくしているところです。
Presenterが直接Activityなどを保持するとPresenterとViewの責務の切り分けが曖昧になり、Viewのモックも作りにくくなってしまうためViewは抽象化します。
一方でPresenterやUseCaseは抽象化しなくてもモック化が容易で抽象化するメリットがあまり無いためInterfaceは作成しません。

実装サンプル

MVPの概念はまあ色々なサイトで説明されているので、実際に個人で開発しているmitsumineというはてぶリーダーアプリに適用した実装例を貼ります。
mitsumineが元々Kotlinで書かれているためサンプルもKotlinです。
(はてなブログにKotlinのシンタックスハイライトが無くて辛い)

View(Interface)

ViewにはActivity・Fragmentのメソッドを全て定義し、Presenterから呼び出せるようにします。

interface FeedView {
    fun initViews()
    fun showRefreshing()
    fun dismissRefreshing()
    fun setFeed(feedList: List<Feed>)
    fun removeItem(feed: Feed)
    fun clearAllItem()
    fun startEntryInfoView(url: String)
    fun sendUrlIntent(url: String)
    fun sendShareUrlIntent(title: String, url: String)
    fun sendShareUrlWithTitleIntent(title: String, url: String)
    fun initListCell(holder: FeedAdapter.ViewHolder, feed: Feed)
    fun loadThumbnailImage(holder: FeedAdapter.ViewHolder, url: String)
    fun loadFaviconUrl(holder: FeedAdapter.ViewHolder, url: String)
}

Fragment(Viewの実体)

Viewを実装ActivityやFragmentです。
Presenterを保持しています。
ActivityやFragmentが行う役割は、ライフサイクルなどのイベントをPresenterに伝える事と、Presenterからの指示で画面を更新する事です。
基本的にはレイアウトやAndroidAPIへのアクセス処理しか書かず、if文やfor文などのロジックが無くなるのが理想です。

public class FeedFragment : Fragment(), FeedView, SwipeRefreshLayout.OnRefreshListener, View.OnClickListener, View.OnLongClickListener {

    companion object {
        public fun newFragment(category: Category, type: Type): FeedFragment {
            val fragment = FeedFragment()
            val bundle = Bundle()
            bundle.putSerializable(Category::class.java.canonicalName, category as Serializable)
            bundle.putSerializable(Type::class.java.canonicalName, type as Serializable)
            fragment.arguments = bundle
            return fragment
        }
    }

    private var adapter: FeedAdapter? = null
    private val presenter: FeedPresenter = FeedPresenter()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return inflater.inflate(R.layout.fragment_feed, container, false)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        val category = arguments.getSerializable(Category::class.java.canonicalName) as Category
        val type = arguments.getSerializable(Type::class.java.canonicalName) as Type
        presenter.onCreate(this, FeedUseCase(FeedRepository(context, category, type)));
    }

    override fun onDestroyView() {
        presenter.onDestroy()
        super.onDestroyView()
    }

    override fun onRefresh() {
        presenter.onRefresh()
    }

    override fun onClick(v: View) {
        presenter.onClick(v.id, v.tag as Feed)
    }

    override fun onLongClick(v: View): Boolean {
        return presenter.onLongClick(v.id, v.tag as Feed)
    }

    override fun initViews() {
        view.swipeLayout.setColorSchemeResources(R.color.blue, R.color.orange)
        view.swipeLayout.setOnRefreshListener(this)
        view.swipeLayout.setProgressViewOffset(false, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24f, resources.displayMetrics).toInt())
        adapter = FeedAdapter(activity.applicationContext, presenter)
        view.feedListView.adapter = adapter
    }

    override fun setFeed(feedList: List<Feed>) {
        adapter!!.addAll(feedList)
    }

    override fun showRefreshing() {
        view.swipeLayout.isRefreshing = true
    }

    override fun dismissRefreshing() {
        view.swipeLayout.isRefreshing = false
    }

    override fun clearAllItem() {
        adapter!!.clear()
    }

    override fun removeItem(feed: Feed) {
        adapter!!.remove(feed)
    }

    override fun sendUrlIntent(url: String) {
        startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
    }

    override fun startEntryInfoView(url: String) {
        val intent = Intent(activity, EntryInfoActivity::class.java)
        intent.putExtras(EntryInfoActivity.buildBundle(url))
        startActivity(intent)
    }

    override fun sendShareUrlIntent(title: String, url: String) {
        val share = Intent(Intent.ACTION_SEND)
        share.setType("text/plain")
        share.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET)
        share.putExtra(Intent.EXTRA_SUBJECT, title)
        share.putExtra(Intent.EXTRA_TEXT, url)
        startActivity(share)
    }

    override fun sendShareUrlWithTitleIntent(title: String, url: String) {
        val share = Intent(Intent.ACTION_SEND)
        share.setType("text/plain")
        share.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET)
        share.putExtra(Intent.EXTRA_TEXT, title + " " + url)
        startActivity(share)
    }

    override fun initListCell(holder: FeedAdapter.ViewHolder, feed: Feed) {
        holder.card.tag = feed
        holder.card.setOnClickListener(this)
        holder.card.setOnLongClickListener(this)
        holder.share.tag = feed
        holder.share.setOnClickListener(this)
        holder.share.setOnLongClickListener(this)
        holder.title.text = feed.title
        holder.content.text = feed.content
        holder.domain.text = feed.linkUrl
    }

    override fun loadThumbnailImage(holder: FeedAdapter.ViewHolder, url: String) {
        Picasso.with(context).load(url).into(holder.thumbnail)
    }

    override fun loadFaviconUrl(holder: FeedAdapter.ViewHolder, url: String) {
        Picasso.with(context).load(url).into(holder.favicon)
    }
}

Adapter

ListViewのAdapterにもViewのPresenterを持たせ、getViewなどのイベントをPresenterに渡します。
その際にViewHolderとItemもPresenterに渡し、ViewHolderの更新はView(Activity・Fragment)内のメソッドで行います。
このようにする事でUIロジックPresenter、画面の更新をViewに集約されAdapterの肥大化を防ぐことが出来ます。

public class FeedAdapter(context: Context, val presenter: FeedPresenter) : ArrayAdapter<Feed>(context, 0) {

    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val view: View
        if (convertView == null) {
            view = LayoutInflater.from(context).inflate(R.layout.row_feed, null)
            val holder = ViewHolder(cell)
            view.tag = holder
        } else {
            view = convertView
        }
        presenter.onGetView(view.tag as ViewHolder, getItem(position));
        return cell
    }

    class ViewHolder(feedView: View) {
        val card: View = feedView.findViewById(R.id.FeedFragmentCardView)
        val thumbnail: ImageView = feedView.findViewById(R.id.FeedFragmentImageViewThumbnail) as ImageView
        val favicon: ImageView = feedView.findViewById(R.id.FeedFragmentImageViewFavicon) as ImageView
        val share: ImageView = feedView.findViewById(R.id.FeedFragmentImageViewShare) as ImageView
        val title: TextView = feedView.findViewById(R.id.FeedFragmentTextViewTitle) as TextView
        val content: TextView = feedView.findViewById(R.id.FeedFragmentTextViewContent) as TextView
        val domain: TextView = feedView.findViewById(R.id.FeedFragmentTextViewDomain) as TextView
    }
}

Presenter

PresenterではViewとUseCaseを持ちます。
Presenterの役割はViewからのイベントを受け取り、表出判定などのUIロジックを行いViewへ画面更新指示を返す事です。
UIに絡まない業務ロジックやデータの取得などはUseCaseクラスに任せます。

class FeedPresenter : Subscriber<List<Feed>>() {

    var view: FeedView? = null
    var useCase: FeedUseCase? = null

    fun onCreate(feedView: FeedView, feedUseCase: FeedUseCase) {
        this.view = feedView
        this.useCase = feedUseCase

        view?.initViews()
        view?.showRefreshing()
        useCase?.requestFeed(this)
    }

    fun onDestroy() {
        view = null
        useCase?.unSubscribe()
    }

    fun onRefresh() {
        view?.clearAllItem()
        view?.showRefreshing()
        useCase?.requestFeed(this)
    }

    override fun onNext(feedList: List<Feed>) {
        view?.setFeed(feedList)
    }

    override fun onError(e: Throwable?) {
        view?.dismissRefreshing()
    }

    override fun onCompleted() {
        view?.dismissRefreshing()
    }

    fun onClick(viewId: Int, feed: Feed) {
        when (viewId) {
            R.id.FeedFragmentCardView -> {
                view?.sendUrlIntent(feed.linkUrl)
            }
            R.id.FeedFragmentImageViewShare -> {
                if (useCase!!.isShareWithTitleSettingEnable()) {
                    view?.sendShareUrlWithTitleIntent(feed.title, feed.linkUrl)
                } else {
                    view?.sendShareUrlIntent(feed.title, feed.linkUrl)
                }
            }
        }
    }

    fun onLongClick(viewId: Int, feed: Feed): Boolean {
        when (viewId) {
            R.id.FeedFragmentCardView -> {
                if (useCase!!.isUseBrowserSettingEnable()) {
                    view?.sendUrlIntent(feed.entryLinkUrl)
                } else {
                    view?.startEntryInfoView(feed.linkUrl)
                }
                return true
            }
            R.id.FeedFragmentImageViewShare -> {
                if (useCase!!.isShareWithTitleSettingEnable()) {
                    view?.sendShareUrlIntent(feed.title, feed.linkUrl)
                } else {
                    view?.sendShareUrlWithTitleIntent(feed.title, feed.linkUrl)
                }
                return true
            }
        }
        return false
    }

    fun onGetView(holder: FeedAdapter.ViewHolder, feed: Feed) {
        view?.initListCell(holder, feed);
        if (!feed.thumbnailUrl.isEmpty()) {
            view?.loadThumbnailImage(holder, feed.thumbnailUrl)
        }
        if (!feed.faviconUrl.isEmpty()) {
            view?.loadFaviconUrl(holder, feed.faviconUrl)
        }
    }
}

UseCase

UseCaseではRepositoryクラスを持ち、画面固有の業務ロジックを担当します。
シンプルな要件のアプリであればUseCaseの処理はほとんどRepositoryに移譲されるかもしれません。
アプリの仕様が複雑だったりAPI側で柔軟な対応が出来ないアプリであれば、UseCaseで画面固有のリスト操作や文字列操作などを行います。

open class FeedUseCase(val repository: FeedRepository) {

    val subscriptions = CompositeSubscription()

    open fun isUseBrowserSettingEnable() = repository.isUseBrowserSettingEnable

    open fun isShareWithTitleSettingEnable() = repository.isShareWithTitleSettingEnable

    open fun requestFeed(subscriber: Observer<List<Feed>>) {
        subscriptions.add(repository.requestFeed()
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .filter { feed -> !FeedUtil.contains(feed, repository.readFeedList) && !FeedUtil.containsWord(feed, repository.ngWordList) }
                .toList()
                .subscribe(subscriber))
    }

    fun unSubscribe() {
        subscriptions.unsubscribe()
    }
}

Repository

RepositoryではAPIやデータベースなどへのデータアクセスを担当します。
RepositoryクラスをUseCaseクラスとは別に定義する事でUseCaseのテストをしやすくします。

class FeedRepository(val context: Context, val category: Category, val type: Type) {

    fun requestFeed(): Observable<Feed> = FeedApi.requestCategory(context, category, type)

    val readFeedList: List<Feed>
        get() = FeedDAO.findAll()

    val ngWordList: List<String>
        get() = NGWordDAO.findAll()

    val isUseBrowserSettingEnable: Boolean
        get() = PreferenceManager.getDefaultSharedPreferences(context).getBoolean(context.getString(R.string.key_use_browser_to_comment_list), false)

    val isShareWithTitleSettingEnable: Boolean
        get() = PreferenceManager.getDefaultSharedPreferences(context).getBoolean(context.getString(R.string.key_is_share_with_title), false)
}

Presenterのテスト

テストにはMockitoというモックライブラリを使用します。
http://mockito.org/

Mockitoはかなり便利なライブラリで、自動でクラスやインターフェイスからモックインスタンスを生成出来て、そのモックインスタンスメソッドが何回呼ばれたかなどを判定するテストを書く事が出来ます。

MockitoでViewやUseCaseのモックを作りメソッドが呼びだされたかを確認する事でPresenterへのテストを非常に簡単に書く事が出来ます。
UseCaseへのテストも同じように書けるはずです。

import static org.mockito.Mockito.*;

@RunWith(AndroidJUnit4.class)
public class FeedPresenterTest {

    FeedView viewMock;
    FeedUseCase useCaseMock;

    @Before
    public void setup() {
        // モックインスタンスを生成
        viewMock = mock(FeedView.class);
        useCaseMock = mock(FeedUseCase.class);
    }

    @Test
    public void onCreateTest() {
        FeedPresenter presenter = new FeedPresenter();
        presenter.onCreate(viewMock, useCaseMock);

        // verifyメソッドでモックオブジェクトのそれぞれのメソッドが呼ばれたかをチェックしている
        verify(viewMock, times(1)).initViews();
        verify(viewMock, times(1)).showRefreshing();
        verify(useCaseMock, times(1)).requestFeed(presenter);
    }

    @Test
    public void onRefreshTest() {
        FeedPresenter presenter = new FeedPresenter();
        presenter.onCreate(viewMock, useCaseMock);
        presenter.onRefresh();
        verify(viewMock, times(1)).clearAllItem();
        verify(viewMock, times(2)).showRefreshing();
        verify(useCaseMock, times(2)).requestFeed(presenter);
    }

    @Test
    public void onNextTest() {
        FeedPresenter presenter = new FeedPresenter();
        presenter.onCreate(viewMock, useCaseMock);
        List<Feed> list = new ArrayList<>();
        presenter.onNext(list);
        verify(viewMock, times(1)).setFeed(list);
    }

    @Test
    public void onErrorTest() {
        FeedPresenter presenter = new FeedPresenter();
        presenter.onCreate(viewMock, useCaseMock);
        presenter.onError(new Throwable());
        verify(viewMock, never()).setFeed(any(List.class));
        verify(viewMock, times(1)).dismissRefreshing();
    }

    @Test
    public void onCompleteTest() {
        FeedPresenter presenter = new FeedPresenter();
        presenter.onCreate(viewMock, useCaseMock);
        presenter.onCompleted();
        verify(viewMock, never()).setFeed(any(List.class));
        verify(viewMock, times(1)).dismissRefreshing();
    }

    @Test
    public void onClickFeedTest() {
        // whenメソッドで指定したメソッドの戻り値を指定している
        when(useCaseMock.isShareWithTitleSettingEnable()).thenReturn(true);
        Feed feed = new Feed();
        feed.setLinkUrl("http://test");

        FeedPresenter presenter = new FeedPresenter();
        presenter.onCreate(viewMock, useCaseMock);
        presenter.onClick(R.id.FeedFragmentCardView, feed);
        verify(viewMock, times(1)).sendUrlIntent("http://test");
    }

    @Test
    public void onFeedLongClickTest() {
        Feed feed = new Feed();
        feed.setLinkUrl("http://test");
        feed.setEntryLinkUrl("http://entry");

        when(useCaseMock.isUseBrowserSettingEnable()).thenReturn(true);
        FeedPresenter presenter = new FeedPresenter();
        presenter.onCreate(viewMock, useCaseMock);
        presenter.onLongClick(R.id.FeedFragmentCardView, feed);
        verify(viewMock, times(1)).sendUrlIntent("http://entry");
        verify(viewMock, never()).startEntryInfoView("http://test");

        when(useCaseMock.isUseBrowserSettingEnable()).thenReturn(false);
        presenter.onLongClick(R.id.FeedFragmentCardView, feed);
        verify(viewMock, times(1)).sendUrlIntent("http://entry");
        verify(viewMock, times(1)).startEntryInfoView(("http://test"));
    }

    @Test
    public void onFeedShareClickTest() {
        Feed feed = new Feed();
        feed.setTitle("title");
        feed.setLinkUrl("http://test");
        feed.setEntryLinkUrl("http://entry");

        when(useCaseMock.isShareWithTitleSettingEnable()).thenReturn(true);
        FeedPresenter presenter = new FeedPresenter();
        presenter.onCreate(viewMock, useCaseMock);
        presenter.onClick(R.id.FeedFragmentImageViewShare, feed);
        verify(viewMock, times(1)).sendShareUrlWithTitleIntent("title", "http://test");
        verify(viewMock, never()).sendShareUrlIntent("title", "http://test");

        when(useCaseMock.isShareWithTitleSettingEnable()).thenReturn(false);
        presenter.onClick(R.id.FeedFragmentImageViewShare, feed);
        verify(viewMock, times(1)).sendShareUrlWithTitleIntent("title", "http://test");
        verify(viewMock, times(1)).sendShareUrlIntent("title", "http://test");
    }

    @Test
    public void onFeedShareLongClickTest() {
        Feed feed = new Feed();
        feed.setTitle("title");
        feed.setLinkUrl("http://test");
        feed.setEntryLinkUrl("http://entry");

        when(useCaseMock.isShareWithTitleSettingEnable()).thenReturn(true);
        FeedPresenter presenter = new FeedPresenter();
        presenter.onCreate(viewMock, useCaseMock);
        presenter.onLongClick(R.id.FeedFragmentImageViewShare, feed);
        verify(viewMock, never()).sendShareUrlWithTitleIntent("title", "http://test");
        verify(viewMock, times(1)).sendShareUrlIntent("title", "http://test");

        when(useCaseMock.isShareWithTitleSettingEnable()).thenReturn(false);
        presenter.onLongClick(R.id.FeedFragmentImageViewShare, feed);
        verify(viewMock, times(1)).sendShareUrlWithTitleIntent("title", "http://test");
        verify(viewMock, times(1)).sendShareUrlIntent("title", "http://test");
    }
}

所感

f:id:kirimin:20151115235813p:plain

すでに実装されているコードをこの設計に書き換えるのはわりと大変ですが、最初からMVPを前提に実装すればそれほど手間は掛からないはずで、むしろ業務の開発規模であれば設計が明確になりテストが書きやすくなるメリットの方が大きくなるんじゃないかなぁと思っています。

Viewのメソッドをどのくらい細かく分けてどの程度の粒度でテストを書くかなどは試行錯誤が必要そうですが、とりあえずテストが書ける環境が整えば試行錯誤もしやすいのではないでしょうか。
サンプルではViewでUseCaseやRepositoryをnewしていますが、Viewのテストも書きたければDaggerなどを使った方がいいかもしれません。