0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Android Compose × Hilt × Room × Retrofit 新規プロジェクト構築手順まとめ

0
Last updated at Posted at 2026-02-21

全体構成

Jetpack Compose を利用したモダン Android アプリ開発において、
新規プロジェクト作成直後にまとめて導入しておきたい主要ライブラリと、その設定手順を整理します。
毎回の環境構築で迷わないための、テンプレートとして利用できる構成です。

対象技術スタック:

  • Jetpack Compose
  • Hilt(依存性注入)
  • Room(ローカルDB)
  • Retrofit(API通信)
  • Moshi(JSON変換)
  • KSP(アノテーション処理)
  • libs.versions.toml(バージョンカタログ)
  • MockK / MockWebServer(テスト)

ディレクトリ構成例:

data/
    local/
        AppDatabase.kt
        Migrations.kt
        UserDao.kt
        UserEntity.kt
        UserEntityMapper.kt
    remote/
        UserApi.kt
        UserResponse.kt
        UserResponseMapper.kt
    repository/
        UserRepository.kt

domain/
    model/
        User.kt
    repository/
        Repository.kt

di/
    DatabaseModule.kt
    NetworkModule.kt

ui/
    user/
        ResetDialog.kt
        UserContent.kt
        UserList.kt
        UserScreen.kt
        UserUiState.kt
        UserViewModel.kt

1. 新規プロジェクト作成

Android Studio で新規プロジェクトを作成します。

  • Android Studio で New Project → Empty Activity
  • Language: Kotlin
  • Minimum SDK: 24以上
  • Use Kotlin DSL(build.gradle.kts)を使用
  • Composeを有効化

2. libs.versions.toml 設定

gradle/libs.versions.toml を編集し、使用するライブラリとバージョンを定義します。

versions

libs.versions.toml
[versions]
ksp = "2.3.6"
hilt = "2.59.1"
hiltNavigation = "1.3.0"
room = "2.8.4"
moshi = "1.15.2"
retrofit = "3.0.0"
loggingInterceptor = "5.3.2"
mockk = "1.14.9"
mockwebserver = "5.3.2"
kotlinxCoroutinesTest = "1.10.2"
robolectric = "4.16.1"

libraries

libs.versions.toml
[libraries]

# Hilt
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigation" }
hilt-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }

# Room
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }

# Moshi
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }

# Retrofit
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }

# OkHttp Logging
logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" }

# Test
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockwebserver" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }

group版
libs.versions.toml
[libraries]
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" }
hilt-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }

# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }

# Moshi
moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
moshi-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }

# Retrofit
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }

# OkHttp Logging
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" }

# Test
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }

plugins

libs.versions.toml
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room = { id = "androidx.room", version.ref = "room" }

3. Project/build.gradle.kts 設定

Gradle プラグインを追加します。

plugins

build.gradle.kts (:Project)
plugins {
    alias(libs.plugins.hilt) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.room) apply false
}

4. app/build.gradle.kts 設定

plugins

build.gradle.kts (:app)
plugins {
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
    alias(libs.plugins.room)
}

dependencies

build.gradle.kts (:app)
dependencies {

    // Hilt
    implementation(libs.hilt.android)
    implementation(libs.hilt.navigation.compose)
    ksp(libs.hilt.compiler)
    androidTestImplementation(libs.hilt.testing)
    kspAndroidTest(libs.hilt.compiler)

    // Room
    implementation(libs.room.runtime)
    implementation(libs.room.ktx)
    ksp(libs.room.compiler)
    androidTestImplementation(libs.room.testing)

    // Moshi
    implementation(libs.moshi)
    implementation(libs.moshi.kotlin)
    ksp(libs.moshi.codegen)

    // Retrofit
    implementation(libs.retrofit)
    implementation(libs.retrofit.moshi)

    // OkHttp Logging
    implementation(libs.logging.interceptor)

    // Test
    testImplementation(libs.mockk)
    testImplementation(libs.mockwebserver)
    testImplementation(libs.kotlinx.coroutines.test)
    testImplementation(libs.robolectric)
}

(AGB8以降) BuildConfig 生成設定

HTTP通信ログ出力を切り替えるために BuildConfig を参照していますが、
AGP8から BuildConfig がデフォルトでは生成されなくなりました。
そのため、buildFeatures 内に buildConfig = true を追加します。

build.gradle.kts (:app)
android {
    buildFeatures {
+        buildConfig = true
        compose = true
    }
}

Room スキーマ出力設定

Build時に app/schemas/ へデータベースの JSON スキーマが生成されるようにします。
これはマイグレーションテストに必須です。

build.gradle.kts (:app)
room {
    schemaDirectory("$projectDir/schemas")
}

kotlinx-serialization 適用バージョンの強制

Migrationテスト実行時に java.lang.AbstractMethodError が発生する場合に必要な対応です。

Room の JSON スキーマ読み込み時に使用される kotlinx-serialization において、不具合で古いバージョン(1.7.3)が選択される場合があります。
この状態でシリアライザが実行されると、ランタイムとの不整合により AbstractMethodError が発生します。
その場合、app/kotlin:build.gradle.ktsにてバージョンを強制することで回避できます。

発生するエラー:

java.lang.AbstractMethodError: abstract method "kotlinx.serialization.KSerializer[] kotlinx.serialization.internal.GeneratedSerializer.typeParametersSerializers()"

追加するコード:

build.gradle.kts (:app)
configurations.all {
    resolutionStrategy.eachDependency {
        val serdeVer = "1.8.0"
        when(requested.module.toString()) {
            "org.jetbrains.kotlinx:kotlinx-serialization-json" -> useVersion(serdeVer)
            "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm" -> useVersion(serdeVer)
            "org.jetbrains.kotlinx:kotlinx-serialization-core" -> useVersion(serdeVer)
            "org.jetbrains.kotlinx:kotlinx-serialization-core-jvm" -> useVersion(serdeVer)
            "org.jetbrains.kotlinx:kotlinx-serialization-bom" -> useVersion(serdeVer)
        }
    }
}

元ネタ:
https://github.com/Kotlin/kotlinx.serialization/issues/2968#issuecomment-3356075918

5. Hilt 初期設定

自動インポート機能を適切に動作させるため、ここに入る前に
Sync Project with Gradle Files を実行してください。

Application の作成

@HiltAndroidApp を付与することで、アプリケーションレベルの依存関係コンテナ(SingletonComponent)が生成されます。

これにより、@Inject@Module で定義した依存関係をアプリ全体で利用できるようになります。

MyApplication.kt
@HiltAndroidApp
class MyApplication: Application()

AndroidManifest.xml に追加:

AndroidManifest.xml
<application
    android:name=".MyApplication"

Activity の変更

MainActivity.kt
+ @AndroidEntryPoint
class MainActivity : ComponentActivity()

ViewModel での利用

ViewModel で Hilt を利用する場合は、
@HiltViewModel を付与し、コンストラクタインジェクションを使用します。

UserViewModel.kt
@HiltViewModel
class UserViewModel @Inject constructor(
    private val userRepository: UserRepository
) : ViewModel()

6. Room 初期設定

Entity、DAO、Database を定義します。
また、必要に応じて Maigration を作成します。

Entity

data/local/UserEntity.kt
@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey val id: Int,
    val name: String,
    val email: String
)

DAO

data/local/UserDao.kt
@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun observeAll(): Flow<List<UserEntity>>

    @Query("SELECT * FROM users WHERE id = :id")
    suspend fun get(id: Int): List<UserEntity>

    @Query("SELECT * FROM users")
    suspend fun getAll(): List<UserEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(user: UserEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(users: List<UserEntity>)

    @Delete
    suspend fun delete(user: UserEntity)

    @Query("DELETE FROM users")
    suspend fun deleteAll()
    
    @Transaction
    suspend fun replace(items: List<UserEntity>) {
        deleteAll()
        insertAll(items)
    }
}

Database

data/local/AppDatabase.kt
@Database(
    entities = [
        UserEntity::class,
        // 追加のEntityを記述
    ],
    version = 1,
    exportSchema = true
  )
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

Migrations

data/local/Migrations.kt
val ALL_MIGRATIONS: Array<Migration> = arrayOf(
    // 必要に応じてマイグレーションを追加
)

7. Moshi + Retrofit 設定

DTOに@JsonClass(generateAdapter = true)を付与することで、
KSP により JSON Adapter が自動生成されます。

DTO (Response Model)

data/remote/UserResponse.kt
@JsonClass(generateAdapter = true)
data class UserResponse(
    @property:Json(name = "id")
    val id: Int,

    @property:Json(name = "username")
    val name: String,

    @property:Json(name = "email")
    val email: String
)
  • JSON ↔ Kotlin 変換は Moshi
  • Adapter 生成は KSP
  • DTO は Domain や DB に依存しない

API定義 (Retrofit)

data/remote/UserApi.kt
interface UserApi {
    @GET("users")
    suspend fun getUsers(): List<UserResponse>

    @GET("users/{id}")
    suspend fun getUser(
        @Path("id") id: Int
    ): UserResponse
}

8. Domain Model とマッピング

domain/model/User.kt
data class User (
    val id: Int,
    val name: String,
    val email: String,
)

Local ↔ Domain

data/local/UserEntityMapper.kt
fun UserEntity.toDomain(): User = User(id, name, email)
fun List<UserEntity>.toDomain() = map { it.toDomain() }

fun User.toEntity(): UserEntity = UserEntity(id, name, email)
fun List<User>.toEntity() = map { it.toEntity() }

Remote ↔ Domain

data/remote/UserResponseMapper.kt
fun UserResponse.toDomain(): User = User(id, name, email)
fun List<UserResponse>.toDomain() = map { it.toDomain() }

リポジトリ

アプリケーションでの データ取得や保存の責務 を集約するのが Repository です。
リモートAPI と ローカルDB の橋渡し役となり、データアクセスの詳細を内部に隠蔽します。
UI や UseCase は Repository を介して Domain Model を扱うため、
アプリケーション層が具体的な DB や API の実装に依存せず済みます。

インタフェース (Domain層)

domain/repository/IUserRepository.kt
interface Repository<T> {
    fun observeAll(): Flow<List<T>>
    suspend fun getAll(): List<T>
    suspend fun get(id: Int): T
    suspend fun save(item: T)
    suspend fun saveAll(items: List<T>)
    suspend fun deleteAll()
}

実装例 (DB + API 橋渡し)

data/repository/UserRepository.kt
@Singleton
class UserRepository @Inject constructor(
    private val userApi: UserApi,
    private val userDao: UserDao
): Repository<User> {

    /** ローカルDBの全ユーザーを監視するFlowを返す */
    override fun observeAll(): Flow<List<User>> =
        userDao.observeAll().map { it.toDomain() }

    /** 指定IDのユーザーをAPIから取得し、ローカルDBへ保存したうえで返却する */
    override suspend fun get(id: Int): User =
        userApi.getUser(id).toDomain()
            .also { save(it) }

    /** APIから全ユーザーを取得し、ローカルDBを完全に置き換えて同期する */
    override suspend fun getAll(): List<User> =
        userApi.getUsers().toDomain().also {
            userDao.replace(it.toEntity())
        }

    override suspend fun save(item: User) = userDao.insert(item.toEntity())
    override suspend fun saveAll(items: List<User>) = userDao.insertAll(items.toEntity())
    override suspend fun delete(item: User) = userDao.delete(item.toEntity())
    override suspend fun deleteAll() = userDao.deleteAll()
}

10. Hilt Modules

DatabaseModule

リポジトリが利用する DAO を提供します。

di/DatabaseModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(
        @ApplicationContext context: Context
    ): AppDatabase =
        Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "app.db"
        )
            .addMigrations(*ALL_MIGRATIONS)
            .build()

    @Provides
    fun provideUserDao(
        database: AppDatabase
    ): UserDao = database.userDao()
}

NetworkModule

リポジトリが利用する API を提供します。

di/NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideMoshi(): Moshi =
        Moshi.Builder().build()

    @Provides
    @Singleton
    fun provideLoggingInterceptor(): HttpLoggingInterceptor =
        HttpLoggingInterceptor().apply {
            level = if (BuildConfig.DEBUG) {
                HttpLoggingInterceptor.Level.BODY
            } else {
                HttpLoggingInterceptor.Level.NONE
            }
        }

    @Provides
    @Singleton
    fun provideOkHttpClient(
        logging: HttpLoggingInterceptor
    ): OkHttpClient =
        OkHttpClient.Builder()
            .addInterceptor(logging)
            .build()

    @Provides
    @Singleton
    fun provideRetrofit(
        moshi: Moshi,
        client: OkHttpClient
    ): Retrofit =
        Retrofit.Builder()
            .baseUrl("https://example.com/")
            .client(client)
            .addConverterFactory(
                MoshiConverterFactory.create(moshi)
            )
            .build()

    @Provides
    @Singleton
    fun provideUserApi(retrofit: Retrofit): UserApi =
        retrofit.create(UserApi::class.java)
}

BaseUrl が複数ある場合は Qualifier を作ります。

BaseUrl が複数ある場合のコード例

BaseUrl が複数ある場合のコード例

例:

Main API → https://api.example.com/

Auth API → https://auth.example.com/

① Qualifier を作る
di/Qualifiers.kt
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class MainApi

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthApi
② Hilt Module で分ける
di/NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient =
        OkHttpClient.Builder().build()

    @MainApi
    @Provides
    @Singleton
    fun provideMainRetrofit(
        client: OkHttpClient
    ): Retrofit =
        Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .client(client)
            .addConverterFactory(MoshiConverterFactory.create())
            .build()

    @AuthApi
    @Provides
    @Singleton
    fun provideAuthRetrofit(
        client: OkHttpClient
    ): Retrofit =
        Retrofit.Builder()
            .baseUrl("https://auth.example.com/")
            .client(client)
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
}
③ API を分ける
@Provides
@Singleton
fun provideUserApi(
    @MainApi retrofit: Retrofit
): UserApi =
    retrofit.create(UserApi::class.java)

@Provides
@Singleton
fun provideAuthApi(
    @AuthApi retrofit: Retrofit
): AuthApi =
    retrofit.create(AuthApi::class.java)

11. テスト環境の構築

  • インメモリ DB + モック API で安全にテスト
  • Hilt @TestInstallIn で本番 Module を置き換え

テスト用の Module を作成

TestDatabaseModule (インメモリ DB)

(androidTest) di/TestDatabaseModule.kt
@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [DatabaseModule::class]
)
object TestDatabaseModule {

    @Provides
    @Singleton
    fun provideInMemoryDatabase(
        @ApplicationContext context: Context
    ): AppDatabase =
        Room.inMemoryDatabaseBuilder(
            context,
            AppDatabase::class.java
        )
            .allowMainThreadQueries() // テスト用にメインスレッド許可
            .build()

    @Provides
    fun provideUserDao(
        database: AppDatabase
    ): UserDao = database.userDao()
}

TestNetworkModule (モック API)

(androidTest) di/TestNetworkModule.kt
@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [NetworkModule::class]
)
object TestNetworkModule {

    @Provides
    @Singleton
    fun provideMockApi(): UserApi = object : UserApi {
        override suspend fun getUsers(): List<UserResponse> =
            listOf(
                UserResponse(1, "Alice", "alice@example.com"),
                UserResponse(2, "Bob", "bob@example.com"),
                UserResponse(3, "Charlie", "charlie@example.com")
            )

        override suspend fun getUser(id: Int): UserResponse =
            UserResponse(id, "User $id", "user$id@example.com")
    }
}

Hilt DI テストランナーの設定

androidTest に Runner を作成

CustomTestRunner.kt
class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

androidTest の Runner を変更

パッケージ名は実装に合わせます。

build.gradle.kts (:app)
android {
    defaultConfig {
        testInstrumentationRunner = "com.example.CustomTestRunner"
    }
}

12. テスト

Hilt DI テスト

(androidTest) UserViewModelTest.kt
@HiltAndroidTest
class HiltInjectionTest {

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var userRepository: UserRepository

    @Inject
    lateinit var userApi: UserApi

    @Inject
    lateinit var userDao: UserDao

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun diInjectionWorks() {
        assertNotNull(userRepository)
        assertNotNull(userApi)
        assertNotNull(userDao)
    }

    @Test
    fun apiReturnsMockData() = runTest {
        val users = userRepository.getAll()
        assert(users.isNotEmpty())
        assertEquals("Alice", users.first().name)
    }

    @Test
    fun daoCanInsertAndRead() = runTest {
        val user = UserEntity(1, "Alice", "alice@example.com")
        userDao.insert(user)

        val flow = userDao.observeAll().first()
        assert(flow.isNotEmpty())
        assertEquals("Alice", flow.first().name)
    }
}

Room DAO テスト

(androidTest) UserDaoTest.kt
@OptIn(ExperimentalCoroutinesApi::class)
class UserDaoTest {

    private lateinit var database: AppDatabase
    private lateinit var userDao: UserDao

    @Before
    fun setup() {
        val context = ApplicationProvider.getApplicationContext<Context>()

        database = Room.inMemoryDatabaseBuilder(
            context,
            AppDatabase::class.java
        )
            .allowMainThreadQueries()
            .build()

        userDao = database.userDao()
    }

    @After
    fun tearDown() {
        database.close()
    }

    @Test
    fun insertUser_andGetAll_returnsInsertedUser() = runTest {

        val user = UserEntity(
            id = 1,
            name = "Alice",
            email = "alice@example.com"
        )
        userDao.insert(user)

        val users = userDao.getAll()

        TestCase.assertEquals(1, users.size)
        TestCase.assertEquals("Alice", users.first().name)
        TestCase.assertEquals("alice@example.com", users.first().email)
    }
}

Room Migration テスト

(androidTest) MigrationTest.kt
@RunWith(AndroidJUnit4::class)
class MigrationTest {

    private val testDatabase = "migration-test"

    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )

    @Test
    fun migrateAll() {

        // version 1 のDBを作成
        val db = helper.createDatabase(testDatabase, 1)
        db.execSQL("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'alice@example.com')")
        db.close()

        // マイグレーション
        val migratedDb = helper.runMigrationsAndValidate(
            testDatabase,
            2, // 最新version
            true,
            *ALL_MIGRATIONS
        )
        
        // データ検証
        migratedDb.query("SELECT * FROM users WHERE id = 1").also {
            assertEquals(1, it.count)
            it.moveToFirst()
            assertEquals("Alice", it.getString(it.getColumnIndexOrThrow("name")))
            it.close()
        }
        migratedDb.close()
    }
}

適切なバージョンの Migration が存在しない場合:java.lang.IllegalStateException
スキーマ JSON が存在しない場合:java.io.FileNotFoundException


Retrofit API 単体テスト (MockK を利用)

(test) UserApiUnitTest.kt
class UserApiUnitTest {

    private lateinit var api: UserApi

    @Before
    fun setup() {
        api = mockk()
    }

    @Test
    fun getUsers_givenSuccess_returnsExpectedUsers() = runTest {

        coEvery { api.getUsers() } returns listOf(
            UserResponse(1, "Alice", "alice@example.com"),
            UserResponse(2, "Bob", "bob@example.com")
        )

        val result = api.getUsers()

        assertEquals(2, result.size)

        assertEquals(1, result[0].id)
        assertEquals("Alice", result[0].name)

        assertEquals(2, result[1].id)
        assertEquals("Bob", result[1].name)

        coVerify { api.getUsers() }
    }

    @Test
    fun getUser_givenValidId_returnsExpectedUserResponse() = runTest {

        coEvery { api.getUser(1) } returns UserResponse(1, "Alice","alice@example.com")

        val result = api.getUser(1)

        assertEquals(1, result.id)
        assertEquals("Alice", result.name)
        assertEquals("alice@example.com", result.email)

        coVerify { api.getUser(1) }
    }
}

Retrofit API 結合テスト (MockWebServer を利用)

(test) UserResponseTests.kt
class UserApiIntegrationTest {

    private lateinit var server: MockWebServer
    private lateinit var api: UserApi

    @Before
    fun setup() {
        server = MockWebServer()
        server.start()

        val retrofit = Retrofit.Builder()
            .baseUrl(server.url("/"))
            .addConverterFactory(MoshiConverterFactory.create())
            .build()

        api = retrofit.create(UserApi::class.java)
    }

    @After
    fun teardown() {
        server.close()
    }

    @Test
    fun getUser_whenServerReturns200_parsesCorrectly() = runTest {
        server.enqueue(
            MockResponse(
                code = 200,
                body = """{"id":1,"username":"Alice","email":"alice@example.com"}"""
            )
        )

        val result = api.getUser(1)

        assertEquals(1, result.id)
        assertEquals("Alice", result.name)
        assertEquals("alice@example.com", result.email)
    }

    @Test
    fun getUsers_whenServerReturns200_parsesCorrectly() = runTest {
        server.enqueue(
            MockResponse(
                code = 200,
                body = """[
                    {"id":1,"username":"Alice","email":"alice@example.com"},
                    {"id":2,"username":"Bob","email":"bob@example.com"}
                ]"""
            )
        )

        val result = api.getUsers()

        assertEquals(2, result.size)
        assertEquals("Alice", result[0].name)
        assertEquals("Bob", result[1].name)
    }

    @Test
    fun getUser_whenServerReturns404_throwsException() = runTest {
        server.enqueue(MockResponse(code = 404))

        try {
            api.getUser(1)
            fail("Expected an exception")
        } catch (e: Exception) {
            assertEquals("HTTP 404 Client Error", e.message)
        }
    }

    @Test
    fun getUser_whenInvalidJson_throwsException() = runTest {
        server.enqueue(
            MockResponse(
                code = 200,
                body = """{"id":"abc","username":123}""" // 型が不正
            )
        )

        try {
            api.getUser(1)
            fail("Expected an exception")
        } catch (e: Exception) {
            // MoshiやRetrofitが変換エラーを投げる
            assertTrue(e is retrofit2.HttpException || e is com.squareup.moshi.JsonDataException)
        }
    }
}

13. UI (おまけ)

  • Compose + ViewModel 連携
  • UIState とイベントハンドラのみを受け取る構造でテスト容易
  • UserScreen / UserContent / ResetDialog で分離
本筋から外れる、かつ長いので見たい方だけ開いてください。

Jetpack Compose を利用したモダン Android アプリ開発では、UI を ViewModel に依存させず、UI State とイベントハンドラのみを受け取る構造にすると、テストやプレビューがしやすくなります。

ここでは UserContent の構築例と UIState 連携のベストプラクティスを紹介します。

UI State の定義

UIState は ViewModel が UI に渡す「画面の状態」をまとめたデータクラスです。
UI はこの State を受け取るだけで描画できるようにします。

ui/user/UserUIState.kt
data class UserUIState(
    val isLoading: Boolean = false,
    val users: List<User> = emptyList(),
    val errorMessage: String? = null
)

Content Composable

UserContent は ViewModel から渡される UserUIState と、ユーザー操作用のイベントリスナを受け取ります。

ui/user/UserContent.kt
@Composable
fun UserContent(
    uiState: UserUIState,
    onRefreshUsers: () -> Unit,
    showResetUsersDialog: () -> Unit,
    modifier: Modifier = Modifier
) {
    Box(modifier = modifier) {

        when {
            uiState.isLoading -> {
                CircularProgressIndicator(
                    modifier = Modifier
                        .align(Alignment.Center)
                        .size(64.dp),
                    color = MaterialTheme.colorScheme.primary,
                    strokeWidth = 6.dp
                )
            }
            !uiState.errorMessage.isNullOrEmpty() -> {
                // エラー表示
                Text(
                    text = uiState.errorMessage,
                    color = MaterialTheme.colorScheme.error,
                    style = MaterialTheme.typography.bodyLarge,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
            else -> {
                UserList(
                    users = uiState.users,
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(bottom = 72.dp) // 更新ボタン分のスペース
                )
            }
        }

        Row(
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .fillMaxWidth()
                .padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
        ) {
            // 更新ボタン
            Button(
                enabled = !uiState.isLoading,
                onClick = onRefreshUsers,
                modifier = Modifier
                    .weight(2f),
                colors = ButtonDefaults.buttonColors(
                    containerColor = MaterialTheme.colorScheme.primary
                )
            ) {
                Text("更新", color = MaterialTheme.colorScheme.onPrimary)
            }

            Spacer(modifier = Modifier.width(6.dp))

            // 削除ボタン
            Button(
                enabled = !uiState.isLoading,
                onClick = showResetUsersDialog,
                modifier = Modifier
                    .weight(1f),
                colors = ButtonDefaults.buttonColors(
                    containerColor = MaterialTheme.colorScheme.error
                )
            ) {
                Text("削除", color = MaterialTheme.colorScheme.onError)
            }
        }
    }
}

Preview

@Preview で動作を確認可能です。

ui/user/UserContent.kt
@Preview(
    showBackground = true,
    heightDp = 800,
    widthDp = 400
)
@Composable
fun UserContentPreview() {
    val uiState = UserUIState(
        isLoading = false,
        errorMessage = "エラーです",
        users = listOf(
            User(1, "Alice", "alice@example.com"),
            User(2, "Bob", "bob@example.com"),
            User(3, "Charlie", "charlie@example.com")
        )
    )

    MaterialTheme {
        UserContent(
            uiState,
            {},
            {}
        )
    }
}

List Composable

ユーザーをリスト表示するコンポーネント。
リスト部分は別 Composable に分離し、UIState とは疎結合にします。

ui/user/UserList.kt
@Composable
fun UserList(
    users: List<User>,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier,
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        items(users) { user ->
            Card(
                modifier = Modifier
                    .fillMaxWidth(),
                shape = RoundedCornerShape(12.dp),
                elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
            ) {
                Column(
                    modifier = Modifier
                        .padding(16.dp)
                ) {
                    // 名前
                    Text(
                        text = user.name,
                        style = MaterialTheme.typography.titleMedium,
                        color = MaterialTheme.colorScheme.onSurface
                    )
                    Spacer(modifier = Modifier.height(4.dp))
                    // アドレス
                    Text(
                        text = user.email,
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
            }
        }
    }
}

Screen

UserScreenUserViewModelUserContent の橋渡しをします。
また、ダイアログ表示の管理も行うことができます。

ui/user/UserScreen.kt
@Composable
fun UserScreen(
    modifier: Modifier = Modifier,
    viewModel: UserViewModel = hiltViewModel()
) {
    // ViewModel から UIState を取得
    val uiState by viewModel.uiState.collectAsState()

    // ダイアログ表示フラグを Compose 側で保持
    var showResetUsersDialog by remember { mutableStateOf(false) }

    UserContent(
        uiState = uiState,
        onRefreshUsers = viewModel::refreshUsers,
        showResetUsersDialog = { showResetUsersDialog = true },
        modifier = modifier
    )

    // リセット確認ダイアログ
    ResetDialog(
        isShown = showResetUsersDialog,
        onDismiss = { showResetUsersDialog = false },
        onReset = viewModel::resetUsers
    )
}

Dialog Composable

ダイアログを独立した Composable にすることで、Content と UIロジックの分離 が可能です。
再利用も簡単になり、テストやプレビューも容易です。

ui/user/ResetDialog.kt
@Composable
fun ResetDialog(
    isShown: Boolean,
    onDismiss: () -> Unit,
    onReset: () -> Unit
) {
    if (!isShown) return

    AlertDialog(
        onDismissRequest = onDismiss,
        title = {
            Text(text = "Reset Users")
        },
        text = {
            Text(text = "ユーザーデータをリセットしますか?")
        },
        confirmButton = {
            TextButton(onClick = {
                onReset()
                onDismiss()
            }) {
                Text(text = "はい")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text(text = "いいえ")
            }
        }
    )
}

ViewModel

ViewModel は UIState を StateFlow で公開します。

ui/user/UserViewModel.kt
@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserRepository
): ViewModel() {

    private var _uiState = MutableStateFlow(UserUIState())
    val uiState: StateFlow<UserUIState> = _uiState.asStateFlow()

    init {
        // DB を監視する
        viewModelScope.launch {
            repository.observeAll()
                .onStart { _uiState.update { it.copy(isLoading = true) } }
                .catch { e ->
                    // Flow 例外時の処理
                    _uiState.update {
                        it.copy(errorMessage = e.message ?: "不明なエラー")
                    }
                }
                .collect { users ->
                    _uiState.update { UserUIState(users = users) }
                }
        }
    }

    /** ユーザー取得をリトライ/更新 */
    fun refreshUsers() {
        if (_uiState.value.isLoading) return
        _uiState.update { it.copy(isLoading = true, errorMessage = null) }
        viewModelScope.launch {
            runCatching { repository.getAll() }
                .onFailure { e ->
                    _uiState.update {
                        it.copy(
                            isLoading = false,
                            errorMessage = e.message ?: "不明なエラー"
                        )
                    }
                }
        }
    }

    /** ユーザーをすべて削除 */
    fun resetUsers() {
        if (_uiState.value.isLoading) return
        _uiState.update { it.copy(isLoading = true, errorMessage = null) }
        viewModelScope.launch {
            runCatching { repository.deleteAll() }
                .onFailure { e ->
                    _uiState.update {
                        it.copy(
                            isLoading = false,
                            errorMessage = e.message ?: "削除エラー"
                        )
                    }
                }
        }
    }
}

MainActivity

MainActivity.kt
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    // UserScreen
                    UserScreen(modifier = Modifier
                        .fillMaxSize()
                        .padding(innerPadding)
                    )
                }
            }
        }
    }
}

以上で終了ですが、ここまでで十分に実行できるはずです。
そのままだとURLがダミーのため HTTP 404 エラーとなりますが、
NetworkModule.provideRetrofitbaseUrl を下記サイトにすると動作を試せます。

https://jsonplaceholder.typicode.com

📚 公式リンク集

🎨 Jetpack Compose

Jetpack Compose 公式
https://developer.android.com/jetpack/compose

Compose Pathway(公式チュートリアル)
https://developer.android.com/courses/pathways/compose

Material3 for Compose
https://developer.android.com/jetpack/compose/designsystems/material3

🧩 Hilt

Hilt 公式ドキュメント
https://developer.android.com/training/dependency-injection/hilt-android

Dagger / Hilt GitHub
https://github.com/google/dagger

Hilt を使用した依存関係挿入  |  App architecture  |  Android Developers
https://developer.android.com/training/dependency-injection/hilt-android?hl=ja

🗄 Room

Room 公式
https://developer.android.com/training/data-storage/room

Room Migration 公式
https://developer.android.com/training/data-storage/room/migrating-db-versions

🌐 Retrofit

Retrofit GitHub(公式)
https://github.com/square/retrofit

Retrofit 公式サイト
https://square.github.io/retrofit/

🔎 Moshi

Moshi GitHub
https://github.com/square/moshi

Moshi Codegen 公式
https://github.com/square/moshi#codegen

⚙ KSP(Kotlin Symbol Processing)

KSP GitHub
https://github.com/google/ksp

KSP Overview
https://kotlinlang.org/docs/ksp-overview.html

🧪 MockK

MockK GitHub
https://github.com/mockk/mockk

MockK Guidebook
https://mockk.io/

🛰 MockWebServer

MockWebServer GitHub
https://github.com/square/okhttp/tree/master/mockwebserver

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?