| id | testing_legacy_strategies |
| name | Testing Legacy Strategies |
| description | 為無測試的 Android 舊代碼建立安全網 — Characterization Tests、Robolectric Native Graphics、MockK、Compose Preview Screenshot Test、Roborazzi/Paparazzi 視覺迴歸、Detekt/Lint baseline、Golden Master、覆蓋率量化目標 |
| type | skill |
Testing Legacy Strategies
Instructions
當需要在無測試或測試稀少的舊代碼上建立安全網(通常為了重構或加新功能)時載入。本 skill 不教 TDD(新代碼正規 TDD 不在範圍);本 skill 是「先把現狀凍結,才能安全動」的策略。
When to Use
- Scenario C:舊專案現代化前的安全網
- Scenario F:KMP commonMain 的測試
- 加入 Compose Preview Screenshot Test 防 UI 退化
- 對複雜輸出(HTML、JSON、PDF)做 Golden Master
- 用 Roborazzi 對 Compose 做視覺迴歸(無需裝置)
When NOT to Use
- 新代碼的 TDD(不在本 skill 範圍)
- CI workflow 設定 →
@release_automation
- Hilt 測試替換策略 →
@dependency_injection_mastery
- 效能 benchmark →
@deep_performance_tuning
Example Prompts
- 「這個 calc 沒有測試,重構前要先 lock 現狀,怎麼開始?」
- 「Robolectric 跑 Compose Preview 截圖,怎麼設?」
- 「Roborazzi vs Paparazzi 該選哪個?我們大多 Compose」
- 「Lint baseline 已經 1.2MB,怎麼有秩序地縮減?」
- 「金本位測試(Golden Master)的 diff 工具怎麼設」
Workflow
- 盤點高風險檔案(修改頻率 × 無測試 × 業務關鍵)。
- Characterization Tests 鎖定現狀(不論對錯)。
- Robolectric / MockK 處理 Framework 與依賴。
- Visual Regression(Roborazzi / Paparazzi)覆蓋 UI states。
- Compose Preview Screenshot Test 把
@Preview 自動轉測試。
- Baseline + 收斂計畫:detekt baseline 凍結,每季減 N 條。
- 覆蓋率門檻:critical path ≥ 80%、整體 ≥ 50%。
Practical Notes (2026-04)
| 工具 | 版本 | 用途 |
|---|
| Robolectric | 4.13+ | Native Graphics 模式可 render Compose |
| MockK | 1.13+ | Coroutines 支援、relaxed mock |
| Roborazzi | 1.30+ | JVM 上對 Compose 截圖(推薦) |
| Paparazzi | 1.3+ | Square 老牌、純 JVM 截圖 |
| Compose Preview Screenshot Test | AGP 8.5+ 內建 | @Preview 自動轉 screenshot test |
| Truth | 1.4+ | 比 JUnit assertions 易讀 |
| Turbine | 1.1+ | Flow 測試 |
Minimal Template
高風險檔案清單: <Top 10 churn × no test>
Characterization 覆蓋: <按檔案逐個鎖定>
Visual Regression: Roborazzi - <screen list>
Coverage 目標: critical path 80%, overall 50%
Baseline 收斂: detekt -10 / lint -50 per sprint
驗收: Quick Checklist
高風險檔案盤點
git log --since='1 year ago' --pretty=format: --name-only \
| grep '\.kt$' | sort | uniq -c | sort -rn | head -50 \
| while read count file; do
test_path=$(echo "$file" | sed 's|/main/|/test/|;s|\.kt$|Test.kt|')
[ -f "$test_path" ] || echo "$count $file"
done | head -20
優先補測:churn ≥ 5 / 年 + 無對應 test file + 業務關鍵路徑。
Characterization Tests(鎖現狀)
@Test fun `calculateDiscount(100, VIP) returns ?`() {
val r = legacyCalc.calculateDiscount(100.0, "VIP")
assertThat(r).isEqualTo(0.0)
}
@Test fun `calculateDiscount(100, VIP) returns 15`() {
assertThat(legacyCalc.calculateDiscount(100.0, "VIP")).isEqualTo(15.0)
}
批量化:
class DiscountCharacterizationTest {
@ParameterizedTest
@CsvSource(
"100.0, VIP, 15.0",
"100.0, REGULAR, 5.0",
"50.0, VIP, 7.5",
"0.0, VIP, 0.0",
"-100.0, VIP, 0.0", // 即使是 bug,先鎖定
)
fun characterization(price: Double, tier: String, expected: Double) {
assertThat(legacyCalc.calculateDiscount(price, tier)).isEqualTo(expected)
}
}
規則:先鎖再修。哪怕現狀是 bug,也先 lock,重構後改測試 + 改實作同 PR。
Robolectric(含 Native Graphics)
testImplementation("org.robolectric:robolectric:4.13")
android {
testOptions {
unitTests {
isIncludeAndroidResources = true
all {
it.systemProperty("robolectric.graphicsMode", "NATIVE")
}
}
}
}
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35], qualifiers = "w400dp-h800dp")
class LegacyActivityTest {
@Test fun `activity displays correct title`() {
val activity = Robolectric.buildActivity(LegacyActivity::class.java).setup().get()
assertThat(activity.title).isEqualTo("Expected")
}
@Test fun `toast shown on error`() {
activity.showError("oops")
assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo("oops")
}
}
Native Graphics 模式可實際 render Bitmap,是 Roborazzi 與 Compose Preview Screenshot Test 的前置。
MockK
@Test fun `repository returns cached`() {
val repo = mockk<UserRepository>()
every { repo.getUser("123") } returns User("123", "Alice")
assertThat(repo.getUser("123").name).isEqualTo("Alice")
verify { repo.getUser("123") }
}
@Test fun `suspend mocking`() = runTest {
val api = mockk<UserApi>()
coEvery { api.fetch("123") } returns User("123", "Alice")
api.fetch("123")
coVerify(exactly = 1) { api.fetch("123") }
}
val legacy = mockk<LegacyHelper>(relaxed = true)
避免在 ViewModel 測試中 mock 純 data class 或 use case 簡單實作;mock 越淺越好。
Roborazzi(推薦:JVM 上對 Compose 截圖)
plugins { id("io.github.takahirom.roborazzi") version "1.30.0" }
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.30.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.30.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-junit-rule:1.30.0")
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35])
class MembershipScreenScreenshotTest {
@get:Rule val composeRule = createComposeRule()
@get:Rule val roborazziRule = RoborazziRule(composeRule, RoborazziOptions())
@Test fun loaded() {
composeRule.setContent {
ModernAppTheme { MembershipScreen(state = MembershipState.loaded()) }
}
composeRule.onRoot().captureRoboImage("MembershipScreen_loaded.png")
}
@Test fun loading() {
composeRule.setContent {
ModernAppTheme { MembershipScreen(state = MembershipState.loading()) }
}
composeRule.onRoot().captureRoboImage("MembershipScreen_loading.png")
}
}
./gradlew :app:recordRoborazziDebug
./gradlew :app:verifyRoborazziDebug
./gradlew :app:compareRoborazziDebug
CI 上 diff > threshold 阻擋 merge;diff 報告 upload 為 artifact。
Roborazzi vs Paparazzi
| Roborazzi | Paparazzi |
|---|
| 引擎 | Robolectric Native Graphics | Square Layoutlib |
| Compose 支援 | 原生 | 需 wrapper |
| 與 androidTest 一致性 | 高 | 中 |
| Layoutlib 升級延遲 | 低 | 略高 |
| 推薦 | 大多 Compose 專案 | 純 View 老專案 |
Compose Preview Screenshot Test(AGP 內建)
android {
experimentalProperties["android.experimental.enableScreenshotTest"] = true
}
dependencies {
screenshotTestImplementation(libs.compose.ui.tooling)
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
@Composable
private fun MembershipScreenLoadedPreview() {
ModernAppTheme { MembershipScreen(state = MembershipState.loaded()) }
}
./gradlew :app:updateDebugScreenshotTest
./gradlew :app:validateDebugScreenshotTest
每個 @Preview 自動變一條測試。適合 Design System component library。
Detekt / Lint Baseline 收斂
./gradlew detektBaseline
./gradlew lintDebug -Dlint.baselines.continue=true
android.lint { baseline = file("lint-baseline.xml") }
detekt { baseline = file("$rootDir/config/detekt/baseline.xml") }
收斂計畫
detekt_count=$(grep -c '<ID>' config/detekt/baseline.xml || echo 0)
target=$((detekt_count - 10))
echo "Target this sprint: $detekt_count → $target"
每 sprint 減 N 條;CI 阻擋 baseline 條目 增加。
Golden Master Testing
@Test fun `report generator html stable`() {
val output = legacyReportGenerator.generate(testData)
val golden = File("src/test/resources/golden/report.html").readText()
assertThat(output).isEqualTo(golden)
}
更新時:
UPDATE_GOLDEN=1 ./gradlew :reporting:test
if (System.getenv("UPDATE_GOLDEN") == "1") goldenFile.writeText(output)
else assertThat(output).isEqualTo(goldenFile.readText())
JSON / PDF 適用同樣模式(PDF 取 normalize 後文字 + Roborazzi 視覺截圖雙保險)。
覆蓋率量化目標
plugins { id("org.jetbrains.kotlinx.kover") version "0.8.3" }
kover {
reports {
verify {
rule {
bound { minValue = 50 }
}
rule("Critical paths") {
filters { includes { classes("com.example.checkout.**", "com.example.auth.**") } }
bound { minValue = 80 }
}
}
}
}
./gradlew koverVerify
Cross-Skill References
@tech_stack_migration:遷移前的安全網來源(兩個 skill 互補)。
@legacy_rapid_expansion:島內新代碼用正規 TDD,不在本 skill 範圍。
@dependency_injection_mastery:Hilt @TestInstallIn / @UninstallModules 替換策略。
@release_automation:把 detekt baseline、koverage gate 接到 CI。
@deep_performance_tuning:Macrobenchmark 的測試設定(不是本 skill 範圍)。
Quick Checklist