Vibe coding全紀錄-11

A:

 好的,請開始下一步,另外請問我該怎麼進行app的測試,謝謝。 

Q:

好的,我們立刻開始讓 App「動起來」的這一步。同時,我也會為你詳細解析如何為你的 App 建立測試,確保程式碼的品質與穩定性。

這是一個大主題,我會先專注於實現新增交易的功能,然後在第二部分詳細講解測試策略


第八步:實現互動功能 - 新增一筆交易

這個流程包含幾個部分:

  1. 建立導航 (Navigation): 讓 App 可以在「主畫面」和「新增交易畫面」之間切換。

  2. 修改 HomeScreen: 加上一個懸浮按鈕 (Floating Action Button, FAB)。

  3. 建立 AddTransactionScreen: 也就是我們的輸入表單畫面。

  4. 擴充 ViewModel: 加入儲存新交易的邏輯。

1. 加入 Navigation Compose 依賴

為了管理畫面切換,我們使用 Jetpack Navigation for Compose。

app/build.gradle.ktsdependencies 中加入:

Kotlin
implementation("androidx.navigation:navigation-compose:2.7.7")

記得點擊 "Sync Now"。

2. 設定導航

我們會修改 MainActivityHomeScreen,讓 MainActivity 作為導航的「主舞台」。

A. 建立 AppNavigation.kt

在 ui 套件下建立一個新檔案,專門管理所有導航路線和主機。

Kotlin
// /ui/AppNavigation.kt
package com.yourcompany.cashflow.ui

import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.yourcompany.cashflow.ui.screens.AddTransactionScreen
import com.yourcompany.cashflow.ui.screens.HomeScreen

// 定義我們的導航路線名稱,避免使用魔法字串
object Routes {
    const val HOME = "home"
    const val ADD_TRANSACTION = "add_transaction"
}

@Composable
fun AppNavigation() {
    val navController = rememberNavController() // 建立導航控制器

    NavHost(
        navController = navController,
        startDestination = Routes.HOME // 設定起始畫面
    ) {
        // 定義主畫面
        composable(Routes.HOME) {
            HomeScreen(
                onNavigateToAddTransaction = {
                    navController.navigate(Routes.ADD_TRANSACTION)
                }
            )
        }

        // 定義新增交易畫面
        composable(Routes.ADD_TRANSACTION) {
            AddTransactionScreen(
                onNavigateBack = {
                    navController.popBackStack() // 返回上一個畫面
                }
            )
        }
    }
}

B. 更新 MainActivity.kt

讓它載入我們的 AppNavigation。

Kotlin
// MainActivity.kt
...
import com.yourcompany.cashflow.ui.AppNavigation // 匯入

...
setContent {
    CashFlowTheme {
        Surface(...) {
            AppNavigation() // 將 HomeScreen() 替換成 AppNavigation()
        }
    }
}
...

3. 修改 HomeScreen.kt

我們需要加上 FAB 按鈕,並在點擊時觸發導航。

Kotlin
// /ui/screens/HomeScreen.kt
...
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.* // 確保匯入

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
    viewModel: TransactionViewModel = hiltViewModel(),
    onNavigateToAddTransaction: () -> Unit // 加入導航回呼函式
) {
    val transactions by viewModel.allTransactions.collectAsStateWithLifecycle(initialValue = emptyList())

    Scaffold(
        topBar = { TopAppBar(title = { Text("錢流 - 交易紀錄") }) },
        floatingActionButton = {
            FloatingActionButton(onClick = onNavigateToAddTransaction) { // 點擊時呼叫導航
                Icon(Icons.Filled.Add, contentDescription = "新增交易")
            }
        }
    ) { innerPadding ->
        TransactionList(
            transactions = transactions,
            modifier = Modifier.padding(innerPadding)
        )
    }
}

4. 擴充 ViewModel

AddTransactionScreen 需要知道有哪些分類和帳戶可以選,並且需要一個方法來儲存新交易。

Kotlin
// /ui/viewmodel/TransactionViewModel.kt
...
import androidx.lifecycle.viewModelScope
import com.yourcompany.cashflow.data.local.entity.Transaction // 匯入
import kotlinx.coroutines.launch // 匯入

@HiltViewModel
class TransactionViewModel @Inject constructor(
    private val repository: TransactionRepository
    // 之後我們會在這裡注入 CategoryRepository 和 AccountRepository
) : ViewModel() {
    
    val allTransactions = repository.getAllTransactionDetails()

    // TODO: 未來從 CategoryRepository 取得可選的分類列表
    // val categories = ...

    // TODO: 未來從 AccountRepository 取得可選的帳戶列表
    // val accounts = ...

    /**
     * 提供給 UI 呼叫,用來插入一筆新的交易
     */
    fun insertTransaction(transaction: Transaction) {
        // 使用 viewModelScope.launch 來在背景執行緒中執行資料庫操作
        viewModelScope.launch {
            repository.insertTransaction(transaction)
        }
    }
}

5. 建立 AddTransactionScreen.kt

這是一個比較複雜的畫面,我們先建立一個基本的骨架。

Kotlin
// /ui/screens/AddTransactionScreen.kt
package com.yourcompany.cashflow.ui.screens

import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.yourcompany.cashflow.data.local.entity.Transaction
import com.yourcompany.cashflow.data.local.entity.TransactionType
import com.yourcompany.cashflow.ui.viewmodel.TransactionViewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTransactionScreen(
    viewModel: TransactionViewModel = hiltViewModel(),
    onNavigateBack: () -> Unit
) {
    // 使用 remember 來保存輸入欄位的狀態
    var amount by remember { mutableStateOf("") }
    var note by remember { mutableStateOf("") }
    // TODO: 加入更多狀態,如選擇的分類、帳戶、日期等

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("新增交易") },
                navigationIcon = {
                    IconButton(onClick = onNavigateBack) {
                        Icon(Icons.Filled.ArrowBack, contentDescription = "返回")
                    }
                }
            )
        }
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .padding(innerPadding)
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            // 金額輸入欄位
            OutlinedTextField(
                value = amount,
                onValueChange = { amount = it },
                label = { Text("金額") },
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(modifier = Modifier.height(16.dp))

            // 備註輸入欄位
            OutlinedTextField(
                value = note,
                onValueChange = { note = it },
                label = { Text("備註 (可選)") },
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(modifier = Modifier.height(32.dp))

            // 儲存按鈕
            Button(
                onClick = {
                    val amountDouble = amount.toDoubleOrNull()
                    if (amountDouble != null) {
                        // 建立 Transaction 物件
                        // 注意:這裡的 accountId 和 subCategoryId 暫時寫死,之後要換成使用者的選擇
                        val newTransaction = Transaction(
                            amount = amountDouble,
                            type = TransactionType.EXPENSE, // 暫時寫死為支出
                            transactionDate = System.currentTimeMillis(),
                            accountId = 1, // 暫時寫死
                            subCategoryId = 1, // 暫時寫死
                            note = note
                        )
                        // 呼叫 ViewModel 的方法來儲存
                        viewModel.insertTransaction(newTransaction)
                        // 儲存後返回主畫面
                        onNavigateBack()
                    }
                },
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("儲存")
            }
        }
    }
}

現在執行 App,你應該可以點擊主畫面的 + 按鈕,跳轉到新增頁面,輸入金額和備註,點擊儲存後返回主畫面。如果你有預先在資料庫建立 ID 為 1 的帳戶和分類,你應該會看到主畫面的列表自動更新了!


如何進行 App 的測試

你的第二個問題非常專業!測試是確保 App 品質、減少 Bug、讓你未來敢於修改舊程式碼的基石。Android 的測試主要分為三類:

1. 單元測試 (Unit Tests)

  • 測什麼? 測試單一的類別或函式,完全不依賴 Android 框架。主要用來測試業務邏輯,例如 ViewModel

  • 放哪裡? app/src/test/java/...

  • 工具: JUnit, MockK (一個強大的 Kotlin Mocking 函式庫)。

  • 範例 (TransactionViewModel 測試):

Kotlin
// 在 app/src/test/java/.../viewmodel/TransactionViewModelTest.kt
import com.yourcompany.cashflow.data.local.entity.Transaction
import com.yourcompany.cashflow.data.repository.TransactionRepository
import com.yourcompany.cashflow.ui.viewmodel.TransactionViewModel
import io.mockk.coVerify
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.setMain
import org.junit.Before
import org.junit.Rule
import org.junit.Test

@ExperimentalCoroutinesApi
class TransactionViewModelTest {

    @get:Rule
    val mockkRule = MockKRule(this) // 啟用 MockK

    @RelaxedMockK // 建立一個假的 Repository
    private lateinit var repository: TransactionRepository

    private lateinit var viewModel: TransactionViewModel

    @Before
    fun setUp() {
        Dispatchers.setMain(StandardTestDispatcher()) // 設定一個測試用的主執行緒
        viewModel = TransactionViewModel(repository)
    }

    @Test
    fun `insertTransaction should call repository`() {
        // 1. 準備 (Arrange)
        val testTransaction = Transaction(/*... 假資料 ...*/)

        // 2. 執行 (Act)
        viewModel.insertTransaction(testTransaction)

        // 3. 驗證 (Assert)
        // 驗證 repository 的 insertTransaction 方法是否被呼叫了,且傳入的參數是 testTransaction
        coVerify { repository.insertTransaction(testTransaction) }
    }
}

2. UI 測試 (Compose Tests)

  • 測什麼? 測試 Composable 是否正確顯示,以及與其互動是否如預期。

  • 放哪裡? app/src/androidTest/java/... (因為需要 Android 環境)

  • 工具: Compose Test Rule (createComposeRule)。

  • 範例 (TransactionItem 測試):

Kotlin
// 在 app/src/androidTest/java/.../components/TransactionItemTest.kt
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.yourcompany.cashflow.data.model.TransactionWithDetails // ... 匯入假資料
import com.yourcompany.cashflow.ui.components.TransactionItem
import com.yourcompany.cashflow.ui.theme.CashFlowTheme
import org.junit.Rule
import org.junit.Test

class TransactionItemTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun transactionItem_displaysDataCorrectly() {
        // 1. 準備 (Arrange)
        val fakeDetails = TransactionWithDetails(/*... 你的假資料 ...*/)

        // 2. 執行 (Act)
        composeTestRule.setContent {
            CashFlowTheme {
                TransactionItem(transactionDetails = fakeDetails)
            }
        }

        // 3. 驗證 (Assert)
        // 驗證子類別名稱和金額是否顯示在畫面上
        composeTestRule.onNodeWithText(fakeDetails.subCategory.name).assertIsDisplayed()
        composeTestRule.onNodeWithText("%,.0f".format(fakeDetails.transaction.amount)).assertIsDisplayed()
    }
}

3. 整合測試 (Integration Tests)

  • 測什麼? 測試多個元件協同工作的場景,例如「點擊按鈕 -> 開啟新畫面 -> 儲存資料 -> 驗證資料庫」。

  • 放哪裡? app/src/androidTest/java/...

  • 工具: Compose Test Rule, Hilt Test Support (@HiltAndroidTest)。

  • 概念: 這種測試最複雜也最耗時。你會使用 Hilt 的測試功能來替換掉真實的資料庫,改用一個「記憶體內的假資料庫」,然後模擬使用者的完整操作流程,最後驗證 UI 狀態或假資料庫的內容是否正確。

測試類型目的位置主要工具速度
單元測試驗證獨立的業務邏輯testJUnit, MockK最快
UI 測試驗證 Composable 的顯示與互動androidTestCompose Test Rule中等
整合測試驗證完整的用戶流程androidTestHilt Test, Espresso最慢

建議: 從單元測試和 UI 測試開始。它們涵蓋了大部分場景,能給你帶來最大的信心。當你熟悉後,再挑戰更複雜的整合測試。