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);
}
}
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:
It seems to inject any field and parameter whose type is assignable from FXRobot
. If I'm not mistaken, that means even fields and parameters whose types are Object
will be injected. That seems wrong to me.
It only searches the declared methods of the test class. This means methods in any supertype are not searched for methods annotated with @Init
, @Start
, or @Stop
. Same thing regarding fields for injecting FxRobot
.
It doesn't cache found FxRobot
fields nor methods annotated with @Init
, @Start
, or @Stop
. This is probably not a big deal, but it may slow down tests slightly.
It doesn't search for the @Init
, @Start
, and @Stop
annotations in a way that allows for so-called composed annotations. Of course, those TestFX annotations only have a target of METHOD
, so they can't be composed annotations in the first place (which was a "mistake" in my opinion).
It doesn't invoke test methods on the FX thread. Though I'm not familiar with TestFX, so this could be intended. In other words, I don't know if TestFX provides APIs that make working with the FX thread more convenient.
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:
I wrote the above using JUnit 5.13.4.
The above is only a proof-of-concept. It may not work exactly as you want or may not work in all situations.
If @Nested
classes and their enclosing class(es) have different Lifecycle
s then the above results in somewhat strange behavior.
The above implements InvocationInterceptor
so that tests are executed on the FX thread. As I noted earlier, this may go against the design of TestFX. If it does, you can simply remove that interface.
The above stores the FxRobot
in the root ExtensionContext.Store
. This effectively makes it global, and thus the same robot will be used by every test. The standard ApplicationExtension
does this differently. I don't know if my approach will cause problems.