Testing Legacy Strategies (遗留代码测试)
Instructions
-
仅在缺乏测试的既有代码上使用
-
先填写 Required Inputs(高风险路径、测试栈、阻挡策略)
-
依照下方章节顺序创建安全网
-
一次只锁定一类行为或输出
-
完成后对照 Quick Checklist
When to Use
-
Scenario C:旧项目现代化前的安全网
-
Scenario F:共享逻辑需要测试保障
Example Prompts
-
"请依照 Characterization Tests 章节,替这个类别创建现状测试"
-
"用 Robolectric 章节,为依赖 Framework 的 Activity 写测试"
-
"请用 Detekt/Lint Baseline 章节创建技术债控管"
Workflow
-
先确认 Required Inputs(风险路径、测试优先级、CI 门槛)
-
创建 Characterization Tests 锁定行为
-
补齐 Framework 测试与 MockK 策略
-
建立 Baseline 与 CI 门禁,避免技术债回流
-
执行 Legacy Test Gate 并用 Quick Checklist 验收
Practical Notes (2026)
-
测试输出必须可重复与可比对
-
每次只锁定一类行为,避免测试爆量
-
Baseline 逐步收敛,避免一次性大改
-
先覆盖高风险业务,再扩展到普通路径
-
flaky case 必须标记 owner 与修复截止时间
Environment & Compatibility (先确认)
-
在开始前先记录测试栈:AGP / Kotlin / JUnit4 or JUnit5 / Robolectric / MockK / kotlinx-coroutines-test
-
同一模块避免混搭两套测试 runner;若必须混搭,先确认执行入口与依赖冲突
-
Robolectric 版本需与项目 compileSdk 与 AGP 组合先做 smoke test
-
所有示例版本号以项目 libs.versions.toml 或既有依赖锁定为准
-
若版本未定,请先完成 1 个最小可执行测试再批量铺开
Minimal Template
目标: 测试范围: 高风险路径: CI 阻挡门槛: 行为锁定: 回归方式: 验收: Quick Checklist
Required Inputs (执行前输入)
-
高风险路径 (金流/登录/权限/写入)
-
测试栈版本 (JUnit/MockK/Robolectric/coroutines-test)
-
CI 阻挡策略 (哪些失败阻挡合并)
-
Baseline 策略 (是否允许、收敛计划)
-
责任人 (测试 owner)
Deliverables (完成后交付物)
-
Characterization tests 列表
-
Framework 相关测试 (Robolectric/Instrumented)
-
Baseline 文件与收敛计划
-
CI 测试门禁 配置
-
Legacy Test Gate 验收记录
Legacy Test Gate (验收门槛)
./gradlew test ./gradlew connectedDebugAndroidTest
PR 需要说明新增测试覆盖了哪些高风险行为。
Characterization Tests (现状测试)
为没有测试的旧代码撰写「现状测试」,不管对错,先锁定行为。
样本选择顺序 (避免盲目铺量)
-
先测高风险路径:金流、登录、权限、数据写入
-
再测边界条件:null 、空集合、最小/最大值、异常输入
-
最后补常见主路径:最常被调用的 20% 场景
-
每轮只新增一类行为,确保失败原因单一可诊断
策略
// 1. 先写一个会失败的测试
@Test
fun calculateDiscount returns unknown value() {
val result = legacyCalculator.calculateDiscount(100.0, "VIP")
assertEquals(0.0, result) // 故意用错误的预期值
}
// 2. 运行测试,记录实际回传值 // AssertionError: expected 0.0 but was 15.0
// 3. 更新测试为实际值
@Test
fun calculateDiscount returns 15 percent for VIP() {
val result = legacyCalculator.calculateDiscount(100.0, "VIP")
assertEquals(15.0, result) // 锁定现有行为
}
批量生成
@ParameterizedTest
@CsvSource(
"100.0, VIP, 15.0",
"100.0, REGULAR, 5.0",
"50.0, VIP, 7.5"
)
fun calculateDiscount characterization(
price: Double,
tier: String,
expected: Double
) {
assertEquals(expected, legacyCalculator.calculateDiscount(price, tier))
}
Robolectric (Android Framework 测试)
处理高度依赖 Android Framework 的旧单元测试。
设置
// build.gradle.kts testImplementation("org.robolectric:robolectric:<project-verified-version>")
// 测试类别 @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) class LegacyActivityTest { private lateinit var activity: LegacyActivity
@Before
fun setUp() {
activity = Robolectric.buildActivity(LegacyActivity::class.java)
.setup()
.get()
}
@Test
fun `activity displays correct title`() {
assertEquals("Expected Title", activity.title)
}
}
Shadow 使用
@Test
fun shows toast on error() {
activity.showError("Network failed")
assertEquals("Network failed", ShadowToast.getTextOfLatestToast())
}
MockK for Kotlin
基本用法
@Test
fun repository returns cached data() {
val repository = mockk<UserRepository>()
every { repository.getUser("123") } returns User(id = "123", name = "Test")
val result = repository.getUser("123")
assertEquals("Test", result.name)
verify { repository.getUser("123") }
}
Coroutines Support
@Test
fun suspend function mocking() = runTest {
val api = mockk<UserApi>()
coEvery { api.fetchUser("123") } returns User(id = "123", name = "Test")
val result = api.fetchUser("123")
assertEquals("123", result.id)
assertEquals("Test", result.name)
coVerify(exactly = 1) { api.fetchUser("123") }
}
Dispatcher 控制 (降低 flaky)
@OptIn(ExperimentalCoroutinesApi::class) class MainDispatcherRule( private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() ) : TestWatcher() { override fun starting(description: Description) { Dispatchers.setMain(dispatcher) }
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
Relaxed Mocks
// 自动回传默认值,适合旧代码测试 val service = mockk<LegacyService>(relaxed = true)
Detekt/Lint Baseline
渐进式收紧旧项目的品质标准。
生成 Baseline
Detekt
./gradlew detektBaseline
产生 config/detekt/baseline.xml
Lint
./gradlew lintDebug -Dlint.baselines.continue=true
产生 lint-baseline.xml
只检查新代码
// build.gradle.kts android { lint { baseline = file("lint-baseline.xml") } }
detekt { baseline = file("config/detekt/baseline.xml") }
渐进式修复
每个 Sprint 减少 Baseline 中的项目
1. 修复一批问题
2. 重新生成 Baseline
./gradlew detektBaseline
CI 门禁 (防止回流)
PR 常规检查:不允许新增违规
./gradlew detekt lintDebug
建议策略:
1. Baseline 文件只允许在「技术债收敛任务」中变更
2. 业务 PR 若修改 baseline,需额外说明与审批
Golden Master Testing
适合复杂输出 (HTML, JSON) 的旧代码。
@Test
fun report generator produces expected output() {
val output = legacyReportGenerator.generate(testData)
// 首次运行:保存为 golden file
// File("src/test/resources/golden/report.html").writeText(output)
// 后续运行:比对
val golden = File("src/test/resources/golden/report.html").readText()
assertEquals(golden, output)
}
实务建议:
-
先做标准化再比对(时间、时区、随机值、UUID、排序)
-
Golden 文件纳入版本控制并在 PR 中审阅 diff
-
输出过大时改用结构化断言 + 关键片段 golden,降低维护成本
Quick Checklist
-
Required Inputs 已填写并冻结(高风险路径/测试栈/门槛)
-
重构前先撰写 Characterization Tests
-
每次只锁定一类行为,失败原因可单点定位
-
使用 Robolectric 处理 Android 依赖
-
MockK/Coroutine 测试同时验证「调用次数 + 业务结果」
-
使用 MainDispatcherRule 或同等机制固定调度器
-
Detekt/Lint Baseline 控制技术债
-
CI 阻挡新增违规,不让 baseline 扩张
-
每个 Sprint 减少 Baseline 项目
-
固定时区、Locale、随机种子,确保测试可重复
-
对 flaky 测试标记原因与修复期限,不长期跳过
-
Legacy Test Gate 已执行并记录结果