跳转至

集成测试专题: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 提供了常用组件封装,例如 ideFramecodeEditorbuttontree。遇到自定义 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 流程

如果一个测试可以在 BasePlatformTestCaseLightJavaCodeInsightFixtureTestCase 中稳定完成,就不应升级到本章的集成测试形态。

参考来源