集成测试专题:Starter、Driver 与 UI 自动化¶
本章补齐官方 Integration Tests 教程中没有在基础测试章节展开的部分:如何启动真实 IDE、安装待测插件、打开真实项目、通过 Driver 调用 IDE 进程内 API,以及怎样用 UI DSL 做稳定的界面自动化。
集成测试成本明显高于 fixture/light tests。它适合验证“插件装进真实 IDE 后是否能跑完整用户故事”,不适合替代所有模型级测试。常规策略是:大量行为用 light/heavy fixture 覆盖,少量关键路径用 integration/UI tests 做端到端门禁。
什么时候写集成测试¶
优先使用集成测试的场景:
- 插件启动、加载和
plugin.xml声明必须在完整产品里验证。 - 需要打开真实项目,等待索引、外部系统导入、后台任务或 Project Activity。
- 需要覆盖 Settings、Tool Window、Dialog、Action、Popup、Search Everywhere 等真实 UI 流程。
- 需要验证插件与多个平台组件协作,例如 VFS、索引、Run Configuration、Debugger、Remote Development。
- 需要在 CI 上复现“本地 fixture 测不到,但用户 IDE 会触发”的启动异常、冻结或类加载问题。
不建议把普通 PSI、completion、inspection、quick fix、formatter 测试都迁移到集成测试。它们更慢、更依赖机器环境,也更容易受 UI 文案和平台布局变化影响。
Starter 与 Driver 架构¶
官方集成测试框架由两个核心部分组成:
| 组件 | 责任 |
|---|---|
| Starter | 配置 IDE、准备测试项目、安装插件、启动 IDE、收集日志和输出 |
| Driver | 与运行中的 IDE 进程通信,执行 UI 操作、JMX/RMI API 调用和等待条件 |
测试进程和 IDE 进程是两个独立 JVM:
- 测试进程负责创建上下文、启动 IDE、发送命令、做断言。
- IDE 进程加载真实 IntelliJ Platform、待测插件、项目和 UI。
- Driver 通过通信层让测试进程访问 IDE 进程中的对象。
- 因为异常发生在另一个进程中,必须明确收集 IDE 日志和错误,否则测试可能只验证“测试代码没有失败”。
Starter 目前依赖 JUnit 5 扩展和监听器;新的集成测试任务应使用 JUnit Platform。
Gradle 2.x 配置¶
建议把集成测试放在独立 source set,避免和普通 test 混在一起:
sourceSets {
create("integrationTest") {
compileClasspath += sourceSets.main.get().output
runtimeClasspath += sourceSets.main.get().output
}
}
val integrationTestImplementation by configurations.getting {
extendsFrom(configurations.testImplementation.get())
}
dependencies {
intellijPlatform {
testFramework(
org.jetbrains.intellij.platform.gradle.TestFrameworkType.Starter,
configurationName = "integrationTestImplementation",
)
}
integrationTestImplementation("org.junit.jupiter:junit-jupiter:5.7.1")
integrationTestImplementation("org.kodein.di:kodein-di-jvm:7.20.2")
integrationTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.1")
}
val integrationTest by intellijPlatformTesting.testIdeUi.registering {
task {
val integrationTestSourceSet = sourceSets.getByName("integrationTest")
testClassesDirs = integrationTestSourceSet.output.classesDirs
classpath = integrationTestSourceSet.runtimeClasspath
useJUnitPlatform()
}
}
这段配置完成几件事:
- 创建
src/integrationTest源集。 - 为该源集加入 Starter/Driver 测试框架。
- 创建
integrationTest任务。 - 让任务使用 JUnit 5。
- 让 IDE 测试在插件构建产物准备好之后运行。
如果 CI 只想先跑普通测试,可以把集成测试放到 nightly、release candidate 或手动 workflow;如果插件 UI 风险很高,则至少把一条 smoke test 放进 PR 门禁。
第一个启动测试¶
最小测试只做三件事:启动 IDE、安装插件、正常关闭。
import com.intellij.ide.starter.ide.IdeProductProvider
import com.intellij.ide.starter.models.TestCase
import com.intellij.ide.starter.plugins.PluginConfigurator
import com.intellij.ide.starter.project.NoProject
import com.intellij.ide.starter.runner.Starter
import org.junit.jupiter.api.Test
import java.nio.file.Path
class PluginStartupTest {
@Test
fun startsIdeWithPluginInstalled() {
Starter.newContext(
testName = "startsIdeWithPluginInstalled",
TestCase(
IdeProductProvider.IC,
projectInfo = NoProject,
).withVersion("2024.3"),
).apply {
val pluginPath = System.getProperty("path.to.build.plugin")
PluginConfigurator(this).installPluginFromDir(Path.of(pluginPath))
}.runIdeWithDriver().useDriverAndCloseIde {
// Smoke test: IDE starts, plugin loads, and shutdown succeeds.
}
}
}
这类测试能尽早发现:
- 插件 ZIP 或 sandbox 打包错误。
plugin.xml依赖缺失。- 目标 IDE 版本无法加载插件。
- 插件启动时抛异常。
- 类加载器隔离导致的运行时问题。
第一次运行会下载目标 IDE,耗时较长;之后通常复用缓存。
打开真实项目¶
真实插件通常需要项目上下文。Starter 支持多种项目来源:
| 来源 | 适合场景 |
|---|---|
NoProject |
Welcome Screen、全局 Settings、插件启动 smoke test |
| GitHub 项目 | 复现开源项目上的真实行为 |
| 远程归档 | CI 中下载固定测试工程 |
| 本地目录 | 仓库内维护小型 fixtures project |
示例:
@Test
fun opensRealProject() {
Starter.newContext(
"opensRealProject",
TestCase(
IdeProductProvider.IC,
GitHubProject.fromGithub(
branchName = "master",
repoRelativeUrl = "JetBrains/ij-perf-report-aggregator",
),
).withVersion("2024.3"),
).apply {
val pluginPath = System.getProperty("path.to.build.plugin")
PluginConfigurator(this).installPluginFromDir(Path.of(pluginPath))
}.runIdeWithDriver().useDriverAndCloseIde {
waitForIndicators(5.minutes)
}
}
waitForIndicators() 很重要。没有等待索引、导入和后台任务完成就做 UI/API 断言,是集成测试最常见的不稳定来源之一。
让 IDE 进程异常变成测试失败¶
因为 IDE 运行在另一个进程,IDE 内部异常不会天然抛回测试线程。官方 Starter 会收集 IDE 侧错误,再交给 CIServer 报告。对非 TeamCity 环境,建议覆盖默认实现,让异常直接让测试失败:
init {
di = DI {
extend(di)
bindSingleton<CIServer>(overrides = true) {
object : CIServer by NoCIServer {
override fun reportTestFailure(
testName: String,
message: String,
details: String,
linkToLogs: String?,
) {
fail("$testName fails: $message\n$details")
}
}
}
}
}
CI 失败时应保留:
- IDE log。
- test process log。
- screenshots 或 UI tree dump。
- sandbox 目录。
- 失败项目副本。
这比只看 JUnit 断言栈更可靠。
API Interaction:用 Driver 调 IDE API¶
Driver 的 API interaction 基于 JMX/RMI。测试进程创建远程接口 stub,IDE 进程执行真实对象方法。
适用场景:
- 验证插件 service 的状态。
- 查询项目、模块、文件、索引状态。
- 触发只在 IDE 进程内可访问的 API。
- 在 UI 操作前后做内部状态断言。
生产代码示例:
package com.example.demo
import com.intellij.openapi.components.Service
object PluginStorage {
@JvmStatic
fun getPluginStorage() = Storage(
key = "static method",
attributes = listOf("static1", "static2"),
)
}
@Service
class PluginService {
fun getAnswer(): Int = 42
}
@Service(Service.Level.PROJECT)
class PluginProjectService {
fun getStrings(): Array<String> = arrayOf("foo", "bar")
}
data class Storage(
val key: String,
val attributes: List<String>,
)
测试侧 stub:
import com.intellij.driver.client.Remote
@Remote("com.example.demo.PluginStorage", plugin = "com.example.demo")
interface PluginStorage {
fun getPluginStorage(): Storage
}
@Remote("com.example.demo.PluginService", plugin = "com.example.demo")
interface PluginService {
fun getAnswer(): Int
}
@Remote("com.example.demo.PluginProjectService", plugin = "com.example.demo")
interface PluginProjectService {
fun getStrings(): Array<String>
}
@Remote("com.example.demo.Storage", plugin = "com.example.demo")
interface Storage {
fun getKey(): String
fun getAttributes(): List<String>
}
调用方式:
runIdeWithDriver().useDriverAndCloseIde {
val storage = utility<PluginStorage>().getPluginStorage()
assertEquals("static method", storage.getKey())
assertEquals(listOf("static1", "static2"), storage.getAttributes())
assertEquals(42, service<PluginService>().getAnswer())
waitForProjectOpen()
val project = singleProject()
assertArrayEquals(
arrayOf("foo", "bar"),
service<PluginProjectService>(project).getStrings(),
)
}
使用 stub 时注意:
@Remote的类名用字符串,避免测试代码直接依赖生产类。plugin参数必须是插件 ID,因为 IDE 为每个插件使用独立 class loader。- 只为测试会调用的方法创建 stub,不要把生产 API 整体镜像一遍。
service()适合 Application/Project Service;utility()适合普通类或静态入口。- proxy 不必缓存,每次需要时获取即可。
JMX/RMI 限制¶
远程调用不是普通本地调用,接口设计要受限:
| 限制 | 实践建议 |
|---|---|
| 只能调用 public 方法 | 给测试暴露小而明确的读取方法 |
| 参数和返回值类型有限 | 使用 primitive、String、数组、List、@Remote 引用 |
| 不能直接调用 suspend 方法 | 在 IDE 侧包一层同步测试入口,或用测试事件等待结果 |
| 每次调用涉及序列化和跨进程通信 | 不要在循环里大量调用细粒度 getter |
| class loader 受插件 ID 影响 | @Remote(..., plugin = "...") 必须准确 |
如果需要检查大量内部状态,优先在 IDE 侧提供一个聚合 DTO 或只返回断言所需的最小数据。
UI Testing:组件层级优先¶
IntelliJ IDE 的 UI 主要基于 Swing/AWT,部分场景使用 JCEF。Driver UI DSL 按组件层级查找元素,类似在 DOM 中逐层定位。
推荐写法:
ideFrame {
invokeAction("SearchEverywhere")
searchEverywherePopup {
actionButtonByXpath(
xQuery { byAccessibleName("Preview") },
).click()
}
}
不推荐直接在整个 IDE frame 下搜同名组件:
ideFrame {
actionButtonByXpath(xQuery { byAccessibleName("Preview") }).click()
}
短写法可读性弱,也可能误点 Tool Window、Project View、Toolbar 或其他 Popup 里的同名按钮。UI 测试越接近用户流程,越要把“从哪个窗口进入、在哪个弹窗里找、点击哪个组件”写清楚。
查找组件¶
官方 Driver 提供了常用组件封装,例如 ideFrame、codeEditor、button、tree。遇到自定义 UI 时,可以让测试暂停,打开远程 Driver 页面检查 Swing 组件树:
Starter.newContext(...)
.apply { ... }
.runIdeWithDriver()
.useDriverAndCloseIde {
Thread.sleep(30.minutes.inWholeMilliseconds)
}
运行日志里会出现类似 http://localhost:63343/api/remote-driver/ 的地址。打开后可以查看组件属性。
定位优先级:
| 属性 | QueryBuilder 方法 | 稳定性 |
|---|---|---|
| accessible name | byAccessibleName() |
高,推荐优先补齐无障碍名称 |
| visible text | byVisibleText() |
中,受本地化和文案调整影响 |
| Java class | byType() |
中,受平台内部实现变化影响 |
| icon/自定义属性 | byAttribute() |
视具体组件而定 |
组合条件能减少误匹配:
xQuery {
and(
byAccessibleName("Current File"),
byVisibleText("Current File"),
)
}
UI 交互与断言¶
常见交互:
ideFrame {
x(xQuery { byVisibleText("Current File") }).click()
}
键盘输入:
keyboard {
enterText("Sample text")
enter()
hotKey(
if (SystemInfo.isMac) KeyEvent.VK_META else KeyEvent.VK_CONTROL,
KeyEvent.VK_A,
)
backspace()
}
键盘操作基于 java.awt.Robot。要先让目标组件获得焦点,最可靠的方式通常是先点击该组件。macOS 上还需要给 IntelliJ IDEA 授予 Accessibility 权限,否则 Robot 交互会失败或无效。
断言时不要只执行查找表达式。很多 UI 引用是 lazy 的,真正查找发生在 click()、shouldBe()、属性读取等动作上:
ideFrame {
x(xQuery { byVisibleText("Current File") }).click()
val configurations = popup().jBlist(
xQuery { contains(byVisibleText("Edit Configurations")) },
)
configurations.shouldBe("Configuration list is not present", present)
assertTrue(
configurations.rawItems.contains("backup-data"),
"Configurations list doesn't contain 'backup-data': ${configurations.rawItems}",
)
}
失败信息要带上当前列表、当前文本或当前状态,不要只写 assertTrue(false) 式消息。
与 runIdeForUiTests 的关系¶
IntelliJ Platform Gradle Plugin 还提供 runIdeForUiTests 任务模式,用于启动带 Robot Server 插件的 IDE。该任务默认不会自动创建,需要手动注册:
val runIdeForUiTests by intellijPlatformTesting.runIde.registering {
task {
jvmArgumentProviders += CommandLineArgumentProvider {
listOf(
"-Drobot-server.port=8082",
"-Dide.mac.message.dialogs.as.sheets=false",
"-Djb.privacy.policy.text=<!--999.999-->",
"-Djb.consents.confirmation.enabled=false",
)
}
}
plugins {
robotServerPlugin()
}
}
选择原则:
| 方式 | 适合场景 |
|---|---|
intellijPlatformTesting.testIdeUi + Starter/Driver |
新集成测试主线,适合 JUnit 5 自动化和 IDE 生命周期管理 |
runIdeForUiTests + Robot Server |
需要外部 UI 自动化客户端、人工调试或兼容旧 UI 测试框架 |
普通 runIde |
本地手工调试,不作为自动化门禁 |
新项目优先用 Starter/Driver;已有 Remote Robot 资产的项目可以逐步迁移,不必一次性重写。
CI 分层建议¶
建议把测试门禁分层:
| 阶段 | 命令 | 目标 |
|---|---|---|
| PR 快速门禁 | ./gradlew test、./gradlew buildPlugin、./gradlew verifyPlugin |
基础正确性和兼容性 |
| PR smoke | ./gradlew integrationTest --tests '*Startup*' |
插件能装进目标 IDE 并启动 |
| Nightly | 完整 integrationTest matrix |
多 IDE、多项目、多 UI 路径 |
| Release candidate | runPluginVerifier + 完整集成测试 |
发布前回归 |
CI 要固定:
- Gradle JVM 和 JBR。
- 目标 IDE 产品和版本。
- 测试项目版本或归档 hash。
- sandbox、缓存和日志保留策略。
- macOS UI 自动化权限说明。
稳定性清单¶
- 能用 fixture 测的逻辑不要上 UI 集成测试。
- UI 测试只覆盖关键路径,避免把每个按钮都测一遍。
- 等待明确条件,不用裸
sleep()作为长期方案。 - 打开项目后等待 background indicators、索引和必要的 Project Activity。
- 使用 accessible name 或稳定容器层级定位,不依赖坐标。
- 避免依赖主题、屏幕分辨率、字体渲染和平台默认窗口大小。
- 失败时输出当前 UI 列表内容、组件属性、日志路径和截图。
- 在 CI 中把 IDE 侧异常视为测试失败。
- 对本地化敏感的 UI,用 accessible name 或 action ID 降低文案波动。
- 集成测试 matrix 先少后多,避免 PR 被慢测试拖垮。
与现有测试章节的分工¶
| 章节 | 主要回答 |
|---|---|
| 插件测试 | 如何理解 IntelliJ Platform 插件测试,以及 fixture 测试的基本写法 |
| 测试与 CI | 测试分层、light/heavy、testdata、highlighting、CI 门禁 |
| 本章 | Starter/Driver 集成测试、UI 自动化、JMX/RMI API interaction、真实 IDE 流程 |
如果一个测试可以在 BasePlatformTestCase 或 LightJavaCodeInsightFixtureTestCase 中稳定完成,就不应升级到本章的集成测试形态。