visible true

技術的なメモを書く

Next.js全部読む 3 (~2.0.0-beta.16)

Add AOT gzip content-encoding support for main build files.

2016/12/30 https://github.com/vercel/next.js/commit/29c226771ce8b5b26632c8e7753e69f7407933b4

AOTってなんだ?と思って引っかかった。 Ahead of Timeの略で、実行前に処理をするという意味のようでした。 差分を見ると、build時にassetsをgzipにするほか、main.js、common.jsをレンダリングのタイミングでAcceptヘッダを見てgzipにしています。

Prevent prefetcher from making identical requests.

2017/01/05 https://github.com/vercel/next.js/commit/e38cacc4c25ec91764ef00bc32936f8108a93a65

prefetchの際に、変更前まではロードが完了してからキャッシュの有無のフラグを立てていた。 Map<string, boolean>でURLをキーにプリフェッチの状態を管理していたのを、 Map<string, Promise>にすることで、二重でロードするのを防ぐようにした。

bypass SSE on Service Worker

2017/01/07 https://github.com/vercel/next.js/commit/0551dc90a14d4beda8dc64058c4ae2837cc61ec6

Service Workerでfetchする際に、リクエストがSSEを期待している場合は実行しないように。 prefetchをスクラッチしている感じがして面白い。

Implement "Immutable build artifacts" feature

2017/01/12 https://github.com/vercel/next.js/commit/b7e57f934740bd1cfb6c0a9c797b8943eb12ca49

Cache-Control: immutableという新しいCache-Controlの仕様をサポート。 immutableは2017年9月に正式にRFCになったらしい。

tex2e.github.io

ビルド時にUUIDを発行し、prefetchやrouterで画面遷移の際に取得するjsonデータなどのパスで利用します。 これによってすでにキャッシュ済みのコンテンツに対してはリクエストを行わないようになります。 もともとFacebookがリロードでの304の大量発生を防ぐために考案したものだそうです。

bitsup.blogspot.com

なるほどこんな仕様があるのだな〜と面白かった。

まとめ

2.0.0-betaは42まであってすごく長い

Next.js全部読む 2 (~2.0.0-beta.0)

Custom Document Support

2016/12/17 https://github.com/vercel/next.js/commit/8ddafaea5c1544f713bb0118b78055585b37e1e3#diff-dc3c27fcfe133615dd2b43df0b2da03e1ed23a7a03d17e86ae20f73ef20bc697R20

pages/_ducument.jsにファイルを置くとそっちを使うように。babelでビルドするタイミングでファイルの存在を見ています。 この変更まではデフォルトのDocumentコンポーネントしか使えず、CSSnext/css一択だったようです。 カスタムした_ducument.jsを使うことで、任意のCSSを使えるようになりました。

Programmatic API

2016/12/17 https://github.com/vercel/next.js/commit/1708222381788060e79de8f343ae2d8670865380

next.js/server/index.jsのServerクラスをexportし、それを用いて任意のNodeアプリケーションを実装できるようになりました。

元々のnext startも以下のようにnodeでServerを起動しているので、このあたりの挙動に手を入れたい場合にカスタムできるようになりました。

const srv = new Server({ dir })
srv.start(argv.port)
.then(() => {
  if (!process.env.NOW) {
    console.log(`> Ready on http://localhost:${argv.port}`)
  }
})
.catch((err) => {
  console.error(err)
  process.exit(1)
})

Implement the Singleton Router API

2016/12/19 https://github.com/vercel/next.js/commit/22776c2eeeca2696b78f38f067aa90af8472a9e1

next/routerがexportされ、アプリケーション側で使えるようになりました。現在だとuseRouterが一般的ですが、このころはimportしてRouter.push()とかいう風に書きます。

その他

現在だと_documentとかrouterとか普通に使ってるけど最初は無かったんだよな〜と。当たり前ではあるんだけど、歴史的経緯みたいな感じがして面白い。

Next.js全部読む 1

2024年の年始にNext.jsのコードベースを読むことにしました。その際に得た知識をアウトプットすることを考えていましたが、初めはどのように読み進めたらよいのか迷い、なかなか整理することができませんでした。最近になって「最初のコミットから差分をすべて読んでいく」という方法が自分には馴染むと分かってきました。そういうわけで特に興味深いと感じたコミットについて、メモを取っていくことにしました。

コミット差分はSourceTreeを使って追いかけています。ソースコード全体はcloneしたrepositoryをIntelliJで開いて読んでいます。

気を抜くとどんどんcanaryが進んでいく

最初のコミット

記念すべき最初のコミットは2016年10月。Readme.mdのみのコミット。SSRのための軽量なフレームワークとして誕生したようです。Pages routerも目玉だったぽい。

記念すべき最初のコミット

~1.2まで省略

Next.jsの基本構造を学ぶ上で興味深い内容が沢山ありましたが、全部書いていくのは大変なので割愛。初期のSSRやハイドレーションの仕組みなどがわかります。この頃はReact Hooksも無く、JavaScriptを前提としていて、結構読むのは大変。CSS関連も現在は使ってない仕組みを利用しているのであまり深入りしても得るものは少ないかも。

prefetchサポート (~v2.0.0)

2016/12/16のコミット。 github.com

prefetchを行うLinkコンポーネントと、prefetchを明示的に行う関数を追加しています。Service Workerを用いて、prefetch対象のURLをCacheStorageに事前に保存します。

Next.jsはSSRを行った際に、ハイドレーションのためのJSONデータをページに埋め込んでいて、クライアントサイドでそのデータを取り出して使っています。画面遷移の際はnext/routerがページのJSONデータを取り出すAPI*1にXHRでリクエストして、取得したJSONを使って再レンダリングをしています。

prefetchではLinkコンポーネントレンダリングしたタイミングで、遷移先のJSONデータを取りに行ってCacheStorageに保存しています。XHRでリクエストするとService Workerのfetchイベントがトリガーされ、そこでキャッシュがあればそれを利用するという仕組みになっているので、next/routerがページ遷移する際に、prefetchしたキャッシュを使うといった動作になるようです。

こんな感じで

面白かったコミットをメモっていきます。ペース的に最新に追いつけるのかちょっと不安ですが、古すぎるものはできるだけスキップできればと思っています。

*1:pagesに対して自動で生える

DoroidKaigi conference app 2023を読んでいく 1

DoroidKaigi 2023の公式アプリのリポジトリが公開されましたね。 github.com

Androidアプリケーション開発から離れて久しいので、まずは最新のトレンドや技術について学ぶために、このプロジェクトを使って実際に手を動かしながら、気になる部分を調べてメモしていくことにしました。コントリビュートはできそうならやろうかなと考えています。

build-logicってなに

プロジェクトをAndroid Studioで開き、ビルドして動かしつつプロジェクト内を眺めると、何やら見慣れないモジュールが。

app-iosとapp-androidというモジュールから、プロジェクト全体がKotlin Multiplatformぽいことは推測できます。また、core, featureは共通のロジックやUIとかだろうな〜とも推測できます。ではbuild-logicとは一体なんなのか?

中身をチラッと眺めてもよくわかりませんでした。いっぱい書いてて大変やな〜というのが第一印象。

build-logicはGradleのComposing buildsを用いた共通のビルド設定

まずは根本から辿っていこう、ということで、Project rootのbuild.gradle.ktssettings.gradle.kts を読みました。するとsettings.gradle.kts に次のような記述がありました

https://github.com/DroidKaigi/conference-app-2023/blob/main/settings.gradle.kts#L2

pluginManagement {
    includeBuild("build-logic")
    ...
}

includeBuildってなんだろう?と調べてみるとComposing buildsというページが見つかりました。

docs.gradle.org

includeBuildに指定したmoduleの設定を取り込めるもの、と理解しました。詳細を見るとなんだか難しそうなので、conference-app-2023での使い方に着目してみると、次のようにpluginをregisterしている記述が見つかりました。

https://github.com/DroidKaigi/conference-app-2023/blob/main/build-logic/build.gradle.kts#L35

register("androidApplication") {
    id = "droidkaigi.primitive.androidapplication"
    implementationClass = "io.github.droidkaigi.confsched2023.primitive.AndroidApplicationPlugin"
}
register("android") {
    id = "droidkaigi.primitive.android"
    implementationClass = "io.github.droidkaigi.confsched2023.primitive.AndroidPlugin"
}

idはpluginIdで、implementationClassはpluginの動作を記述しているクラスのFQCNです。対応するクラスの実装を見てみると、build.gradleに記述しそうなことが書いてあります。

例: https://github.com/DroidKaigi/conference-app-2023/blob/main/build-logic/src/main/kotlin/io/github/droidkaigi/confsched2023/primitive/AndroidComposePlugin.kt

Jetpack ComposeやDagger Hiltなんかは結構色んな設定を書くので、各種モジュールに毎回宣言するのは大変ですもんね。pluginとしてまとめて、モジュール側でpluginをapplyする形にすることで、共通化をしているようです。マルチモジュールでは必須の仕組みと言えそうですね。

ちょっとした気になり

build-logicは、プロジェクト全体に、configをまとめたplugin群を定義する仕組みと分かりました。実際にfeature moduleを覗いてみると、pluginをapplyしています。

https://github.com/DroidKaigi/conference-app-2023/blob/main/feature/about/build.gradle.kts

plugins {
    id("droidkaigi.convention.androidfeature")
}

android.namespace = "io.github.droidkaigi.confsched2023.feature.about"

dependencies {
    implementation(projects.core.designsystem)
    implementation(projects.core.ui)
    implementation(projects.core.model)
    testImplementation(projects.core.testing)

    implementation(libs.androidxCoreKtx)
    implementation(libs.composeUi)
    implementation(libs.composeHiltNavigtation)
    implementation(libs.composeMaterial)
    implementation(libs.composeUiToolingPreview)
    implementation(libs.androidxLifecycleLifecycleRuntimeKtx)
    implementation(libs.androidxActivityActivityCompose)
    implementation(libs.composeMaterialIcon)
    androidTestImplementation(libs.composeUiTestJunit4)
    debugImplementation(libs.composeUiTooling)
    debugImplementation(libs.composeUiTestManifest)
}

droidkaigi.convention.androidfeatureにはandroidアプリに関するpluginがいくつかバンドルされています。さてそこで気になるのは、モジュールのdependenciesの記述です。droidkaigi.convention.androidfeatureにはJetpack ComposeやDagger Hilt、テスティングなどのdependenciesが含まれています。droidkaigi.convention.androidfeatureを利用するモジュールで再度dependenciesを宣言する必要は無い気がしますが、実際には宣言が書かれていました。

試しに重複しているdependenciesを除去して、クリーンビルドしたら、無事ビルドが成功しました。なんとなくrepository公開に向けてわーっと作ってこの辺りの重複除去はまだ特にやってない、ということかなと解釈しました。

libs.versions.tomlってなに

モジュールのdependenciesを見ると、

testImplementation(projects.core.testing)
implementation(libs.androidxCoreKtx)

といった具合にlibs.androidxCoreKtxという謎の変数を参照しています。これは一体どこから来るのか?

gradle/libs.versions.tomlというファイルを見つけました。中身はなんだかpackage.json.lockだとかGemfile.lockみたいな雰囲気。

conference-app-2023/gradle/libs.versions.toml at main · DroidKaigi/conference-app-2023 · GitHub

[versions]
androidGradlePlugin = "8.1.0"
# For updating Kotlin and Compose Compiler version, see:
# https://github.com/JetBrains/compose-multiplatform/blob/master/VERSIONING.md#kotlin-compatibility
# https://developer.android.com/jetpack/androidx/releases/compose-kotlin?#pre-release_kotlin_compatibility
kotlin = "1.9.0"

androidxCore = "1.10.1"
androidDesugarJdkLibs = "2.0.3"
compose = "1.5.0"
compose-jb = "1.4.3"
composeCompiler = "1.5.1"
...

これはGradleのバージョンカタログという機能のようです。 developer.android.com

build-logic/settings.gradle.ktsで当該tomlファイルを読み込む記述をしていて、これによりプロジェクト全体でバージョンカタログの変数を参照できるようになっているようです。

https://github.com/DroidKaigi/conference-app-2023/blob/main/build-logic/settings.gradle#L10

バージョンの変数宣言はとっ散らかる感じがあったので、こういった仕組みは便利ですね。

next

Gradleの設定やモジュール構成とKotlin Multiplatformの関係性がまだ理解しきれていません。次回はKotlin Multiplatformの構成について学び、各モジュールの関係について調べようと思います。

開発時の動作確認ツールとしてCypressのE2Eテストを導入した話

ユビーAI問診は、Ubieが提供する医療機関向けのプロダクトです。患者さんに対して問診を実施し、医師向けのカルテを作成します。現在は大きく分けて、タブレットスマートフォンの2つの利用方法があります。

f:id:sys1yagi:20220109133721p:plain
タブレット用、スマートフォン用の画面

これらはどちらもWebアプリケーションとして実装していて、フロントエンドはReact/TypeScriptで書いています。

問診のプロセスは画面遷移が多い

ユビーAI問診は紙の問診票で書くような定型的な質問だけでなく、来院した目的に合わせて様々な質問を行います

例えば「頭が痛い」といった症状を入力した場合、発症時期や部位、痛みの程度、持続時間、経過、頻度などを掘り下げて、更にそれらの回答内容から疑われる疾患に関連する質問を重ねていきます。あるいは「足をひねった」など外傷に関する場合は、スポーツをしていたかや事故かといった状況を聴取したりします。問診の長さは入力内容によって様々ですが、短くて10数回、長いと4、50回ほど画面遷移を行います

質問の種類は10数種程度なので、質問の表示の動作確認はStorybookなどを用いれば十分行えます。 しかし問診の回答結果に基づいて作成するカルテは、膨大なパターンの質問と回答の組み合わせがあるため、動作確認にはかなりの労力を必要とします。

f:id:sys1yagi:20220109150227j:plain
最初の数質問の分岐。今はもっと複雑に

CypressによるE2Eテストを開発時の動作確認用に導入する

Ubieでは基本的にひとつのフィーチャーをひとりのエンジニアが担当します*1。バックエンドとフロントエンドの両方を設計・実装したあと、全体の動作確認を行うわけですが、一回の問診は数分かかるのでトライアンドエラーが発生すると非常に時間がかかります。

そこでE2EテストフレームワークであるCypressを、開発時の動作確認用に導入することにしました。特定の問診フローを実行するテストを追加していくことで、開発時の動作確認を気軽に行えるようにしようという目論見です。

Cypressを選んだ深い理由は特になかったのですが、

  • 導入が簡単な点
  • 拡張が容易な点(テスト中にNode.jsで任意の処理を実行できる)
  • Cypress StudioというGUIアプリケーションで、テストの実行や管理が容易な点

などが気に入っています。

Cypressを導入する

Cypressの導入は非常にかんたんです。

yarn add -D cypress # あるいは npm install cypress --save-dev

次のコマンドでCypress Studioを起動できます。

yarn run cypress open # あるいは npx cypress open

初回の起動時にテストのための各種ファイルが生成されます。

cypress
├── fixtures
│   └── example.json
├── integration # テストをここに置く
│   ├── 1-getting-started
│   └── 2-advanced-examples
├── plugins
│   └── index.js
└── support
    ├── commands.js
    └── index.js

f:id:sys1yagi:20220109210900p:plain
Cypress Studio。integration配下のテストが一覧される

デフォルトはjsなので、TypeScriptにするための設定がちょこちょこ必要になります。 https://docs.cypress.io/guides/tooling/typescript-support

とりあえず動かす

Googleで'Cypress E2E'というキーワードで検索し、https://www.cypress.ioのページを開くテストをするとします。実装は次のとおりです。

describe('CypressをGoogleで検索する', () => {
  it('Cypress E2Eで検索するとヒットする', () => {
    // Googleを開く
    cy.visit('https://google.com');

    // input要素にキーワードを入力する
    cy.get('input')
        .first()
        .clear()
        .type('Cypress E2E{enter}');

    // Cypressのページタイトルを探して、クリック
    cy.get('h3')
      .contains('JavaScript End to End Testing Framework')
      .click();
  });
});

cyという特殊なオブジェクト以外は概ねJestのような書き口です

f:id:sys1yagi:20220109162555g:plain
上記のコードが動作する様子

開発環境でCypressを利用する

基本的には最初にvisitするページをlocalhostにすれば自分の環境で立ち上げたアプリケーションにアクセスするテストが書けます。

f:id:sys1yagi:20220109164059g:plain
開発環境でのログインのテストの様子

画面の操作をする他にアサーションも書けます。

// 画面上に'診察券がある'という文言のボタンが存在することを要求する
cy.get('button').contains('診察券がある').should('exist');

要素の状態のアサーション以外にも、Cookieの値やURL、API Callの内容の検証などについても行えます。

Introduction to Cypress | Cypress Documentation

プラグインを使ってテスト実行前にDB設定を整える

localhostに向けてテストを書くだけでは不十分です。医療機関にはいろいろな種類や設定があり、それぞれ動作が異なります。これらの設定を行うにはデータベースをセットアップしなければなりません

CypressのテストコードはChromeFirefoxなどの環境で動作するので、そこからデータベースを直接操作するといったことは基本的にはできません。バックエンド側に開発用のAPIを生やすことも考えられますが、そうすると複数の環境にテストのためのコードが散逸してしまうためできれば避けたいところです。

Cypressではプラグインを追加できます。プラグインはNode.jsで動作します。 デフォルトでtaskというプラグインがあり、ここでNode.jsで動作する任意の処理を追加できます。 https://docs.cypress.io/api/commands/task

plugins/index.tsでtaskイベントに対する処理を記述することで、

(module).exports = (on) => {
  on('task', {
    hello(message: string): string {
      // Node.jsで動作する
      const value = `hello ${message}!`;
      console.log(value);
      return value;
    }
  });
}

テストコードから呼び出せるようになります。

// ブラウザではなく、コマンドラインのほうに'hello world!'とログが出る
cy.task('hello', 'world'); 

ここでNode.js向けのデータベースクライアントを導入し、データをセットアップするtaskを追加することで、テスト実行時に必要な環境を整えられるようになります。

ユビーAI問診では各テーブル毎にCRUD操作をする関数を生やして、任意のデータを用意できるようにしています。TypeOrm を使い、typeorm-model-generatorで既存のテーブルからEntityを生成したので、比較的簡単に準備用のコード群ができました*2

このスタイルの場合、データベースを直接操作し、バックエンドAPIに対して実際にAPI Callをすることになるので、CIなどでのテスト実行は困難になります。少し迷いましたが、あくまで開発時に使うということで割り切ることにしました。

導入してよかった点

思ったよりメンテしやすい

動作確認を楽にするためとはいえ、メンテナンスが難しいと結局コストとしてどうなんだっけ?ということになりますが、Cypressのテストコードはかなりメンテナンスがしやすい印象です。Best Practices | Cypress Documentationを参考に再利用可能な関数を整頓していくと様々なバリエーションのテストを素早く増やしていけます。TypeScriptが使える点もありがたいです

experimentalですがCypress Studio上で操作を記録し、テストコードを生成する機能もあります。 Cypress Studio | Cypress Documentation この辺りは要素のセレクタなどを適切に準備する必要があるので気軽には使えないですが、テスタブルなコードを書く動機にもなって良いなと思います。

大胆な変更も安心できる

一連のシナリオを実行するテストを書けば、何度でも使えるので、そのシナリオ中に関連するコードを変更する際にリグレッションテストとして機能します。 問診のフローはかなり複雑に関連しあっているので、これまでは変更にかなり慎重に取り組まなければなりませんでしたが、テストが増えていくにつれて大胆な変更が可能になりました。

ドキュメントとしての価値もあった

実はCypressを導入したあとに気づいたのですが、各種テストをきちんと構造化するとドキュメントとしての価値もでてきました。現在カバーできている範囲は全体の数%にも満たないですが、それでも新たな実装に対してCypressのテストを追加することで、他の人に引き継いだり、時間が経過した後のキャッチアップなどが容易に行えます。 データのセットアップもコードで表現しているので、前提条件なども把握できるようになっており、CIでの実行を犠牲にした価値は十分あったかなと思います。

心残りな点

とはいえやはり、CIなどの三者による定期的な実行は諦めきれない要素です。どうしても人間が任意のタイミングで実行するだけでは漏れが生じるからです。この間も圧倒的に壊れていました。この辺りは別途対策を考えています。各リポジトリのdevelopブランチをデプロイする環境を持っておいて、変更が入るとデプロイ後にCypress Testをキックするなど、自前で用意することになりますが実現自体は可能なのではないかと思っています。

まとめ

E2Eテストというと結構重たいというか、大変そうなイメージがありましたが、開発時の動作確認ツールとして割り切ることで、かなり便利に使えることがわかりました。

ユビーAI問診は開発が始まってから時間が経っていて、ドメインの深さやコードの複雑さが新メンバーの負担になったり、あるいは古い人が離れられないといった問題がありました。CypressのE2Eテストがすべてを解決するわけではありませんが、今後もこうした取り組みを重ねて、壊れにくく、キャッチアップしやすく、手離れしやすい環境を作っていきたいと思っています。

そんなUbieでは新たなソフトウェアエンジニアを募集しています。最近はフロントエンド、バックエンドに特化したポジションが増えたりしていますので、昔見たな〜という方もぜひまた見てみてください。 recruit.ubie.life

*1:もちろん規模が大きい場合手分けする場合もあります

*2:prismaも検討しましたが、複数のDB接続を簡単にはできなそうだったので諦めました。https://github.com/prisma/prisma/issues/2443

rubyのslice!でめっちゃハマった

map!

各要素を順番にブロックに渡して評価し、その結果で要素を置き換えます。

https://docs.ruby-lang.org/ja/latest/class/Array.html#I_COLLECT--21

てことで、自分自身を書き換える。

array = [1,2,3,4,5]
array.map!{ |a| a * 2 }  # [2,4,6,8,10]になる

slice! も同じノリと思ったら、

array = [1,2,3,4,5]
array.slice!(0, 2)  # [1,2]になると思ったら...

指定した要素を自身から取り除き、取り除いた要素を返します。取り除く要素がなければ nil を返します。

https://docs.ruby-lang.org/ja/latest/class/Array.html#I_SLICE--21

てことで、

戻り値 = [1, 2] array = [3,4,5]

になった。めちゃくちゃ時間を溶かしました。 ドキュメントはちゃんと読まないといけませんね。

Junit5でJetpack ViewModelのviewModelScopeを使っている関数のテストをする

viewModelScopeを使っている関数を持つViewModel

次のRecommendBookViewModelクラスは、loadRecommendBooks関数という内部でviewModelScopeを用いて非同期処理を行う関数を持っている。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import hoge.fuga.recommendbook.RecommendBook
import hoge.fuga.recommendbook.RecommendBookRepository

data class ViewState(
  val loading: Boolean,
  val recommendBooks: List<RecommendBook>
)

class RecommendBookViewModel(
  private val recommendBookRepository: RecommendBookRepository
) : ViewModel() {

  private val _viewState = MutableStateFlow(ViewState(true, emptyList()))

  val viewState: StateFlow<ViewState> = _viewState

  fun loadRecommendBooks() {
    viewModelScope.launch {    
      val recommendBooks = recommendBookRepository.recommendBooks()
      _viewState.value = ViewState(false, recommendBooks)
    }
  }
}

loadRecommendBooks関数をいい感じにテストするためにはいくらかの準備が必要になる。

環境

次のような環境とする。

implementation "org.jetbrains.kotlin:kotlin-stdlib:1.4.21"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2'

testImplementation "org.junit.jupiter:junit-jupiter-api:5.6.1"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.6.1"

testImplementation 'org.robolectric:robolectric:4.4' // 多分今回のテストには関係ない
testImplementation 'androidx.test:runner:1.3.0'
testImplementation 'androidx.test.ext:junit:1.1.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2'
testImplementation "io.mockk:mockk:1.10.2"
testImplementation('androidx.test.ext:truth:1.3.0') {
  exclude group: 'com.google.auto.value', module: 'auto-value-annotations'
}

TestCoroutinesExtensionを用意する

Junit5ではorg.junit.rules.TestRule は無くなった。代わりにorg.junit.jupiter.api.extensionパッケージ内の各種インタフェースを実装したクラスを、ExtendWithで指定する形になった。そこでDispatchers.setMain()などを実行するTestCoroutinesExtensionを作る。

package hoge.fuga

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.extension.*

// テストの前後の実行、テストのコンストラクタへのパラメータをサポートする
class TestCoroutinesExtension : AfterTestExecutionCallback, BeforeTestExecutionCallback, ParameterResolver {
  private val namespace = ExtensionContext.Namespace.create(javaClass)

  private val key: Any = TestCoroutineDispatcher::class.java

  private val dispatcher = TestCoroutineDispatcher()

  override fun afterTestExecution(context: ExtensionContext?) {
    Dispatchers.resetMain()
  }

  override fun beforeTestExecution(context: ExtensionContext?) {
    Dispatchers.setMain(dispatcher)
  }

  override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean {
    return parameterContext.parameter.type === TestCoroutineDispatcher::class.java
  }

  override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any {
    return extensionContext.getStore(namespace).getOrComputeIfAbsent(key, { dispatcher }, TestCoroutineDispatcher::class.java)
  }
}

ViewModelのテストを書く

TestCoroutinesExtensionを使ってRecommendBookViewModelのテストを書く。

import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.mockk
import hoge.fuga.TestCoroutinesExtension
import hoge.fuga.recommendbook.RecommendBook
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith

// エクステンションを使う
@ExtendWith(TestCoroutinesExtension::class)
class RecommendBookViewModelTest(val dispatcher: TestCoroutineDispatcher) {
  // コンストラクタでTestCoroutineDispatcherを受け取る↑

  @Nested
  inner class loadRecommendBooks {
    @Test
    fun `success`() = dispatcher.runBlockingTest {
      val viewModel = RecommendBookViewModel(
        mockk {
          coEvery { recommendBooks() } returns listOf(
            RecommendBook("a"), 
            RecommendBook("b"), 
            RecommendBook("c")
          )
        }
      )
      viewModel.loadRecommendBooks()

      val viewState = viewModel.viewState.value
      assertThat(viewState.loading).isFalse()
      assertThat(viewState.recommendBooks.size).isEqualTo(3)
    }
  }
}

割とシンプルにできて良さそう。