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
高風險檔案盤點
# Top 20 修改頻繁但無測試的檔案
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(鎖現狀)
// 1. 寫一個故意失敗的測試
@Test fun `calculateDiscount(100, VIP) returns ?`() {
val r = legacyCalc.calculateDiscount(100.0, "VIP")
assertThat(r).isEqualTo(0.0) // 故意錯
}
// 2. 跑測試,記下實際值(例如 15.0)
// 3. 改成正確的 expected
@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)
// gradle
testImplementation("org.robolectric:robolectric:4.13")
// build.gradle.kts
android {
testOptions {
unitTests {
isIncludeAndroidResources = true
all {
it.systemProperty("robolectric.graphicsMode", "NATIVE") // 啟用 Native Graphics
}
}
}
}
@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") }
}
// relaxed: 自動為未 stub 方法回傳預設值,適合舊代碼大量無關依賴
val legacy = mockk<LegacyHelper>(relaxed = true)
避免在 ViewModel 測試中 mock 純 data class 或 use case 簡單實作;mock 越淺越好。
Roborazzi(推薦:JVM 上對 Compose 截圖)
// gradle
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 # CI 比對
./gradlew :app:compareRoborazziDebug # 產 diff 報告
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 內建)
// gradle
android {
experimentalProperties["android.experimental.enableScreenshotTest"] = true
}
dependencies {
screenshotTestImplementation(libs.compose.ui.tooling)
}
// src/screenshotTest/kotlin/.../MembershipPreviews.kt
@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
// build.gradle.kts
android.lint { baseline = file("lint-baseline.xml") }
detekt { baseline = file("$rootDir/config/detekt/baseline.xml") }
收斂計畫
# scripts/baseline-shrink.sh — 跑在 PR 之外的 cron
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 視覺截圖雙保險)。
覆蓋率量化目標
// koverage 報告(推薦 Kover 2.x)
plugins { id("org.jetbrains.kotlinx.kover") version "0.8.3" }
kover {
reports {
verify {
rule {
bound { minValue = 50 } // overall
}
rule("Critical paths") {
filters { includes { classes("com.example.checkout.**", "com.example.auth.**") } }
bound { minValue = 80 }
}
}
}
}
./gradlew koverVerify # 失敗則 CI 阻擋
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
- [ ] 高風險檔案盤點完成(churn × no test × business critical)
- [ ] 重構前先 Characterization Tests 鎖定現狀
- [ ] Robolectric 4.13+ 含 Native Graphics 啟用
- [ ] MockK relaxed 用於舊代碼,避免 stub 爆量
- [ ] Roborazzi(Compose)或 Paparazzi(View)做視覺迴歸
- [ ] Compose Preview Screenshot Test 已啟用,
@Preview自動轉測試 - [ ] Detekt + Lint baseline 凍結,每 sprint 減 N 條
- [ ] Golden Master 用於 HTML/JSON/PDF
- [ ] Kover gate:critical path ≥ 80% / overall ≥ 50%
- [ ] PR 阻擋 baseline 條目數增加
TDD Red-Green-Refactor
Testing
Skill qui guide Claude a travers le cycle TDD complet.
Audit d'Accessibilité Web
Testing
Réalise un audit d'accessibilité web complet selon les normes WCAG.
Générateur de Tests UAT
Testing
Génère des cas de test d'acceptation utilisateur structurés et complets.