もやもやエンジニア

IT系のネタで思ったことや技術系のネタを備忘録的に綴っていきます。フロント率高め。

手持ちのアプリをAndroidX対応したメモ

特に知見というほどのものでもないのですが、AndroidX対応したやつをStoreに載せたので作業ログなど。ASは3.2.1、対応するアプリはKotlin製です。

読むべきページ

Migrating to AndroidX  |  Android Developers 移行の手順と旧ライブラリ群がどのようにAndroidXにmappingされるのかが書いてあります。

事前準備

Refactor -> Migrate to AndroidXでガツンと変えてくれるらしいのですが、build.gradleが3.2.0より小さいと実行できないと言われるので、まず上げます。このときは3.2.1に上げました。

Migrateする

改めてASのメニューからRefactor -> Migrate to AndroidXします。すると今までSupportライブラリを使っていたファイルのパスがandroidx.XXXまたはcom.google.android.material.XXXに置き換わります。ただ、一部うまく変換できてないところがあったので変換後の差分はちゃんと見たほうがいいです。僕のやつだとimportしてる部分が変換されてるのに引数の型がFQDNで指定されてたりしました。

つまづきポイント1

ビルドするも通らず。。。原因はDataBindingが生成するActivityXXXBinding系のファイルでDataBindingComponentが見つからないと言われました。generatedJavaを見ても確かに見つかりません。gradle.propertiesのenableJetifierが怪しいかなと思い、フラグをfalseにすると生成されました。ただ、このフラグ自体は旧supportライブラリとAndroidXをつなぐために必要な設定なのでfalseにはできません。

似たような事例をさがすとバージョンは微妙に違うものの同じ問題でDagger16に戻すと解消するというPostがあり、戻してみると該当の問題は解消しました。

stackoverflow.com

つまづきポイント2

ビルドするもまだ通らず。。。原因はSQLiteのORMにOrmaを使っていて、Ormaが生成するスキーマのファイル内で旧Support系のライブラリを使っていて、それの参照エラーになっていました。AnnotationProcessorで生成される分についてはgradle.propertiesのenableJetifierは面倒を見てくれないということですね。こちらはちょうど作業中にAndroidX対応のバージョンがリリースされたので、バージョンを更新したことで事なきを得ました。

まとめ

  • Migrate to AndroidXで楽に移行できるけど、完璧ではないので差分は確認したほうがいいです
  • 自分の環境(DaggerでDI、DataBinding使用)だとDagger2.17はビルドエラーとなり、Dagger2.16に落とすとビルドできました。コードを追えていないので(というかAnnotationProcessor系でこけるとどこが問題なのか追うのがつらい)はっきりとした原因とはいえないのですが。。。
  • AnnotationProcessorでサードパーティのライブラリが生成するファイル内で旧supportライブラリが使われているとビルドできませんでした。こちらは生成されるファイル内の旧supportライブラリ系のパスがandroidx対応になっている必要があります。

個人アプリで10万DLまでいったのでいろいろふりかえる

個人で開発したアプリがDL数10万/評価1000件まで育ってくれて、いい機会なので現時点で取れている各種情報をキャプチャしておきます。個人のアプリ開発の参考にどうぞ。

play.google.com

※作ったときに書いたブログ

rei19.hatenablog.com

サマリー

  • Android版のみ。初回リリースしてから1年半強で18回リリースしている。最初Javaで書いたけどKotlinで書き換えて今に。
  • 機能はあまり増やさずにコアの機能を改善するようなリリースがほとんどです。
項目 数値 取得元
インストール数 118.87K(うちアクティブは24,000) PlayStore
平均評価 4.408 PlayStore
レビュー/評価数 375/1,000 PlayStore
ANR/直近30日 99.9% PlayStore
クラッシュ/直近30日 99.5% PlayStore
DAU 直近の8末で700前後。全期間での最高値は3500 Firebase

※補足: クラッシュ率はProguradの設定で事故ったリリースがあったのでそれを抜かすともうちょっといい数字のはず

直近の継続率(Firebase)

f:id:Rei19:20180830204502p:plain:w400

直近のユーザー属性(Firebase)

f:id:Rei19:20180830204506p:plain:w400

技術的なところも少し

  • 今の構成は↓の感じで、MVVM/Fluxな雰囲気の作りになっています。アプリの仕様的にそこまでやらなくてもという設計なのですが、素振りも兼ねて作りたいように作っています。仕事ではちゃんと設計は取捨選択しましょう。
  • 本業ではAndroid全然関係ない物作っていてトレンドを追いきれていないので古いところもあるかも。

全体の構成

https://github.com/rei-m/android_hyakuninisshu

SPA版など

  • iOS対応する代わりに簡易的な仕様のSPA版も作りました。こちらはあんまりメンテしてないのでぼちぼち改善します。

https://hyakuninanki.net/

おしまい

  • Webではなくアプリでリリースするところのいいところはレビュー書いてもらえるところだと思っていて、アプリのおかげで大会で入賞しました!とか書いてくれる人がいて、作った甲斐があったなーという感じです。Webだとエゴサーチでもしないと見つかりませんからね。
  • iOSでリリースしたアプリも含めてStoreに載せたのは3個目ですが継続して何か世に送り出していきたいですね。

AACのViewModel使ってFluxする

結論

出落ちですがAndroidのモダンな設計周りの話はPEAKSで出ているこの本を読んだ方が手っ取り早く吸収できます。AACとFluxの話も載ってます。

Android アプリ設計パターン入門

Android アプリ設計パターン入門

  • 著者:日高 正博,小西裕介,藤原聖,吉岡 毅,今井 智章,
  • 製本版,電子版
  • PEAKSで購入する

とはいえ、いろいろ自分でも試行錯誤しないと理解が進まないので、上記を踏まえた上で自分なりに実装してみた話です。自分でリリースしているアプリ↓をいじっています。

play.google.com

構成

  • パターン本にも書いてありますがFluxで状態を管理するところのStoreはAACのViewModelが担うことができます。ここを軸に以下の構成で作ってみます。

全体の構成

  • お題となる画面は解答で表示する札を表示するだけのやつ

札

Dispatcher

  • dispatchで渡されたActionをpublisherに流し込むだけの責務を持ってます。

action/Dispacher.kt

@Singleton
class Dispatcher @Inject constructor() {

    private val processor = PublishProcessor.create<Action>()

    fun dispatch(action: Action) {
        Logger.action(action)
        processor.onNext(action)
    }

    fun <T : Action> on(clazz: Class<T>): Observable<T> = processor.onBackpressureBuffer()
            .ofType(clazz)
            .observeOn(AndroidSchedulers.mainThread())
            .toObservable()
}

ActionDispatcher

  • 名前はActionCreatorのほうが良かったかも。DBにアクセスしたりユーザーからの入力を処理したりなどのビジネスロジックが詰まっています。
  • SchedulerProviderはDroidKaigiのアプリ参考にさせてもらいました。テスト時にはtrampolineなテスト用のスケジューラーを差し込めます。
  • dispatcherに流しているFetchKarutaActionはpayloadと例外情報を持つだけのclassです。

action/karuta/KarutaActionDispatcher.kt

@Singleton
class KarutaActionDispatcher @Inject constructor(
        private val karutaRepository: KarutaRepository,
        private val dispatcher: Dispatcher,
        private val schedulerProvider: SchedulerProvider
) : SingleExt {
    fun fetch(karutaId: KarutaIdentifier) {
        karutaRepository.findBy(karutaId).scheduler(schedulerProvider).subscribe({
            dispatcher.dispatch(FetchKarutaAction(it))
        }, {
            dispatcher.dispatch(FetchKarutaAction(null, it))
        })
    }
}

Store

  • Dispacherから流れてくるActionを監視して自分の状態を更新します。ReduxのReducer的な責務も兼ねてますね。
  • StoreはAACのViewModelを継承した抽象クラスでDispatcher監視用の共通メソッドが生えてます。onClearedでregisterで登録した監視を解除しているので、ライフサイクルで死ぬときに 監視は解除されます。

presentation/karuta/KarutaStore.kt

class KarutaStore(dispatcher: Dispatcher) : Store() {

    private val karutaLiveData = MutableLiveData<Karuta?>()
    val karuta: LiveData<Karuta?> = karutaLiveData

    private val notFoundKarutaEventLiveData = SingleLiveEvent<Void>()
    val notFoundKarutaEvent = notFoundKarutaEventLiveData

    init {
        register(dispatcher.on(FetchKarutaAction::class.java).subscribe {
            if (it.error == null) {
                karutaLiveData.value = it.karuta
            } else {
                notFoundKarutaEventLiveData.call()
            }
        })
    }

    class Factory @Inject constructor(private val dispatcher: Dispatcher) : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            return KarutaStore(dispatcher) as T
        }
    }
}

ViewModel

  • Storeを監視してViewにStoreの状態を加工して反映させます。AACのViewModelではありません。。。名前付け難しい。。。

presentation/karuta/KarutaViewModel.kt

class KarutaViewModel(
        store: KarutaStore,
        actionDispatcher: KarutaActionDispatcher,
        karutaId: KarutaIdentifier
) : LiveDataExt {

    val isLoading: LiveData<Boolean> = store.karuta.map { it == null }

    val karutaNo: LiveData<Int?> = store.karuta.map { it?.identifier()?.value }

    val karutaImageNo: LiveData<String?> = store.karuta.map { it?.imageNo?.value }

    val creator: LiveData<String?> = store.karuta.map { it?.creator }

    val kimariji: LiveData<Int?> = store.karuta.map { it?.kimariji?.value }

    val kamiNoKuKanji: LiveData<String?> = store.karuta.map { it?.kamiNoKu?.kanji }

    val shimoNoKuKanji: LiveData<String?> = store.karuta.map { it?.shimoNoKu?.kanji }

    val kamiNoKuKana: LiveData<String?> = store.karuta.map { it?.kamiNoKu?.kana }

    val shimoNoKuKana: LiveData<String?> = store.karuta.map { it?.shimoNoKu?.kana }

    val translation: LiveData<String?> = store.karuta.map { it?.translation }

    val notFoundKarutaEvent = store.notFoundKarutaEvent

    init {
        if (store.karuta.value == null) {
            actionDispatcher.fetch(karutaId)
        }
    }

    class Factory @Inject constructor(private val actionDispatcher: KarutaActionDispatcher) {
        fun create(store: KarutaStore, karutaId: KarutaIdentifier): KarutaViewModel =
                KarutaViewModel(store, actionDispatcher, karutaId)
    }
}

Fragment

  • FragmentはStoreを取得して、それを元にViewModelを作ってDataBinidngにセットするまでを仕事します(必要そうなところだけ抜粋)。

presentation/karuta/KarutaFragment.kt

class KarutaFragment : DaggerFragment(), FragmentExt {

    @Inject
    lateinit var storeFactory: KarutaStore.Factory

    @Inject
    lateinit var viewModelFactory: KarutaViewModel.Factory

    private var listener: OnFragmentInteractionListener? = null

    private val karutaId: KarutaIdentifier
        get() = requireNotNull(arguments?.getParcelable(ARG_KARUTA_ID)) {
            "$ARG_KARUTA_ID is missing"
        }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val viewModel = viewModelFactory.create(obtainActivityStore(KarutaStore::class.java, storeFactory), karutaId)
        viewModel.notFoundKarutaEvent.observe(this, Observer {
            listener?.onError()
        })

        val binding = FragmentKarutaBinding.inflate(inflater, container, false).apply {
            setLifecycleOwner(this@KarutaFragment)
        }

        binding.viewModel = viewModel

        return binding.root
    }

...

}

おしまい。

  • という感じでリリース済のアプリを書き換えてみました。(まだリリースしていなくて、テスト周りを充実させているところ)各クラスの役割は分担できていて自分以外の人も追いやすく読める形になってるのかなと。例えばここに歌を編集するような操作を追加したいときはactionDispatcherにeditとか生やしてそこに歌を編集して永続化する処理を実装、Actionは編集後の歌をpayloadとして流す、storeはそれを受け取って自身の状態を変更する、というような実装の流れになります。ViewModelはstoreの監視対象は変わらないので、ユーザーのactionとactionDispatcherのeditを結びつけるだけですね。
  • READMEにも書きましたがアプリの規模に対してはやや過剰な設計なので、興味があればあまり参考にしないで話半分にコードを読むのがいいと思います。

GitHub - rei-m/android_hyakuninisshu

実装から考えてしまう癖に気をつけようという話

これはサービス開発してる人にとっては当たり前かもしれないのですが、自分に対しての戒めでもあるので、なんとなく文章として残しときます。

どんなことかというと、自社サービスを開発・運営していると、不定期に○○をこうしてほしい、△△はユーザーが使いづらいと言っていたので直したほうがいいのではーなどの要望が上がってくるわけですが、その時の思考の入り口として、それはユーザーにとってどのようなメリットが有るのか、体験が改善されるのかというところから入るのではなくて、その言われた要望は実装的に出来そうか出来なさそうか、というところから考えてしまうという話です。

これは割と癖になりやすいかなーと思っていて、早いうちにプログラマーの仕事は言われたことを作る仕事であるみたいな感じで染み付いてしまうと、なかなか変えづらいのかなと感じてます。特に今は僕は2Bのサービスを作っていてクライアントが明確なので2Cのサービスよりダイレクトにユーザーの声を聞きやすく、故に「あ、それは実装できますね、やりましょうか」みたいな感じでほいほい言われたものを作るだけだと、容易に統一感がなく使いづらいプロダクトが出来上がってしまいます。

ユーザーのオペレーションも含めて検討した結果、その要望は実は既存の他の機能で満たせることかもしれないし、見せ方を変えるだけで解決するかもしれない。僕らの仕事はモノを作ることではなく、課題を解決することにあって、何かを開発することはその選択肢のひとつにすぎないのですよね。

というようなことをインターンの子に↓の話をしてるときに改めて思ったので、僕も意識しようと思ったという話でした。おしまい。

イシューからはじめよ―知的生産の「シンプルな本質」

イシューからはじめよ―知的生産の「シンプルな本質」

ちはやふるにはまったので百人一首を暗記するサイトをSPAで作った

夜なべしてコツコツ作っていたのがまあまあ形になったので公開しました。ちはやふる ~結び~の公開には間に合わなかった。。。

https://hyakuninanki.net/

去年から仕事でB向けのサービスをSPAで開発してて、ある程度自分の中で知見がたまって自分でも何か作ってみようと思って、Androidアプリで出したやつ(下記参考)の焼き直しをSPAで公開することにしました。見た目はほぼ一緒。

rei19.hatenablog.com

雑なコメント

  • React/Reduxで取り立てて珍しいものはないです。言語はTypeScript。型最高。ReactはできるだけFSCベースでPropsをViewに変換するようなイメージでComponentを組んでます。
  • styleはstyled-componentsで管理。デザインフレームワークのサポートにblueprintjsというのを使ってみたけど、使わなくても良かったかも(material-uiは仕事で使ってるので避けた)
  • テストもぼちぼち書いてます。jestのsnapshot機能は最高なので、テスト書かないプロジェクトでもsnapshotだけでも取るようにすると意図しない変更が検知できていいのかなと思いました。
  • SSRまでは頑張っていなくて、単にbundleしたjsとcssを撒いているだけという手抜き構成。ちゃんとクローラが見てくれるのかはよくわかってない。。。クロールされた後の検索結果見てから考えます。
  • webpack4に上げたらawsome-typescript-loaderが対応してないのか、ファイル修正するたびにエラーが起きてリロードしなくなってしまった。。。多分ts-loaderにしたら直る(気がする)

おしまい

  • ポートフォリオ代わりにコードは公開してます。もうちょいコンテンツ充実させるのと、インタラクションを調整する予定。

GitHub - rei-m/web_hyakuninisshu

ArchitectureComponentsのViewModelとDataBindingを使う

触るがてら既存のDataBinding + MVVMなアプリにAACのViewModelを入れてみました。対象は結構前にKotlinで書いたアプリ。

GitHub - rei-m/HBFav_material: HBFav for Android with kotlin

2018/07/26 更新 こっちが自分なりの最新の設計なのでこっち見たほうがいいです。 GitHub - rei-m/android_hyakuninisshu

AACのViewModel

developer.android.com

  • Activity/Fragmentとは別のライフサイクルをもったDataHolderの模様
  • 説明ざざっと読んだ感じ onSavedInstanceState いらないのか!と思ったもののそうではなく、普通にActivity保持しないとかにすると消し飛ぶので、自分が死ぬタイミングがわかっていて、Activity/Fragmentとは別のライフサイクルをもったModelであると解釈

書いてみる

Model層

  • 既存の作りと変わらずにObservableなプロパティを公開しているアプリケーションスコープなModelとして作っています。

ViewModel層

  • 既存のDataBindingに食わせていたViewModelは基底クラスにライフサイクルメソッドを生やして、Activity/Fragmenntからそれを呼んでModelの購読/解除をしていました。ここを変えてAACのViewModelを継承するようにします。DataBindingに公開するObservableFieldは変えていません。

自分のブックマーク表示する画面のVM / UserBookmarkFragmentViewModel.kt

class UserBookmarkFragmentViewModel(private val userBookmarkModel: UserBookmarkModel,
                                    userModel: UserModel,
                                    readAfterFilter: ReadAfterFilter) : ViewModel() {

略)

   init {
        userId.addOnPropertyChangedCallback(userIdChangedCallback)
        hasNextPage.addOnPropertyChangedCallback(hasNextPageChangedCallback)
        this.readAfterFilter.addOnPropertyChangedCallback(readAfterFilterChangedCallback)

        disposable.addAll(userBookmarkModel.bookmarkList.subscribe {
            if (it.isEmpty()) {
                bookmarkList.clear()
            } else {
                bookmarkList.addAll(it - bookmarkList)
            }
            isVisibleEmpty.set(bookmarkList.isEmpty())
        }, userBookmarkModel.hasNextPage.subscribe {
            hasNextPage.set(it)
        }, userBookmarkModel.isLoading.subscribe {
            isVisibleProgress.set(it)
        }, userBookmarkModel.isRefreshing.subscribe {
            isRefreshing.set(it)
        }, userBookmarkModel.isRaisedError.subscribe {
            isVisibleError.set(it)
        }, userModel.user.subscribe {
            userId.set(it.id)
        })
    }

    override fun onCleared() {
        userId.removeOnPropertyChangedCallback(userIdChangedCallback)
        hasNextPage.removeOnPropertyChangedCallback(hasNextPageChangedCallback)
        readAfterFilter.removeOnPropertyChangedCallback(readAfterFilterChangedCallback)
        disposable.dispose()
        super.onCleared()
    }

略)
  • 自分が破棄されるタイミングがわかるようになったので、生成時にModelのプロパティの購読開始。onClearedで購読解除するようにしました。ここではuserModel.userの購読開始するとBehaviorSubjectから値が流れてくる -> ViewModelがUserIdの変更を検知してブックマークとる命令を出す、という感じでViewModel生成時にバックグラウンドでデータを取りに行っています。

ViewModelとFactory

  • ViewModelにBundleから取り出した引数を与えたいというような場合、ViewModelProvidersにFactoryを与えることで、ViewModel生成時の処理に介入できるようになります。FactoryはDagger経由でDIするようにしてます。
viewModel = ViewModelProviders.of(this, viewModelFactory).get(OthersBookmarkFragmentViewModel::class.java)

他人のブックマーク表示する画面のVM / OthersBookmarkFragmentViewModel.kt

略)
    // ViewModelProvidersに渡すViewModelを提供するFactory
    class Factory(private val userBookmarkModel: UserBookmarkModel,
                  private val userId: String) : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel?> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(OthersBookmarkFragmentViewModel::class.java)) {
                return OthersBookmarkFragmentViewModel(userBookmarkModel, userId) as T
            }
            throw IllegalArgumentException("Unknown class name")
        }
    }

Factoryを提供するModule / OthersBookmarkFragmentViewModelModule.kt

@Module
class OthersBookmarkFragmentViewModelModule(private val userId: String) {
    @Provides
    @ForFragment
    fun provideViewModelFactory(@Named("othersUserBookmarkModel") userBookmarkModel: UserBookmarkModel): OthersBookmarkFragmentViewModel.Factory =
            OthersBookmarkFragmentViewModel.Factory(userBookmarkModel, userId)
}
  • userBookmarkModelはDaggerからDIされますが対象のユーザーIDはModule生成時に設定してあげる必要があります。

他人のブックマーク表示する画面 / OthersBookmarkFragment.kt

略)
    @dagger.Subcomponent(modules = arrayOf(
            OthersBookmarkFragmentViewModelModule::class,
            BookmarkListItemViewModelModule::class))
    interface Subcomponent : AndroidInjector<OthersBookmarkFragment> {
        @dagger.Subcomponent.Builder
        abstract class Builder : AndroidInjector.Builder<OthersBookmarkFragment>() {

            abstract fun viewModelModule(module: OthersBookmarkFragmentViewModelModule): Builder

            override fun seedInstance(instance: OthersBookmarkFragment) {
                viewModelModule(OthersBookmarkFragmentViewModelModule(instance.userId))
            }
        }
    }

    @dagger.Module(subcomponents = arrayOf(Subcomponent::class))
    abstract inner class Module {
        @Binds
        @IntoMap
        @FragmentKey(OthersBookmarkFragment::class)
        internal abstract fun bind(builder: Subcomponent.Builder): AndroidInjector.Factory<out Fragment>
    }
}
  • Fragment内のinner classとしてSubComponentを定義してその中のseedInstance内でFragmentのインスタンスからuserIdを取り出してModuleを作成しています。onSavedInstanceStateから値を復元したい場合はDIされたviewModelFactoryに取り出した値をセットしてからViewModelProviderに渡すようにしました。

書いてみて

  • 毎回Activity/FragmentのライフサイクルでviewModel.onXXXX を呼んでたのでそれがなくなったのはだいぶ楽
  • こういう使い方でいいのか、あるいはDataBindingのViewModelとは別のレイヤーとして考えるべきなのか?とか一人で書いてるとどっちがいいのかよくわからんという感じになったのでトークしたい。

enzymeでReact RouterのwithRouterで包んだRedux Containerのテストする

  • 今の仕事はReact/ReduxなSPAをTypeScriptで書いてて、HOCのテストの書き方ちょっと迷ったのでメモとして残しておきます。

関連する主なライブラリ

  • "react": "~16.0.0",
  • "react-dom": "~16.0.0"
  • "react-redux": "~5.0.6"
  • "react-router-dom": "~4.2.2"
  • "redux": "~3.7.2"
  • "enzyme": "~3.1.0"
  • "enzyme-adapter-react-16": "~1.0.1"
  • "redux-mock-store": "~1.3.0"

こんな感じ

  • ラップするComponent。コードは適当にURIから受け取ったIDが表示 + ボタンを一つ持つだけのやつ。

components/HogeContent/index.tsx

export interface HogeContentOwnProps extends React.ClassAttributes<HogeContent>;

export interface HogeContentConnectedProps {
  readonly disabled: boolean;
}

export interface HogeContentDispatchProps {
  readonly onClickButton: () => void;
}

export interface HogeContentRouterProps = RouteComponentProps<{id: number}>;

export type HogeContentProps = HogeContentOwnProps & 
  HogeContentConnectedProps & 
  HogeContentDispatchProps & 
  HogeContentRouterProps;

export default class HogeContent extends React.Component<HogeContentProps> {

  constructor(props: HogeContentProps) {
    super(props);
    this.onClickButton = this.onClickButton.bind(this);
  }

  public render() {
    return (this.props.disabled) ? (
      <div>
        {this.props.match.params.id}
      </div>
    ) : (
      <div>
        <button
          type="submit"
          onClick={this.props.onClickButton}
        >
          {this.props.match.params.id}
        </button>
      </div>
    );
  }
}
  • テスト対象となるComponentを包むContainer

containers/Hoge/index.tsx

const mapStateToProps = (state: GlobalState, _: HogeRouterProps): HogeContentOwnProps & HogeContentConnectedProps {
  const { disabled } = state.hoge;
  return {
    disabled
  }
};


const mapDispatchToProps = (dispatch: Dispatch<GlobalState>, props: HogeRouterProps): HogeContentDispatchProps {
  return {
    onClickButton: () => {
        dispatch(sendHoge(););
        props.history.push('/fuga');
    }
  };
};

export default withRouter(connect(mapStateToProps, mapDispatchToProps)(HogeContent));
  • こんな感じで '/hoge' で来たらHogeContentを表示するみたいなやつに対して以下の項目をテストしたいとします。
    • Reduxのstoreからdisabledを渡しているか
    • onClickButtonが押されたらsendHogeをdispatchしているか
    • onClickButtonが押されたら /fuga に遷移しているか
  • なおテストはmochaで走らせてます。
describe('containers', () => {
  describe('<HogeIndex />', () => {
    let wrapper: ShallowWrapper;
    let mockStore: MockStore<{}>;
    let mockRouter: any;

    beforeEach(() => {
      mockStore = configureMockStore()({
        hoge: {
          disabled: false
        }
      });
      mockRouter = {
        route: {
          match: { params: { id: 1 } },
          location: {
            pathname: '/hoge'
          }
        },
        history: {
          push: sinon.stub()
        }
      };

      wrapper = shallow(
        <Hoge
          match={mockRouter.route.match as any}
          location={mockRouter.route.location as any}
          history={mockRouter.history as any}
        />
      ).dive({
        context: {
          router: mockRouter
        }
      }).dive({
        context: {
          store: mockStore
        }
      });
    });

    it('should component received props', () => {
      const actual = wrapper.find(HogeContent).props().disabled;
      assert.equal(actual, false);
    });

    it('should dispatch sendHoge when button clicked', () => {
      const actual = wrapper.find(HogeContent).props().onClickButton();
      const actions = mockStore.getActions();  // configureMockStoreを使うとこのようにDispatchされたActionが取れる
      assert.equal(actions[0].type, 'SEND_HOGE_ACTION');
    });

    it('should go to fuga when button clicked', () => {
      const actual = wrapper.find(HogeContent).props().onClickButton();
      assert(mockRouter.history.push.calledWith('/fuga'));
    });
  });
});
  • Enzymeのdiveを使うとラップされたComponentをshallow renderしたのが返ってくるので、その時にHOCから注入されるContextを設定してあげます。Reduxだけの場合はshallowの2番目の引数にContextを設定できるのですが、withRouterで更に包んだ場合は withRouter -> Router -> connect -> componentという階層になってるので、diveしつつ各階層で必要な情報を注入して上げる感じでテスト対象のComponentをrenderできました。

参考

dive() · Enzyme