JavaFX应用测试的7大常见错误及解决方案

本文详细分析了测试JavaFX应用程序时最常犯的7个错误,包括线程管理、事件处理、像素测试等关键技术问题,并提供了具体的代码示例和解决方案,帮助开发者构建可靠的测试基础。

JavaFX应用测试的7大常见错误

测试JavaFX程序初看可能不太简单。本文描述了测试桌面应用程序时最常见的错误、其原因及解决方案。

范围和基线

使用两个项目演示JavaFX测试能力:RaffleFX和SolfeggioFX。后者除了JavaFX还使用了Spring Boot。

注意这些项目不包含JavaFX依赖项,因为它们基于集成了JavaFX支持的开源Liberica JDK开发。

  • JDK版本:21
  • 使用TestFX作为测试框架
  • 使用RobotFX(TestFX类)与UI交互
  • 其他使用的库和工具:JUnit5、AssertJ、用于CI中无头测试的JavaFX Monocle

错误1:在FX线程外更新UI

JavaFX在应用程序启动时创建应用程序线程,只有该线程可以渲染UI元素。这是JavaFX测试中最常见的陷阱之一,因为测试运行在JUnit线程上,而不是FX应用程序线程上,很容易忘记在FX线程上显式执行特定操作,如写入或读取UI。

查看以下代码片段:

1
2
3
4
List<String> names = List.of("Alice", "Mike", "Linda");
TextArea area = fxRobot.lookup("#text")
                .queryAs(TextArea.class);
area.setText(String.join(System.lineSeparator(), names));

这里,我们尝试在应用程序线程外更新UI。结果,另一个线程被创建并尝试对UI元素执行操作。这导致:

  • 抛出java.lang.IllegalStateException: Not on FX application thread
  • 皮肤内的随机NPE
  • 死锁
  • 永不更新的状态

解决方案: 在FX线程上写入UI以突变控件或触发处理程序。如果使用FxRobot类,可以通过将突变包装在robot.interact(() -> { … })中实现。

1
2
3
4
5
List<String> names = List.of("Alice", "Mike", "Linda");
TextArea area = fxRobot.lookup("#text")
                .queryAs(TextArea.class);
fxRobot.interact(() ->
                area.setText(String.join(System.lineSeparator(), names)));

在FX线程上从UI读取以获取文本、快照像素或查询布局并返回值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private static Color samplePixel(Canvas canvas, Point2D p) throws Exception {
    return WaitForAsyncUtils.asyncFx(() -> {
        WritableImage img = canvas.snapshot(new SnapshotParameters(), null);
        PixelReader pr = img.getPixelReader();
        int x = (int) Math.round(p.getX());
        int y = (int) Math.round(p.getY());
        x = Math.max(0, Math.min(x, (int) canvas.getWidth() - 1));
        y = Math.max(0, Math.min(y, (int) canvas.getHeight() - 1));
        return pr.getColor(x, y);
    }).get();
}

另一方面,输入(如按下、点击或释放)应发生在测试线程上。不要将其包装在robot.interact()中:

1
robot.press(KeyCode.Q);

错误2:错误引导测试和FXML ClassLoader

当将JavaFX/TestFX与Spring Boot等框架结合使用时,很容易错误引导应用程序。问题是TestFX拥有Stage,但Spring拥有bean。因此,如果在不提供TestFX Stage的情况下启动Spring,bean将无法使用它。另一方面,如果直接调用Application.start(…),最终可能会有两个上下文。

另一个错误与使用JavaFX和Spring的情况相关。FXMLLoader使用与Spring不同的类加载器。因此,Spring创建的控制器与FXML请求的控制器不是相同的"类型"。

不正确的引导导致:

  • NoSuchBeanDefinitionException: …Controller即使它是@Component
  • 由于applicationContext为null,自定义FxmlLoader出现随机NPE
  • 堆栈跟踪提到与ClassLoader或"找不到控制器X的bean"相关的异常

解决方案: 在应用程序代码中使FXMLoader使用与Spring相同的类加载器:

1
2
3
4
5
6
public Parent load(String fxmlPath) throws IOException {
    FXMLLoader loader = new FXMLLoader();
    loader.setLocation(getClass().getResource(fxmlPath));
    loader.setClassLoader(getClass().getClassLoader());
    return loader.load();
}

使用@Start连接真实的Stage,并使用依赖注入注入假对象。 如果此代码内部引导Spring,不要调用new FxApplication.start(stage)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ExtendWith(ApplicationExtension.class)
class PianoKeyboardColorTest {
    @Autowired
    private ConfigurableApplicationContext context;
    
    @Start
    public void start(Stage stage) throws Exception {
        FxmlLoader loader = new FxmlLoader(context);
        Parent rootNode = loader.load("/fxml/in-build-keyboard.fxml");
        
        stage.setScene(new Scene(rootNode, 800, 600));
        stage.show();
        WaitForAsyncUtils.waitForFxEvents();
    }
}

错误3:混淆处理程序连接与真实用户输入

当尝试通过直接调用控制器方法来触发UI行为时,测试的是代码连接,而不是用户采取的真实事件路径(如聚焦、点击、按下等)。结果,测试可能通过但遗漏错误。或者,测试可能挂起并失败,因为输入从未触发。

这个问题的另一面是触发在CI中的无头模式下不可能发生的UI事件,如全屏显示。在这种情况下,断言将超时等待永远不会发生的事件。

解决方案: 不要混合集成测试和交互测试,即在UI测试中避免直接调用控制器方法。

使用robot.clickOn()或类似的FxRobot方法来测试用户交互和UI行为:按下/悬停视觉效果等。注意此方法运行在测试线程上,因此不必将其包装在interact()中:

1
2
3
Canvas canvas = robot.lookup("#keyboard").queryAs(Canvas.class);
robot.interact(canvas::requestFocus);
robot.press(KeyCode.Q);

使用button.fire()或类似的控件方法在不依赖真实指针语义的情况下断言处理程序效果。注意这些方法运行在FX线程上,因此必须包装在interact()中:

1
2
Button btn = fxRobot.lookup("#startButton").queryButton();
fxRobot.interact(btn::fire);

通过UI中的变化进行断言,如新场景中节点的存在、标签文本变化、按钮可见性模式,而不是假设服务调用成功:

1
2
WaitForAsyncUtils.waitFor(3, SECONDS,
    () -> robot.lookup("#startPane").tryQuery().isPresent());

在无头模式下,如果平台无法执行如全屏显示等操作,断言代理信号(伪类、按钮状态)。

错误4:与FX事件队列竞争

由于JavaFX是单线程工具包,所有UI事件都发生在FX应用程序线程上,因此动画、布局等事件被排队。如果在队列耗尽之前进行断言,测试的是尚不存在的UI:

  • 触发操作后立即断言,检查在处理程序执行前运行
  • 在新节点尚未附加时,在场景切换后立即查询场景
  • 在JavaFX处于布局中期时,从测试线程读取像素或控件状态

因此,测试根据CPU、CI等不可预测地通过或失败。

解决方案: 对于简单变化,使用WaitForAsyncUtils.waitForFxEvents()完成JavaFX应用程序线程的事件队列:

1
2
3
4
5
6
7
8
9
@Start
public void start(Stage stage) throws Exception {
    FxmlLoader loader = new FxmlLoader(context);
    Parent rootNode = loader.load("/fxml/in-build-keyboard.fxml");
    
    stage.setScene(new Scene(rootNode, 800, 600));
    stage.show();
    WaitForAsyncUtils.waitForFxEvents();
}

当等待可观察结果时,使用WaitForAsyncUtils.waitFor()等待某些条件满足:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Test
void shouldChangeSceneWhenContinueButtonIsClicked(FxRobot fxRobot) throws TimeoutException {
    Parent oldRoot = stage.getScene().getRoot();
    
    Button btn = fxRobot.lookup("#continueButton").queryButton();
    fxRobot.interact(btn::fire);
    
    WaitForAsyncUtils.waitFor(3, TimeUnit.SECONDS,
            () -> stage.getScene().getRoot() != oldRoot);
    
    assertThat(stage.getScene().getRoot()).isNotSameAs(oldRoot);
    assertThat(
            fxRobot.lookup("#startButton")
                    .queryAs(Button.class)).isNotNull();
}

处理动画时应采用相同方法。等待状态改变,而不是动画应该运行的持续时间:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Test
void shouldHideAndDisableButtonsWhenRaffling(FxRobot fxRobot) throws TimeoutException {
    Button start = fxRobot.lookup("#startButton").queryButton();
    Button repeat = fxRobot.lookup("#repeatButton").queryButton();
    
    fxRobot.interact(start::fire);
    
    WaitForAsyncUtils.waitFor(5, TimeUnit.SECONDS, () ->
            WaitForAsyncUtils.asyncFx(() ->
                    repeat.isVisible() && !repeat.isDisabled()
            ).get()
    );
    assertThat(repeat.isVisible()).isFalse();
    assertThat(repeat.isDisabled()).isTrue();
}

错误5:假设跨平台像素完美相等

由于各种原因,JavaFX应用程序中的像素颜色在不同平台上可能略有不同:CI使用Monocle,而Prism SW和笔记本电脑使用GPU管道,或者一台机器使用LCD子像素文本,另一台使用灰度。如果测试在所有平台上评估精确的RGB相等性,测试可能在本地通过,但在CI或另一台本地机器上失败。

解决方案: 不要断言精确的颜色。比较基线vs变化,并允许颜色和像素密度的一定容差。

例如,在SolfeggioFX中,为了测试虚拟钢琴上的键颜色在按下相应键时已改变,我们可以使用Math.round()计算像素索引以容忍HiDPI情况下的分数位置,并使用Math.max()/min()避免在Point2D值靠近边缘时在图像外部采样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private static Color samplePixel(Canvas canvas, Point2D p) throws Exception {
    return WaitForAsyncUtils.asyncFx(() -> {
        WritableImage img = canvas.snapshot(new SnapshotParameters(), null);
        PixelReader pr = img.getPixelReader();
        int x = (int) Math.round(p.getX());
        int y = (int) Math.round(p.getY());
        x = Math.max(0, Math.min(x, (int) canvas.getWidth() - 1));
        y = Math.max(0, Math.min(y, (int) canvas.getHeight() - 1));
        return pr.getColor(x, y);
    }).get();
}

此外,我们可以在比较颜色时允许小的绝对差异:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
private static boolean colorsClose(Color a, Color b) {
    double eps = 0.02; // 容忍小的AA差异(约2%)
    return Math.abs(a.getRed() - b.getRed())   < eps &&
            Math.abs(a.getGreen() - b.getGreen()) < eps &&
            Math.abs(a.getBlue() - b.getBlue())  < eps;
}

@Test
void shouldHighLightPressedKey(FxRobot robot) throws Exception {
    Point2D point = Objects.requireNonNull(centers.get('Q'));
    Color before = samplePixel(canvas, point);
    
    robot.press(KeyCode.Q);
    
    WaitForAsyncUtils.waitFor(1500, TimeUnit.MILLISECONDS,
            () -> !colorsClose(WaitForAsyncUtils.asyncFx(() -> samplePixel(canvas, point)).get(), before));
    
    Color duringPress = samplePixel(canvas, point);
    assertThat(before.equals(duringPress)).isFalse();
}

在形状内部采样像素,而不是靠近边界,以避免边界与背景混合时颜色不同。

在SolfeggioFX中,我们在绘制虚拟钢琴时将每个键的中心存储在Canvas属性中,并在测试中使用此数据在键中心附近采样像素:

1
2
3
4
5
// 生产代码
canvas.getProperties().put("keyCenters", Map<Character, Point2D> centers);

// 测试
Point2D point = Objects.requireNonNull(centers.get('Q'));

错误6:错误配置无头CI

在CI中运行JavaFX测试与标准测试过程不同。测试必须在无头模式下运行,并由Monocle支持,Monocle是JavaFX的Glass窗口组件用于嵌入式系统的实现。但仅仅添加对Monocle的依赖没有太大帮助,本地通过的测试可能由于多种因素在CI中失败:

  • UI测试并行运行
  • 所需模块被锁定,但Monocle反射性地使用com.sun.glass.ui
  • 测试断言无头模式下不存在的平台功能

解决方案: 添加Monocle依赖并设置所有必要标志以在无头模式下运行测试。此外,使用–add-opens打开所需模块。

首先添加Monocle依赖:

1
2
3
4
5
6
<dependency>
    <groupId>org.pdfsam</groupId>
    <artifactId>javafx-monocle</artifactId>
    <version>21</version>
    <scope>test</scope>
</dependency>

然后,在单独插件中指定所有必需标志:设置无头模式、禁用并行性等。注意–add-opens特定于用于演示的RaffleFX应用程序,在您的情况下模块可能不同。

在工作流文件中,确保安装JavaFX所需的所有本机库,并使用正确的配置文件运行测试。

错误7:业务逻辑与UI纠缠(非确定性)

测试业务逻辑与UI不是最佳实践。正如为Web应用程序分离控制器和服务测试一样,域逻辑测试不应与UI测试共存于一个类中。

解决方案: 最佳解决方案是将业务逻辑移动到ViewModels并使用普通JUnit测试它。这样,不依赖动画和其他UI事件,并确保测试始终是确定性的。

结论

JavaFX应用程序需要像任何其他程序一样进行测试。一方面,验证应用程序完全按预期运行。另一方面,使其长期更易维护。

然而,不熟悉的JavaFX测试过程可能导致测试运行期间出现大量异常或"神秘"测试失败。幸运的是,开发者可以安全导航这些未知水域,关注以下路标:

  • FX线程vs测试线程:在FX应用程序线程上突变UI和从UI读取,从测试线程发送输入
  • 正确引导:如果使用Spring等框架,确保以正确顺序启动Spring/TestFX,并使FXMLLoader使用Spring的类加载器
  • FX事件队列:在断言前等待FX队列耗尽,并通过状态而不是持续时间断言
  • 无像素完美断言:记住环境和平台可能轻微影响视觉效果,因此在测试颜色时允许容差,并更靠近元素中心采样
  • CI无头配置:使用Monocle配置无头测试,打开所需的Glass内部结构,避免断言Monocle无法模拟的平台功能

测试JavaFX可能看起来很复杂,本文涵盖了最常见的陷阱。但遵循这些建议,将能够为JavaFX程序构建可靠的测试基础。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计