君の名は。展に行ってきた。良さがあった。
君の名は。展に行ってきたので簡単なレポートなど。基本的に中は撮影NGなので雰囲気だけ。
場所
長野県は小海町というところにある高原美術館で開催されています。
小海は新海監督の出身地で、以前にも新海誠展が開催されたところです。僕はその時に初めて行って、この場所を気に入ったので、たまにぷらっと遊びに行くようになりました。
東京からの行き方
電車とバスで行きました。東京から北陸新幹線で佐久平へ行き、そこからJR小海線で小海駅または松原湖駅まで行きます。駅に着いたらバスかタクシーで美術館へ向かいます。
電車とバスの時間は事前に調べてからのほうがよいです。小海線は1時間に1本、バスも1時間に1本あればいいという感じなので、タイミングが悪いと新幹線で佐久平についたけど小海線が来ない、とか小海駅についたけどバスがないとか、ありえます。
個人的には、小海駅からの美術館までは、行きは割り切ってタクシーを使って、帰りはバスを使うのがオススメです。今回は君の名は。が予想以上にヒットしたので、いつもは適当につかまえていたタクシーが全然捕まらず、タクシーを呼んだっぽい二人組に声をかけて相乗りさせてもらいました。
昼時に着いたら駐車場は満車だったので車で行く場合は早めに行ったほうがよいでしょう。
見所
美術館自体はそこまで大きくないものの、貴重な絵コンテや君の名は。の原案など、ここでしか見られないものがあり、ファンの人は行って損はないと思います。
美術館自体も山に囲まれた珍しい立地で、隣にはレストランと温泉が併設されています。君の名は。展を見て、レストランでランチを食べて、山々を見ながら露天風呂に入ってさっぱりして帰る、という贅沢な時間をすごすことができます。
来館者一万人達成!
- まわりはこんな感じでどこを見渡しても山ですね。自然が気持ちいい。
- レストランでのランチ。信州は飯がうまい!
- ちなみにレストランに君の名は。に出てくるパンケーキを再現したメニューというのがあるんですが、数量限定で予約制だったので、食べたい人は着いたら、先にレストランに行って予約できるか確認するといいかもしれません。(僕は知らずにいったのでありつけなかった。。。)
おわり
新海作品は秒速5センチメートル以来のファンですけど、今回はめちゃくちゃヒットしたのでびっくりですね。前回の新海誠展に行った時は、僕が行った時間に来ていたお客さんは3組くらいだった記憶があるのですが、今回は駐車場は満車、レストランも満席という感じだったので人気のほどを伺えました。
君の名は。展は12/25日までやっているので、今回でファンになった人も昔からファンの人も、行ってみるとより君の名は。を好きになるのではないでしょうか。
転職してAndroidエンジニアになってから1年経ったので振り返る
去年に一休を退職してから今の会社に入ってちょうど1年経ったので軽く振り返ってみます。
仕事の内容
- 前はWebサービスの開発 / 運用まで一通りやってました。アプリも作りました。(Web系エンジニアの僕が初めてAndroidアプリをリリースした話 - もやもやエンジニア )
- 現在は新規サービスのAndroidアプリの開発に集中してます。WebはたまにFluxとかReduxとかの新しい概念を知るためにJavaScript触ったりする程度。チームとしてはAndroid担当がもう一人いて2人で作ってます。
入社してからやったこととか
- とりあえずリリース
- アプリのフルKotlin化。1.0になったタイミングでえいやーでコンバート。
- スマートUIな作りからレイヤードアーキテクチャを意識した作りにして、レイヤーごとの責務を分割。
- Model層にDDDの概念を取り入れてみた。
- これは途中の方針転換でアプリ側で保持する情報が少なくなって、DDDが解決すべきドメインの複雑さは無くなったので元に戻した。
- DIを取り入れてテストの土台を作った。テストも少しずつ増やしていっている。
振り返り
- 新規サービス開発担当ということで、割と制約なく自由にアプリをいじくれたのはよかったなと感じました。特に設計周りについてはダイナミックに試して壊してを繰り返して、実際に手を動かしながら学ぶことができたのはよかったですね。
- けんすうさんを始めとして今まで一緒に働いたことのないタイプの人達と働いているので色々学びが大きいなと感じてます。特にサービスの立ち上げやデザイン部分は新鮮。
- Androidに集中して色々試せた分、Webの開発からはだいぶ離れてしまったので、ちょっとどうにかしたいなと思ってます。とはいうもののAndroidでやりたいこと多いしSwiftも始めてるのでそっちも置いて行きたくないし悩ましい。
転職とかキャリアのこととか
新卒でSIerに就職した頃は、エンジニア35歳定年説とか、プログラマは下級戦士だから早く卒業して設計書書いてマネジメントする人にならないと、とか色々いわれたような気がしますけど、なんだかんだ手を動かしてコードを書き続けて今に至りますね。Web業界で働くようになってからはそういう話も聞かなくなりましたけど、思うに業界自体が若いからキャリアパスみたいなものはまだないんでしょうね。なので自分のキャリアは自分で意識してかないとなと思います。
前職は特に不満があって辞めたわけではありませんが、5年強も働いてると、サービスは成長してるけど、今の自分の実力は他の会社でも通用するのかな、業界的にはどうなのかなとか思うようになってしまって、ある時ちょっと試してみるか!という勢いで転職決めて今に至ります。いい会社だと思うので、何かのタイミングが合えばまた働いてみたいとも思いますな。
おわり
- 人生の最終目標はVRでRPGの世界で生きることなので、こつこつがんばります。
We are Hiring的なやつ
適当な就職活動からSEになった話
今、一緒に働いているインターンの子が就活で頑張っていて、自分の時はどうやってたっけ?と、ふと思ったのでちょっと書いてみます。参考になるかは謎。
当時の大学生活
当時がどれくらいかというと2006年ですね。法学部生でしたが、ゼミにも入らず、サークルにも入らず、バイトとゲームばっかりしてました。おおよそ大学生っぽいことをした記憶がない。。。 ゲームはFF11というオンラインゲームにはまってましたね。昼は大学行って、終わったらバイトして家帰ったらヴァナ・ディールに精神を移動するという生活でした。
※サービス終了すると聞いた気がしたけど、まだサーバーは動いてるのかな?
4年の春から就職活動開始
そんな感じで過ごしてたので、就職活動を始めたものの割と適当でした。無難に合同説明会に行って新卒向けの採用サイトに登録してSPIちょろっと勉強して説明会に行ってーという一般的な就職活動です。その時は大手家電量販店の内定をもらって、俺の就活終わった、あとは今まで通り遊んで過ごそう!という気持ちでした。
ちなみにそこを選んだ理由は、当時やってたバイトが古本屋での本やゲームの買取やら販売やらだったので、その延長線で接客業でいいかみたいな理由です。
転機となる内定者向けのバイト経験
その時に受かった会社の制度で、内定者が実際の店舗で入社前にアルバイトとできる制度があって、長らく務めていた古本屋のバイトを辞めてそれに応募しました。そこは確か2ヶ月くらい働いた気がします。
で、ある時ふと気づいてしまうんですね。自分には向いてないというか、バイトとしてなら続けられるけど社員としてこの仕事で本当に自分は続けられるのかな?と。そう思い始めたら、行動は早くて内定辞退という形でまた就職活動に舞い戻りました。
初めて自分と向き合う
自己分析も対してしてこなかったので、自分がやりたいことなんだろなと初めて真面目に向き合いました。そして出た答えは 何かを作る仕事にしよう
でした。もともと昔からプラモデルとか模型とか何か作るのが好きだったんですよね。大学時代もちょいちょい作ってました。
その中でもIT業界を選んだ理由は、まだ若い業界でこの先も伸び続けるだろうという予想と、大学の授業でかじったIT系の授業が面白かったからですね。そこで、初めてシステムエンジニアという職業を知りました。
そしたらSIerの会社に受かった
プログラミングは全くの未経験だったので、研修が手厚いところを基準に選びました。で、受かったところは大手の独立系のSIerで、入社前からそこの会社の研修施設でC言語とJavaを中心に勉強しました。ちなみにそこの会社はもうなくなってしまったので業界の移ろいを感じますね。。。
そこで初めてコード書いてコンパイルして動かすという経験をしたわけですが、思いのほか楽しかったんですよね。法学部なのに対して勉強もしてこなかったけど、プログラミングの勉強はすごい面白いと感じました。これなら自分に向いてるし続けられそうだなとも思い、そのままそこに入社しました。
その後はSIer→自社のWebサービス開発→Androidアプリ開発という感じでキャリアをつんで3社目の今に至りますね。
仕事は長く続けられそうなものを探したほうがいいんじゃないか
30歳超えておじさんになったなあと思うわけですが、当時の就職活動始めた僕にアドバイスするとしたら、長く続けられそうなことを仕事にしたほうがいいぞ、ということを伝えたいです。
就職することはゴールではなく社会人としてのスタートなわけで、いい会社に入ることは決してゴールではないんですよね。入った後に何をするかというのをイメージすると後々につながるんじゃないかなあと思いました。
下の記事はすごい共感できるので一読しておくと就活生の参考になるかもしれません。
エンジニア立ち居振舞い : オンラインコミュニケーションに少しの気遣い
お題「エンジニア立ち居振舞い」 ということで普段自分が気をつけてることでも書いてみます。
今でこそコミュニケーションはSlackやQiita Teamなどのツールを使い、レビューはGitHubの上でPull Requestを送ってそこにコメントするという開発スタイルになっていますが、自分のエンジニア歴の中ではここ3年くらいの出来事です。
そこで昔を振り返って今と比べてみると、開発を進める上で、オンラインでのテキストコミュニケーションが格段に増えたと思うんですよね。すぐ近くの席にいるのにSlack上で盛り上がったりするのもよくある光景になりました。
で、気づいたことがあって、テキストのコメントは不思議と受け取る人によってはキツめにとられることがあるんですよね。実体験としては、ある問題に対してチャット上ですごい厳しいことを言っている人がいて、このままだと荒れそうだから直接話に行ったら意外と揉めなくて、アレ?というような感じのことがありました。
これってすごくもったいないことだと思うんですよね。チームにJoinしたばかりの人とかはこういうシーンをチャットでみると、あの人は怖そうな人だなって印象がついてしまって、コミュニケーションを取る時に無駄に身構えてしまったりします。もちろん正しいことを正しいと伝えるのは大事ですが、伝え方次第では受け手も素直に受け取れなくて無駄なコミュニケーションコストが発生します。
なので自分としては投げるコメントには、より気を使うということを心がけています。特に大事なシーンで自分の意見を言う時に、誰かを非難するように捉えられないか、何かを否定しているように捉えられないかというのを、一呼吸おいて見返してから書き込むようにしています。
ツールが発達して手軽にコミュニケーションができるようになったからこそ、HRTを忘れずに相手のことも考えたコミュニケーションを取るようにして、チームの良い雰囲気を育てていきたいですね。
おんやど恵で開発合宿してきた日記。開発 + 温泉 is 最高
会社の同僚と開発合宿するぞ!という話になったので、おんやど恵さんの開発合宿プランを使って4人で行ってきました。今回は会社のイベントではなくて個人でアプリ作ったりサービス作ったりしてる人たちの集いで、各々自分の作りたいものを普段とは違うところで作るぞ!という趣旨です。
事前の情報でこちらの旅館を選択した理由としては以下のポイントで選びました。
- 既に複数のIT企業が利用している実績がある。有名なところだと↓の記事など。
- 24時間使える会議室がある。wifi、電源タップ等、開発に必要な設備が用意されている。
- 都心からアクセスが良く交通費が安い。宿泊費用も食事付で一泊1万円からという手頃な価格帯。
- 補足としてこのプランは会議室料が1日 20,000円、連泊の場合は2日目以降は10,000円が宿泊料とは別にかかるので、人数が多い方が一人あたりの会議室代が安くなってお得。
- 温泉がある。寒くなってきたので嬉しい。
合宿の様子
- 日程は10/29(土)〜10/31(月)にかけて予約しました。月曜は平日なので各自有給か午前半休で解散後に会社行って少し仕事するという感じです。
1日目
- 湯河原に14:00集合です。
- 駅から旅館までは歩いてもいけますが、そこそこ遠いのでタクシーで向かいました。
- あっというまに到着
この時点で14時少し過ぎたくらいです。普通に宿に泊まる場合は一番早いチェックイン時間より前なのでまだ部屋の用意ができていないのですが、開発合宿プランの場合は開発用の会議室が使えるのでそちらに案内されます。
ご飯の時間や館内の利用ルールなど確認して早速開発開始
- 会議室はwifiと電源タップは完備で利用料金に含まれています。このあたりはうれしいですね。広さ的には8人から10人くらいまでの規模ならゆったり使えるんじゃないでしょうか。
別料金ですが、カップ麺やお菓子なども会議室に用意されています。
18:30まで各々開発して初日の夕飯に向かいます。
- 質、量ともに十分なご飯でした。むしろ食い過ぎた。。。
- 初日は日本シリーズの6戦がやっていたので食後の休憩がてらみんなで観戦。
各自、温泉に行ったり会議室に戻って開発を続けたり思い思いの時間を過ごします。僕は温泉に行ってから開発に戻りました。
温泉の写真なかったので旅館のサイトから紹介
- この日は夜中の2時から3時くらいまで開発して就寝。進捗は出たのでしょうか。
2日目
- 朝食の時間が8時だったので間に合うように起床。健康的な朝食をいただきます。
- この日はフルに開発に使える日なのでみんなもくもく開発。なのであまり写真がありません。。。
- お昼を食べに出かけたときに見かけた猫など。野良猫が多いのかやたら猫をみかけました。
- 夕飯のお肉。おいしかったです。
3日目
- 11:00チェックアウトなので朝食をいただいた後に追い込みの開発です。
休憩で足湯入ると気持ちよくていいですね。
10時になったら各々成果発表です。
4人なのでプロジェクターは使用しないで、合宿で作ったプロダクトをワイワイしながら近くで見るという感じでした。
メンバーの中にはリリースまで完了した人もいました。僕は完成はしなかったので動くところまでを見せるという感じ。
- 発表が終わったら後片付けをしてチェックアウトです。あっという間の3日間でした。
まとめ
- 何回か開発合宿は実施していますが、今回はかなり満足度が高かったです。貸切な環境ではないもののネットの使える会議室での作業はかなり集中できました。温泉も食事も満足。
- 費用的なところだと今回はモニタープランでの予約でしたので通常料金からの割引がきいて、かなりお得に使えました。
- 若干、困ったこととしては以下の点
- 会議室が少し寒めでした。会議室のエアコンが宴会場と一緒に集中管理されているので暖房が使えなく、フロントに問い合わせてヒーターを持ってきてもらいました。
- The 温泉街という立地だったので、昼飯を食べに外に繰り出したときに選択肢が少なくて結構さまよいました。素直に出前にするか、最寄りのコンビニで調達してくればよかったなという印象です。コンビニも片道歩いて15分くらいのところにあるのでチェックインするときにある程度買い込んでいくのかいいかもです。
開発合宿やオフサイトミーティングを検討中の方はぜひ選択肢の一つとして検討してみてはいかがでしょうか。
今回利用したモニタープランの案内はこちらになります。
- 宿本体はこちら
AndroidでEspresso + Spoonでキャプチャを撮りつつUIテストを走らせる
Android実機上でのテストにはEspressoというテスティングフレームワークを使いますが、Spoonというライブラリと組み合わせることでUIテスト実行時のキャプチャを残すことができます。導入してみたのでメモになります。
導入
Espressoは導入済の状態からSpoonを入れます。この記事を書いている時点でのバージョンは以下の通りです。
- Espresso : 2.2.2
- Spoon : 1.6.4
プロジェクトのbuild.gradleにプラグインを追加。
buildscript { dependencies { ... classpath 'com.stanfy.spoon:spoon-gradle-plugin:1.2.2' } }
- アプリケーションのbuild.gradleにライブラリを追加。プラグインも有効にしておく。
apply plugin: 'spoon' spoon { // for debug output debug = true // To execute the tests device by device */ sequential = true // To grant permissions to Android M >= devices */ grantAllPermissions = true } dependencies { ... androidTestCompile group: 'com.squareup.spoon', name: 'spoon-client', version: '1.6.4' }
- これで準備はできました。
キャプチャを撮る
- テストメソッド内で
Spoon.screenshot
を呼ぶことで実行時のキャプチャを残せます
@Test fun testInitializedView() { // これでキャプチャを残せる Spoon.screenshot(activityRule.activity, "initial_state") onView(withId(R.id.fragment_initialize_edit_hatena_id)) .perform(closeSoftKeyboard(), scrollTo()) .check(matches(isDisplayed())) onView(withId(R.id.fragment_initialize_button_set_hatena_id)) .perform(scrollTo()) .check(matches(isDisplayed())) .check(matches(not(isEnabled()))) onView(withId(R.id.fab)) .check(matches(not(isDisplayed()))) }
- 実行するときはGradleのタスクとして実行します。他のテストタスクと同じように
spoonDebug
のようにフレーバーをつけることも可能です。
./gradlew spoon
実行結果
- 実行結果は通常のEspressoの実行結果とは異なり
app/build/spoon
の下に格納されます。実行されると以下のように表示されます。 - 画像にはメソッド呼び出し時のタグが振られているのでどの時点のキャプチャかわかるようになっています。
コード
- こちらが個人のアプリに導入した時のプルリクになります。参考までに。
add Spoon plugin by rei-m · Pull Request #219 · rei-m/HBFav_material · GitHub
最近の個人的なAndroidの設計とかテスト周りとかまとめ
- 最近、Androidの設計やらテストの書き方やらを試行錯誤していて、ちょっと情報が散らばってきたので個人的なまとめです。これが絶対的にイケてる!とかじゃなくて単にいろんな人のスライド読んだり、自分で試したりして今こんな感じになったというレベルのものです。
- コードは↓で作ったアプリをベースにいじってます。。Kotlin製のアプリなのでJavaに適時読み替えてください。
設計の話
設計の目標はモジュールの責務を分割して将来の変更に強くするという感じです。Androidの場合は特に適当に作るとActivityとFragmentが膨れ上がってメンテつらい作りになりがちです。で、去年に書いた記事(Androidのデザインパターンを考えてみたの続き。Kotlin対応版。 - もやもやエンジニア)とかを経て、いまのところはMVPな構成にしてModel層をDDDライクに作るようにしてます。先日のAndroidオールスターズ2でもid:kgmyshin 氏が同様の話をしてましたが、自分も近いものを目標に作ってます。
雑な絵だとこんな感じです。Activity/FragmentはViewの操作に集中して、ドメインロジックはModel層に、Presenterが両者を仲介するというふいんきですね。
実際に作った例
- 起動時にIDを設定する画面(InitializeFragment)を例にとってみます。この画面は入力されたIDが有効か問い合わせて有効なら端末に保存して次の画面に行くという仕様です。
- まずはPresenterとViewをつなぐInterfaceです。ViewはFragmentに実装してActionsはPresenterに実装させます。
interface InitializeContact { interface View { fun showNetworkErrorMessage() fun showProgress() fun hideProgress() fun displayInvalidUserIdMessage() fun navigateToMain() } interface Actions { fun onCreate(view: InitializeContact.View) fun onResume() fun onPause() fun onClickButtonSetId(userId: String) } }
- 次にFragmentです。ライフサイクルやユーザーの操作で発生したイベントをPresenterに伝えるのと、上で定義したInterfaceを実装してViewの操作をするメソッドを生やしてます。
- Presenterはテスト時に差し替えできるようにDagger経由で注入できるようにしておきます。Model層の操作はここでは一切出てきません。
class InitializeFragment() : BaseFragment(), InitializeContact.View, ProgressDialogController { companion object { fun newInstance(): InitializeFragment = InitializeFragment() } @Inject lateinit var navigator: ActivityNavigator @Inject lateinit var presenter: InitializeContact.Actions override var progressDialog: ProgressDialog? = null private var subscription: CompositeSubscription? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) component.inject(this) presenter.onCreate(this) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { subscription = CompositeSubscription() val view = inflater.inflate(R.layout.fragment_initialize, container, false) val editId = view.findViewById(R.id.fragment_initialize_edit_hatena_id) as AppCompatEditText val buttonSetId = view.findViewById(R.id.fragment_initialize_button_set_hatena_id) as AppCompatButton buttonSetId.setOnClickListener { presenter.onClickButtonSetId(editId.editableText.toString()) } subscription?.add(RxTextView.textChanges(editId) .map { v -> 0 < v.length } .subscribe { isEnabled -> buttonSetId.isEnabled = isEnabled }) return view } override fun onResume() { super.onResume() presenter.onResume() } override fun onPause() { super.onPause() presenter.onPause() } override fun onDestroyView() { super.onDestroyView() subscription?.unsubscribe() subscription = null } override fun showNetworkErrorMessage() { with(activity as AppCompatActivity) { val rootView = findViewById(R.id.fragment_initialize_layout_root) hideKeyBoard(rootView) showSnackbarNetworkError(rootView) } } override fun showProgress() { showProgressDialog(activity) } override fun hideProgress() { closeProgressDialog() } override fun displayInvalidUserIdMessage() { view?.findViewById(R.id.fragment_initialize_layout_hatena_id)?.let { it as TextInputLayout it.error = getString(R.string.message_error_input_user_id) } } override fun navigateToMain() { navigator.navigateToMain(activity) activity.finish() } }
- 最後にView層から受け取ったイベントとModel層の操作を仲介するPresenterです。Presenterが依存するModel層のモジュールはコンストラクタで受け取るようにしています。テスト時にはモックにするなりして差し替えます。
class InitializePresenter(private val userRepository: UserRepository, private val userService: UserService) : InitializeContact.Actions { private lateinit var view: InitializeContact.View private var subscription: CompositeSubscription? = null private var isLoading = false override fun onCreate(view: InitializeContact.View) { this.view = view val userEntity = userRepository.resolve() if (userEntity.isCompleteSetting) { view.navigateToMain() } } override fun onResume() { subscription = CompositeSubscription() } override fun onPause() { subscription?.unsubscribe() subscription = null } override fun onClickButtonSetId(userId: String) { if (isLoading) return isLoading = true view.showProgress() subscription?.add(userService.confirmExistingUserId(userId) .doOnUnsubscribe { isLoading = false view.hideProgress() } .onBackpressureBuffer() .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ onConfirmExistingUserIdSuccess(it, userId) }, { onConfirmExistingUserIdFailure(it) })) } private fun onConfirmExistingUserIdSuccess(isValid: Boolean, userId: String) { if (isValid) { userRepository.store(UserEntity(userId)) view.navigateToMain() } else { view.displayInvalidUserIdMessage() } } private fun onConfirmExistingUserIdFailure(e: Throwable) { if (e is HttpException) { if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) { view.displayInvalidUserIdMessage() return } } view.showNetworkErrorMessage() } }
- これでFragmentはViewの操作のにのみ集中出来るようになって見通しがよくなりました。ViewはDatabindingを使うとよりスッキリするかと思います。
テストの話
Viewのテスト
- Viewは本来はEspressoを使ってエミュレーターなり実機なりの上でテストを動かす必要がありますが、Robolectric を使うと端末を使わずにテストができます。
- 事前にテスト用のアプリケーションクラスを作成してViewが依存するPresenterを差し替えておきます。全体のコードはGitHubを見た方が早いと思うので差し替えの部分だけ紹介。Test用のComponetを作ってファクトリメソッドをオーバーライドして差し替えます。
open class TestApp : App() { override fun createApplicationComponent(): ApplicationComponent { return DaggerTestApplicationComponent.builder() .applicationModule(ApplicationModule(this)) .infraLayerModule(InfraLayerModule()) .build() } }
- こちらが実際のRobolectricを使ったテストケースです。Configで上で作ったテスト用のアプリケーションを指定することで、テスト対象のFragmentが依存するPresenterを差し替えて、Model層の操作を行わないようにして、実装したViewのメソッドが正しく実装されているかのみを検証します。
- 余談ですがKotlinはMockitoのwhenやらhamcrestのisやらが予約語になってるのでバッククォートで囲む必要があってちょっとめんどくさい。。。
@RunWith(RobolectricTestRunner::class) @Config(constants = BuildConfig::class, application = TestApp::class, sdk = intArrayOf(Build.VERSION_CODES.LOLLIPOP)) class InitializeFragmentTest { lateinit var fragment: InitializeFragment private val view: View by lazy { fragment.view ?: throw IllegalStateException("fragment's view is Null") } private val editHatenaId: EditText get() = view.findViewById(R.id.fragment_initialize_edit_hatena_id) as EditText private val buttonSetHatenaId: Button get() = view.findViewById(R.id.fragment_initialize_button_set_hatena_id) as Button private val textInputLayoutHatenaId: TextInputLayout get() = view.findViewById(R.id.fragment_initialize_layout_hatena_id) as TextInputLayout private val snackbarTextView: TextView get() = fragment.activity.findViewById(android.support.design.R.id.snackbar_text) as TextView private fun getString(resId: Int): String { return fragment.getString(resId) } @Before fun setUp() { fragment = InitializeFragment.newInstance() SupportFragmentTestUtil.startFragment(fragment, SplashActivity::class.java) } @Test fun initialize() { assertThat(editHatenaId.visibility, `is`(View.VISIBLE)) assertThat(buttonSetHatenaId.visibility, `is`(View.VISIBLE)) assertThat(buttonSetHatenaId.isEnabled, `is`(false)) } @Test fun testButtonSetHatenaIdStatus_input_id() { editHatenaId.setText("a") assertThat(buttonSetHatenaId.isEnabled, `is`(true)) } @Test fun testButtonSetHatenaIdStatus_not_input_id() { editHatenaId.setText("") assertThat(buttonSetHatenaId.isEnabled, `is`(false)) } @Test fun testButtonSetHatenaIdClick() { val presenter = mock(InitializeContact.Actions::class.java) doAnswer { Unit }.`when`(presenter).onClickButtonSetId("valid") fragment.presenter = presenter editHatenaId.setText("valid") buttonSetHatenaId.performClick() verify(presenter).onClickButtonSetId("valid") } @Test fun testShowNetworkErrorMessage() { fragment.showNetworkErrorMessage() assertThat(snackbarTextView.visibility, `is`(View.VISIBLE)) assertThat(snackbarTextView.text.toString(), `is`(getString(R.string.message_error_network))) } @Test fun testDisplayInvalidUserIdMessage() { fragment.displayInvalidUserIdMessage() assertThat(textInputLayoutHatenaId.error.toString(), `is`(getString(R.string.message_error_input_user_id))) } @Test fun testShowHideProgress() { fragment.showProgress() assertThat(fragment.progressDialog?.isShowing, `is`(true)) fragment.hideProgress() assertNull(fragment.progressDialog) } @Test fun testNavigateToMain() { val navigator = spy(fragment.navigator) doAnswer { Unit }.`when`(navigator).navigateToMain(fragment.activity) fragment.navigator = navigator fragment.navigateToMain() verify(navigator).navigateToMain(fragment.activity) } }
Presenterのテスト
- PresenterはAndroidの実装に依存しない形になっているのでピュアなJUnitのテストになります。Contextが必要な場合などはMockitoを使ってContextのMockを作ってあげればいいかなと思います。
- setUpでモックのViewのメソッドを空実装するのと、RxAndroidのスケジューラーを使った処理がすぐ返るように設定しています。テストの検証はMockのViewのメソッドが呼ばれたかどうかで判定するようにして、その先で何が起きるかはPresenterは関心を持ちません。
- モデル層はMockitoを使ってテスト時に必要な処理を実装しています。
@RunWith(MockitoJUnitRunner::class) class InitializePresenterTest { @Mock lateinit var userRepository: UserRepository @Mock lateinit var userService: UserService @Mock lateinit var view: InitializeContact.View @Before fun setUp() { doAnswer { Unit }.`when`(view).navigateToMain() doAnswer { Unit }.`when`(view).showProgress() doAnswer { Unit }.`when`(view).hideProgress() doAnswer { Unit }.`when`(view).displayInvalidUserIdMessage() doAnswer { Unit }.`when`(view).showNetworkErrorMessage() RxAndroidPlugins.getInstance().reset() RxAndroidPlugins.getInstance().registerSchedulersHook(object : RxAndroidSchedulersHook() { override fun getMainThreadScheduler(): Scheduler? { return Schedulers.immediate() } }) } @After fun tearDown() { RxAndroidPlugins.getInstance().reset() } @Test fun testOnCreate_initialize_not_complete_register_user() { `when`(userRepository.resolve()).thenReturn(UserEntity("")) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) verify(view, never()).navigateToMain() } @Test fun testOnCreate_initialize_complete_register_user() { `when`(userRepository.resolve()).thenReturn(UserEntity("test")) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) verify(view).navigateToMain() } @Test fun testOnClickButtonSetId_success_check_id() { `when`(userRepository.resolve()).thenReturn(UserEntity("")) doAnswer { Unit }.`when`(userRepository).store(UserEntity("success")) `when`(userService.confirmExistingUserId("success")).thenReturn(Observable.just(true)) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) presenter.onResume() presenter.onClickButtonSetId("success") verify(userRepository, timeout(TimeUnit.SECONDS.toMillis(1))).store(UserEntity("success")) verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).showProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).hideProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).navigateToMain() } @Test fun testOnClickButtonSetId_fail_check_id() { `when`(userRepository.resolve()).thenReturn(UserEntity("")) `when`(userService.confirmExistingUserId("fail")).thenReturn(Observable.just(false)) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) presenter.onResume() presenter.onClickButtonSetId("fail") verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).showProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).hideProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).displayInvalidUserIdMessage() } @Test fun testOnClickButtonSetId_fail_check_id_404() { `when`(userRepository.resolve()).thenReturn(UserEntity("")) `when`(userService.confirmExistingUserId("fail")) .thenReturn(Observable.error(HttpException(Response.error<HttpException>(HttpURLConnection.HTTP_NOT_FOUND, ResponseBody.create(MediaType.parse("text/html"), ""))))) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) presenter.onResume() presenter.onClickButtonSetId("fail") verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).showProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).hideProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).displayInvalidUserIdMessage() } @Test fun testOnClickButtonSetId_fail_check_id_network_error() { `when`(userRepository.resolve()).thenReturn(UserEntity("")) `when`(userService.confirmExistingUserId("fail")) .thenReturn(Observable.error(HttpException(Response.error<HttpException>(HttpURLConnection.HTTP_INTERNAL_ERROR, ResponseBody.create(MediaType.parse("text/html"), ""))))) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) presenter.onResume() presenter.onClickButtonSetId("fail") verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).showProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).hideProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).showNetworkErrorMessage() } }
- これでgradleのtestタスクでUnitTestが回るようになるのでCircleCIなどの上で動かすようにしてあげれば継続的にテストを回せる仕組みができます。