以前に作成したAndroidのCompose + マルチモジュールプロジェクトで、実装時に割愛したテスト導入とCIの整備を行いました。 背景やベースのプロジェクトについては以下をご確認ください。
Jetpack Compose + マルチモジュール (+ Atomic Design)で作るAndroidプロジェクト - alpha Lounge
UseCaseとViewModelにテストを書く
今回はdomainモジュール内のUseCaseと、featureモジュール内のViewModelにテストを導入します。
どちらも共通して、モックライブラリとしてMockKを、assert関数の拡張ライブラリとしてTruthを使用します。
UseCase
UseCaseのテストは、Repositoryをモックして、UseCaseの実行結果を確認するだけです。依存するRepositoryが増えたり別のUseCaseを呼ぶ場合も、モックを追加するだけで柔軟に対応可能です。
private val query = "test" private val page = 1 private val mockRepository = mockk<SearchRepository> { coEvery { search(q = query, page = page) } returns SearchRepositoryEntity( totalCount = 2, incompleteResults = true, items = listOf( RepositoryEntity( ... ), ... ) } @Test fun `正常系のチェック`() = runBlocking { val useCase = SearchRepositoryUseCaseImpl(mockRepository) val result = useCase.execute(SearchRepositoryUseCaseParam(query, page)) // なんかテスト書く coVerify { mockRepository.search(q = query, page = page) } }
ViewModel
ViewModelではUiStateの公開用にStateFlowを使用しているため、検証のためにTurbineというライブラリを使用します。こちらはflow周りのテスト用のライブラリです。
テストを書く前に、Kotlin公式のテストガイドを参考にメインスレッドを置き換えます。これはviewModelScopeがメインスレッドを使用しているためです。
@OptIn(DelicateCoroutinesApi::class) private val mainThreadSurrogate = newSingleThreadContext("UI thread") @OptIn(ExperimentalCoroutinesApi::class) @Before fun setup() { Dispatchers.setMain(mainThreadSurrogate) } @OptIn(ExperimentalCoroutinesApi::class) @After fun tearDown() { Dispatchers.resetMain() mainThreadSurrogate.close() }
続いてViewModelのテストを書きます。
UseCaseをモックして、ViewModelのpublic関数(ここではsearch関数)をチェックします。この際に、TurbineでUiState用のStateFlowを購読することで、awaitItem()
を使用してデータを取り出すことができます。
JetpackComposeTest/HomeScreenViewModelTest.kt at master · alpha2048/JetpackComposeTest · GitHub
private val searchWord = "test" private val param = SearchRepositoryUseCaseParam( q = searchWord, page = 1 ) private val mockUseCase = mockk<SearchRepositoryUseCase> { coEvery { execute(param) } returns UseCaseResult.Success( data = SearchRepositoryEntity( ..., ) ) } @Test fun `正常系のチェック`() = runBlocking { val viewModel = HomeScreenViewModel(mockUseCase) delay(1000) viewModel.uiState.test { viewModel.search(searchWord) delay(1000) coVerify { mockUseCase.execute(param) } val state1 = awaitItem() val state2 = awaitItem() val state3 = awaitItem() assertThat(state3.state).isInstanceOf(HomeScreenViewModel.UiState.Loaded::class.java) // なんかテスト書く cancelAndConsumeRemainingEvents() } }
今回はflowでのテストの紹介でしたが、Stateの公開にLiveDataを使用している場合はcore-testingを使用すると似たようなことができそうです。
AndroidのマルチモジュールプロジェクトにCIを導入する
プルリクの作成時やプッシュ時に、前項で作成したテストの実行、及びAndroidのlint、ktlintの実行を行い、Dangerでコメントします。GitHub Actionsで組んでいます。
ktlint設定
ktlintの設定はktlint-gradleを使用し、ルートのbuild.gradleから各モジュールに設定を反映します。
JetpackComposeTest/build.gradle at master · alpha2048/JetpackComposeTest · GitHub
subprojects { apply plugin: "org.jlleitschuh.gradle.ktlint" // Version should be inherited from parent repositories { // Required to download KtLint mavenCentral() } // Optionally configure plugin ktlint { android = true debug = true reporters { reporter "checkstyle" } ignoreFailures = true } }
GitHub Actions
マルチモジュールプロジェクトではテストやlintの結果がそれぞれのモジュールに出力されるので、下記の記事を参考にそれぞれの結果からDanger実行します。
作成したDangerfileは以下の通り。
JetpackComposeTest/Dangerfile at master · alpha2048/JetpackComposeTest · GitHub
# ktlint checkstyle_format.base_path = Dir.pwd Dir["**/build/reports/ktlint/*/*.xml"].each do |file| checkstyle_format.report file end # android lint android_lint.skip_gradle_task = true # 事前にビルド実行するのでskip android_lint.filtering = true Dir["**/build/reports/lint-results*.xml"].each do |file| android_lint.report_file = file android_lint.lint(inline_mode: true) end # junit Dir["**/build/test-results/*/*.xml"].each do |file| junit.parse file junit.show_skipped_tests = true junit.report end
完成したYAMLファイルは以下の通り。あまり最適化はできてないですが、ベースはこれで動くと思います。
JetpackComposeTest/check_pr.yml at master · alpha2048/JetpackComposeTest · GitHub
jobs: setup: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: set up JDK 11 uses: actions/setup-java@v3 with: distribution: 'temurin' java-version: '11' cache: gradle check: needs: setup runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup ruby uses: ruby/setup-ruby@v1 with: ruby-version: '2.7' bundler-cache: true - name: Build with Gradle run: ./gradlew assembleDebug - name: run UnitTest continue-on-error: true run: ./gradlew testDebugUnitTest - name: run androidLint run: ./gradlew lintDebug - name: run ktlintCheck run: ./gradlew --no-daemon ktlintCheck - name: run danger env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gem install bundler bundle update bundle install bundle exec danger
デフォルトの状態だとktlintが自動で直せない部分(max lengthとか)は結構怒られるので、そのあたりのチューニングは必要そうです😢
おわりに
Turbineは初めて使いましたが、簡単にflowのテストが作成できるのでとてもいい感じ👍 今後のアップデートにも期待です。
CIについては、運用中のアプリであればリリースビルドを作ってFirebase Distributionなどで配布するworkflowを作るとかしたいですね。
もしこの記事をご覧になった方、参考になったらぜひブクマやシェアお願いします🙏