javafxjunit-jupitertestfx

testfx executes start before each test in spite of @TestInstance(TestInstance.Lifecycle.PER_CLASS)


I am testing a JavaFX GUI using org.fxtest. Setting up the Application is quite time consuming so I annotated my Test-class with @TestInstance(TestInstance.Lifecycle.PER_CLASS) Nevertheless, the start method is repeated for each test. How can I avoid this ?

I tried FxToolkit.registerPrimaryStage() inside start but no effect. The tests run fine, but because start is repeated very often it is too slow.

Here is a minimal example:

package guitest;

import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.testfx.api.FxAssert;
import org.testfx.framework.junit5.ApplicationExtension;
import org.testfx.framework.junit5.Start;
import org.testfx.matcher.control.LabeledMatchers;

/**
 * start is repeated for each Test
 * @author Harold
 * @version 2025-08-19
 * @since 2025-08-19
 */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ExtendWith(ApplicationExtension.class)
public class DemoTest {

    private Button button;
    int counter = 0;

    @Start
    public void start(Stage stage) {
       button = new Button("click me!");
       counter++;
       stage.setScene(new Scene(new StackPane(button), 100, 100));
       stage.show();
    }

    @Test @Order(1)
     void firstTest(){
       Assertions.assertNotNull(button);
    }

    @Test @Order(2)
    void secondTest(){
       FxAssert.verifyThat(".button", LabeledMatchers.hasText("click me!"));
    }

    @Test @Order(3)
    void seeCounter(){
       Assertions.assertEquals(1, counter);
    }

}

Solution

  • If it's the @Start-annotated method itself that takes up the most significant amount of time, then you might be able to use some kind of boolean flag to avoid work. That may not work well with TextFX's lifecycle though.

    Regardless, that doesn't stop TestFX from calling the @Start-annotated method. If you look at the implementation of ApplicationExtenstion, you can see it doesn't check the test instance lifecycle at all. It simply calls all @Init and @Start methods before each test and all @Stop methods after each test. There's also a few other things I find somewhat questionable with the implementation:

    With all that said, it doesn't look like ApplicationContext uses any internal APIs of TestFX. This means it should be possible to write a custom extension that fixes some of the aforementioned issues, including where it doesn't take the test instance lifecycle into account. Here's a proof-of-concept:

    Warning: I don't know if not cleaning up TestFX between tests can cause issues. It seems like TestFX keeps some global state, so this may lead to inconsistent state. Again, I don't know enough about TestFX to be sure.

    import static org.junit.platform.commons.support.AnnotationSupport.findAnnotatedMethods;
    import static org.junit.platform.commons.support.HierarchyTraversalMode.TOP_DOWN;
    import static org.junit.platform.commons.support.ModifierSupport.isStatic;
    import static org.junit.platform.commons.support.ReflectionSupport.findFields;
    import static org.junit.platform.commons.support.ReflectionSupport.invokeMethod;
    import static org.junit.platform.commons.support.ReflectionSupport.makeAccessible;
    import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException;
    
    import java.lang.annotation.Annotation;
    import java.lang.reflect.Field;
    import java.lang.reflect.Method;
    import java.util.ArrayList;
    import java.util.List;
    import java.util.concurrent.TimeoutException;
    import java.util.function.Consumer;
    import java.util.function.Predicate;
    import java.util.stream.Collectors;
    import javafx.application.Platform;
    import javafx.stage.Stage;
    import org.junit.jupiter.api.TestInstance;
    import org.junit.jupiter.api.extension.AfterAllCallback;
    import org.junit.jupiter.api.extension.AfterEachCallback;
    import org.junit.jupiter.api.extension.BeforeAllCallback;
    import org.junit.jupiter.api.extension.BeforeEachCallback;
    import org.junit.jupiter.api.extension.DynamicTestInvocationContext;
    import org.junit.jupiter.api.extension.ExtensionContext;
    import org.junit.jupiter.api.extension.InvocationInterceptor;
    import org.junit.jupiter.api.extension.ParameterContext;
    import org.junit.jupiter.api.extension.ParameterResolver;
    import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
    import org.junit.jupiter.api.extension.TestInstancePostProcessor;
    import org.junit.platform.commons.support.ModifierSupport;
    import org.testfx.api.FxRobot;
    import org.testfx.api.FxToolkit;
    import org.testfx.framework.junit5.ApplicationAdapter;
    import org.testfx.framework.junit5.ApplicationFixture;
    import org.testfx.framework.junit5.Init;
    import org.testfx.framework.junit5.Start;
    import org.testfx.framework.junit5.Stop;
    import org.testfx.util.WaitForAsyncUtils;
    
    public class TestFxExtension
        implements BeforeAllCallback,
            AfterAllCallback,
            BeforeEachCallback,
            AfterEachCallback,
            ParameterResolver,
            TestInstancePostProcessor,
            InvocationInterceptor {
    
      private static final ExtensionContext.Namespace TESTFX_NAMESPACE =
          ExtensionContext.Namespace.create(TestFxExtension.class);
    
      /* **************************************************************************
       *                                                                          *
       * ParameterResolver implementation                                         *
       *                                                                          *
       ****************************************************************************/
    
      @Override
      public boolean supportsParameter(
          ParameterContext parameterContext, ExtensionContext extensionContext) {
        return parameterContext.getParameter().getType() == FxRobot.class;
      }
    
      @Override
      public Object resolveParameter(
          ParameterContext parameterContext, ExtensionContext extensionContext) {
        return getFxRobot(extensionContext);
      }
    
      /* **************************************************************************
       *                                                                          *
       * TestInstancePostProcessor implementation                                 *
       *                                                                          *
       ****************************************************************************/
    
      // injects an FxRobot into any **instance** field whose type is FxRobot
      @Override
      public void postProcessTestInstance(Object testInstance, ExtensionContext context)
          throws Exception {
        injectRobotFields(context, testInstance.getClass(), testInstance, ModifierSupport::isNotStatic);
      }
    
      @Override
      public ExtensionContextScope getTestInstantiationExtensionContextScope(
          ExtensionContext rootContext) {
        return ExtensionContextScope.TEST_METHOD;
      }
    
      /* **************************************************************************
       *                                                                          *
       * BeforeAllCallback implementation                                         *
       *                                                                          *
       ****************************************************************************/
    
      // If lifecycle is PER_CLASS, runs the @Init and @Stop methods; also
      // injects FxRobot into any **static** field whose type is FxRobot
      @Override
      public void beforeAll(ExtensionContext context) throws Exception {
        injectRobotFields(context, context.getRequiredTestClass(), null, ModifierSupport::isStatic);
        if (isPerClassLifecycle(context)) {
          initFixture(context, true);
          runSetup(context);
        }
      }
    
      /* **************************************************************************
       *                                                                          *
       * AfterAllCallback implementation                                          *
       *                                                                          *
       ****************************************************************************/
    
      // If lifecycle is PER_CLASS, runs the @Stop methods
      @Override
      public void afterAll(ExtensionContext context) throws Exception {
        if (isPerClassLifecycle(context)) {
          runCleanup(context);
        }
      }
    
      /* **************************************************************************
       *                                                                          *
       * BeforeEachCallback                                                       *
       *                                                                          *
       ****************************************************************************/
    
      // If lifecycle is PER_METHOD, runs the @Init and @Start methods
      @Override
      public void beforeEach(ExtensionContext context) throws Exception {
        if (!isPerClassLifecycle(context)) {
          initFixture(context, false);
          runSetup(context);
        }
      }
    
      /* **************************************************************************
       *                                                                          *
       * AfterEachCallback implementation                                         *
       *                                                                          *
       ****************************************************************************/
    
      // If lifecycle is PER_METHOD, runs the @Stop methods
      @Override
      public void afterEach(ExtensionContext context) throws Exception {
        if (!isPerClassLifecycle(context)) {
          runCleanup(context);
        }
      }
    
      /* **************************************************************************
       *                                                                          *
       * InvocationInterceptor implementation                                     *
       *                                                                          *
       ****************************************************************************/
    
      // invokes @Test methods on FX thread
      @Override
      public void interceptTestMethod(
          Invocation<Void> invocation,
          ReflectiveInvocationContext<Method> invocationContext,
          ExtensionContext extensionContext)
          throws Throwable {
        invokeOnFxThread(invocation);
      }
    
      // invokes DynamicTest nodes on FX thread
      @Override
      public void interceptDynamicTest(
          Invocation<Void> invocation,
          DynamicTestInvocationContext invocationContext,
          ExtensionContext extensionContext)
          throws Throwable {
        invokeOnFxThread(invocation);
      }
    
      // invokes @TestTemplate (e.g., @ParameterizedTest) methods on FX thread
      @Override
      public void interceptTestTemplateMethod(
          Invocation<Void> invocation,
          ReflectiveInvocationContext<Method> invocationContext,
          ExtensionContext extensionContext)
          throws Throwable {
        invokeOnFxThread(invocation);
      }
    
      private void invokeOnFxThread(Invocation<Void> invocation) throws Throwable {
        if (Platform.isFxApplicationThread()) {
          invocation.proceed();
        } else {
          WaitForAsyncUtils.asyncFx(() -> {
                try {
                  return invocation.proceed();
                } catch (Exception | Error e) {
                  throw e;
                } catch (Throwable t) {
                  throw throwAsUncheckedException(t);
                }
              })
              .get();
        }
      }
    
      /* **************************************************************************
       *                                                                          *
       * Helper methods                                                           *
       *                                                                          *
       ****************************************************************************/
    
      private void runSetup(ExtensionContext context) throws TimeoutException {
        var fixture = getFixture(context);
        FxToolkit.registerPrimaryStage();
        FxToolkit.setupApplication(() -> new ApplicationAdapter(fixture));
      }
    
      private void runCleanup(ExtensionContext context) throws TimeoutException {
        var fixture = getFixture(context);
        FxToolkit.cleanupAfterTest(getFxRobot(context), new ApplicationAdapter(fixture));
        WaitForAsyncUtils.waitForFxEvents();
      }
    
      private boolean isPerClassLifecycle(ExtensionContext context) {
        var lifecycle = context.getTestInstanceLifecycle().orElseThrow();
        return lifecycle == TestInstance.Lifecycle.PER_CLASS;
      }
    
      private FxRobot getFxRobot(ExtensionContext context) {
        var store = context.getStore(TESTFX_NAMESPACE);
        return store.getOrComputeIfAbsent(FxRobot.class, k -> new FxRobot(), FxRobot.class);
      }
    
      private void putFixture(ExtensionContext context, ApplicationFixture fixture) {
        context.getStore(TESTFX_NAMESPACE).put(ApplicationFixture.class, fixture);
      }
    
      private ApplicationFixture getFixture(ExtensionContext context) {
        var store = context.getStore(TESTFX_NAMESPACE);
        return store.get(ApplicationFixture.class, ApplicationFixture.class);
      }
    
      private void initFixture(ExtensionContext context, boolean perClass) {
        if (perClass) {
          var fixture = createFixture(context, context.getRequiredTestInstance());
          putFixture(context, fixture);
        } else {
          var instances = context.getRequiredTestInstances().getAllInstances();
    
          ApplicationFixture fixture = null;
          for (var instance : instances) {
            var next = createFixture(context, instance);
            fixture = (fixture == null) ? next : combine(fixture, next);
          }
          putFixture(context, fixture);
        }
      }
    
      private ApplicationFixture createFixture(ExtensionContext context, Object testInstance) {
        var testClass = testInstance.getClass();
        var initMethods = getAppMethods(context, testClass, Init.class, this::validateInitOrStopMethod);
        var startMethods = getAppMethods(context, testClass, Start.class, this::validateStartMethod);
        var stopMethods = getAppMethods(context, testClass, Stop.class, this::validateInitOrStopMethod);
        return new TestAppFixture(testInstance, initMethods, startMethods, stopMethods);
      }
    
      @SuppressWarnings("unchecked")
      private List<Method> getAppMethods(
          ExtensionContext context,
          Class<?> testClass,
          Class<? extends Annotation> annoClass,
          Consumer<Method> validator) {
        var store = context.getRoot().getStore(TESTFX_NAMESPACE);
        var key = new AppMethodsKey(testClass, annoClass);
        try {
          return store.getOrComputeIfAbsent(key, k -> findAppMethods(k, validator), List.class);
        } catch (RuntimeException ex) {
          store.put(key, List.of());
          throw ex;
        }
      }
    
      private List<Method> findAppMethods(AppMethodsKey key, Consumer<Method> validator) {
        var methods = findAnnotatedMethods(key.testClass(), key.annoClass(), TOP_DOWN);
        methods.forEach(validator);
        return methods;
      }
    
      private void validateInitOrStopMethod(Method m) {
        var reasons = new ArrayList<String>();
        if (isStatic(m)) reasons.add("Must not be static.");
        if (m.getReturnType() != void.class) reasons.add("Must have void return type.");
        if (m.getParameterCount() != 0) reasons.add("Must have zero parameters.");
    
        if (!reasons.isEmpty()) {
          throw new IllegalStateException(createValidationMessage(m, reasons));
        }
      }
    
      private void validateStartMethod(Method m) {
        var reasons = new ArrayList<String>();
        if (isStatic(m)) reasons.add("Must not be static.");
        if (m.getReturnType() != void.class) reasons.add("Must have void return type.");
    
        if (m.getParameterCount() != 1)
          reasons.add("Must have one parameter of type 'javafx.stage.Stage'.");
        else if (m.getParameterTypes()[0] != Stage.class)
          reasons.add("Parameter must be of type 'javafx.stage.Stage'.");
    
        if (!reasons.isEmpty()) {
          throw new IllegalStateException(createValidationMessage(m, reasons));
        }
      }
    
      private String createValidationMessage(Method m, List<String> reasons) {
        var first = "Invalid application method: " + m + "\n\t\t==> ";
        return reasons.stream().collect(Collectors.joining("\n\t\t==> ", first, ""));
      }
    
      private void injectRobotFields(
          ExtensionContext context, Class<?> testClass, Object testInstance, Predicate<Field> filter)
          throws IllegalAccessException {
        var fields = getRobotFields(context, testClass);
        for (var field : fields) {
          if (filter.test(field)) {
            makeAccessible(field).set(testInstance, testClass);
          }
        }
      }
    
      @SuppressWarnings("unchecked")
      private List<Field> getRobotFields(ExtensionContext context, Class<?> testClass) {
        var store = context.getRoot().getStore(TESTFX_NAMESPACE);
        var key = new RobotFieldsKey(testClass);
        return store.getOrComputeIfAbsent(key, this::findRobotFields, List.class);
      }
    
      private List<Field> findRobotFields(RobotFieldsKey key) {
        Predicate<Field> isRobotField = f -> f.getType() == FxRobot.class;
        return findFields(key.testClass(), isRobotField, TOP_DOWN);
      }
    
      /* **************************************************************************
       *                                                                          *
       * Key types                                                                *
       *                                                                          *
       ****************************************************************************/
    
      private record AppMethodsKey(Class<?> testClass, Class<? extends Annotation> annoClass) {}
    
      private record RobotFieldsKey(Class<?> testClass) {}
    
      /* **************************************************************************
       *                                                                          *
       * ApplicationFixture implementation                                        *
       *                                                                          *
       ****************************************************************************/
    
      private static ApplicationFixture combine(ApplicationFixture before, ApplicationFixture after) {
        return new ApplicationFixture() {
    
          @Override
          public void init() throws Exception {
            before.init();
            after.init();
          }
    
          @Override
          public void start(Stage stage) throws Exception {
            before.start(stage);
            after.start(stage);
          }
    
          @Override
          public void stop() throws Exception {
            // Want @Stop methods in @Nested classes to execute before
            // any such methods in the enclosing class. This matches the
            // behavior of, e.g., @AfterEach methods.
            after.stop();
            before.stop();
          }
        };
      }
    
      private static class TestAppFixture implements ApplicationFixture {
    
        private final Object instance;
        private final List<Method> initMethods;
        private final List<Method> startMethods;
        private final List<Method> stopMethods;
    
        TestAppFixture(
            Object instance,
            List<Method> initMethods,
            List<Method> startMethods,
            List<Method> stopMethods) {
          this.instance = instance;
          this.initMethods = initMethods;
          this.startMethods = startMethods;
          this.stopMethods = stopMethods;
        }
    
        @Override
        public void init() {
          initMethods.forEach(m -> invokeMethod(m, instance));
        }
    
        @Override
        public void start(Stage stage) {
          startMethods.forEach(m -> invokeMethod(m, instance, stage));
        }
    
        @Override
        public void stop() {
          stopMethods.forEach(m -> invokeMethod(m, instance));
        }
      }
    }
    

    And here's a composed annotation to make it easier to apply the above extension:

    import java.lang.annotation.Documented;
    import java.lang.annotation.ElementType;
    import java.lang.annotation.Inherited;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    import org.junit.jupiter.api.extension.ExtendWith;
    
    @Documented
    @Inherited
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @ExtendWith(TestFxExtension.class)
    public @interface TestFxClass {}
    

    Some notes: