Testing JavaFX in headless mode

I have recently investigated how we could test our JavaFX components and applications.

My requirements are:

  • Write tests in java, usable with JUnit
  • Simple test framework API
  • Can run in headless mode (for automated testing)

After a quick search, it seems TestFX is exactly what I need, and moreover quite active and contributed by several individuals (which is not the case for most of its competitors). TestFX provides a nice wrapper on top of the JavaFX/AWT/Glass robots APIs.

The Component to test: StringAutoSuggestEditor

The goal is to test a simple component, which is a TextField that display suggested strings, based on what is typed (similar to the Google Search box).

This is a very simple component, built with Scene Builder:

SB_StringAutoSuggestEditorAs you can see, this is a simple TextField with a custom ContextMenu, which include a ListView.

You can check the source code:

<?import java.lang.*?>
<?import javafx.geometry.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>


<TextField id="textField" fx:id="textField" onKeyReleased="#textFieldKeyReleased" onMouseClicked="#textFieldMouseClicked" prefWidth="-1.0" xmlns="http://javafx.com/javafx/8.0.40-ea" xmlns:fx="http://javafx.com/fxml/1">
<contextMenu>
  <ContextMenu maxHeight="-1.0" styleClass="auto-suggest-popup">
    <items>
      <CustomMenuItem mnemonicParsing="false">
        <content>
          <ListView id="suggestedLv" fx:id="suggestedLv" maxHeight="-1.0" minHeight="-1.0" onKeyPressed="#suggestedLvKeyPressed" onMousePressed="#suggestedLvMousePressed" prefHeight="92.0" prefWidth="180.0" />
        </content>
      </CustomMenuItem>
    </items>
  </ContextMenu>
</contextMenu>
</TextField>
/**
 * Abstract editor that provide a text field with an auto-suggest popup.
 *
 *
 */
public class StringAutoSuggestEditor {

    @FXML
    public ListView&amp;lt;String&amp;gt; suggestedLv;
    @FXML
    public TextField textField;

    private Parent root;
    private List&amp;lt;String&amp;gt; suggestedList;
    private boolean suggest = true;

    public StringAutoSuggestEditor(List&amp;lt;String&amp;gt; suggestedList) {
        preInit(suggestedList);
    }

    private void preInit(List&amp;lt;String&amp;gt; suggestedList) {
        setSuggestedList(suggestedList);
        URL fxmlURL = getClass().getResource(&amp;quot;StringAutoSuggestEditor.fxml&amp;quot;);
        root = loadFxml(fxmlURL, this);

        assert textField != null;
        assert root != null;

        initialize();
    }

    private void initialize() {
        textField.focusedProperty().addListener((ChangeListener&amp;lt;Boolean&amp;gt;) (ov, prevVal, newVal) -&amp;gt; {
            if (newVal) {
                // Getting focus: show the popup
                suggest = true;
                handleSuggestedPopup();
            } else {
                // Loosing focus: hide the popup
                hidePopup();
            }
        });
        // Align popup with (at least its list view) with property text field
//        suggestedLv.prefWidthProperty().bind(textField.widthProperty());

    }

    public Object getValue() {
        return textField.getText();
    }

    public void setValue(Object value) {
        if (value == null) {
            textField.setText(null);
        } else {
            assert value instanceof String;
            textField.setText((String) value); //NOI18N
        }
    }

    public void requestFocus() {
        textField.requestFocus();
    }

    public void reset(List&amp;lt;String&amp;gt; suggestedList) {
        resetSuggestedList(suggestedList);
    }

    protected void resetSuggestedList(List&amp;lt;String&amp;gt; suggestedList) {
        setSuggestedList(suggestedList);
        textField.setPromptText(null);
    }

    protected List&amp;lt;String&amp;gt; getSuggestedList() {
        return suggestedList;
    }

    private void setSuggestedList(List&amp;lt;String&amp;gt; suggestedList) {
        Collections.sort(suggestedList);
        this.suggestedList = suggestedList;
    }

    public Parent getRoot() {
        return root;
    }

    public TextField getTextField() {
        return textField;
    }

    @FXML
    protected void suggestedLvKeyPressed(KeyEvent event) {
        if (event.getCode() == KeyCode.ENTER) {
            useSuggested();
        }
        if (event.getCode() == KeyCode.ESCAPE) {
            hidePopup();
            suggest = false;
        }
    }

    @FXML
    protected void suggestedLvMousePressed(MouseEvent event) {
        useSuggested();
    }

    @FXML
    protected void textFieldKeyReleased(KeyEvent event) {
//                System.out.println(&amp;quot;Key code : &amp;quot; + event.getCode());
        if ((event.getCode() == KeyCode.ENTER) || (event.getCode() == KeyCode.UP)
                || (event.getCode() == KeyCode.ESCAPE)) {
            return;
        }
        if (event.getCode() == KeyCode.DOWN) {
            // 'Down' key shows the popup even if popup has been disabled
            suggest = true;
            suggestedLv.requestFocus();
        }
        handleSuggestedPopup();
    }

    @FXML
    protected void textFieldMouseClicked(MouseEvent event) {
    }

    private void handleSuggestedPopup() {
        String value = textField.getText();
        if (!suggest) {
            // Suggest popup is disabled
            if (value == null || value.isEmpty()) {
                // Suggest popup is re-enabled when text is empty
                suggest = true;
            } else {
                return;
            }
        }

        List&amp;lt;String&amp;gt; suggestedItems;
        suggestedItems = getSuggestedItems(value, value);
        // If the suggested list is empty, or contains a single element equals to the current value,
        // hide the popup
        if (suggestedItems.isEmpty()
                || ((suggestedItems.size() == 1) &amp;amp;&amp;amp; suggestedItems.get(0).equals(value))) {
            hidePopup();
        } else {
            showPopup(suggestedItems);
        }
    }

    private List&amp;lt;String&amp;gt; getSuggestedItems(String filter, String currentValue) {
        List&amp;lt;String&amp;gt; suggestedItems = new ArrayList&amp;lt;&amp;gt;();
        if (filter == null || currentValue == null) {
            // Return the whole suggestedList
            return suggestedList;
        }
        // We don't want to be case sensitive
        filter = filter.toLowerCase(Locale.ROOT);
        currentValue = currentValue.toLowerCase(Locale.ROOT);
        for (String suggestItem : suggestedList) {
            String suggestItemLower = suggestItem.toLowerCase(Locale.ROOT);
            if (suggestItemLower.contains(filter)) {
                // We don't want to suggest the already used value
                if (suggestItemLower.equals(currentValue)) {
                    continue;
                }
                suggestedItems.add(suggestItem);
            }
        }
        return suggestedItems;
    }

    private void showPopup(List&amp;lt;String&amp;gt; suggestedItems) {
        if (!suggestedLv.getItems().equals(suggestedItems)) {
            suggestedLv.setItems(FXCollections.observableArrayList(suggestedItems));
        }
        if (textField.getContextMenu().isShowing() == false) {
//                System.out.println(&amp;quot;showPopup&amp;quot;);
            suggestedLv.getSelectionModel().clearSelection();
            // popup x coordinate need to be slightly moved, so that the popup is centered
            textField.getContextMenu().show(textField, Side.BOTTOM, 0, 0);
        }
    }

    private void hidePopup() {
        if (textField.getContextMenu().isShowing() == true) {
            textField.getContextMenu().hide();

        }
    }

    private void useSuggested() {
        if (!suggestedLv.getSelectionModel().isEmpty()) {
            String selected = suggestedLv.getSelectionModel().getSelectedItem();
            textField.setText(selected);
            textField.requestFocus();
            textField.selectAll();
        }
        hidePopup();
    }

    public static Parent loadFxml(URL fxmlURL, Object controller) {
        final FXMLLoader loader = new FXMLLoader();
        loader.setController(controller);
        // Do we really need this?
//        loader.setClassLoader(controller.getClass().getClassLoader());
        loader.setLocation(fxmlURL);
//        loader.setResources(I18N.getBundle());
        Parent root;
        try {
            root = (Parent) loader.load();
        } catch (IOException ex) {
            throw new RuntimeException(&amp;quot;Failed to load &amp;quot; + fxmlURL.getFile(), ex); //NOI18N
        }
        return root;
    }

}

The Application

To put this in action, I built a simple app, that allows to add a set of StringAutoSuggestEditor and clear it when needed.SB_AutoSuggestEditorApp

As you can see, the VBox that will contain the editors is wrapped in a ScrollPane: this will allow to test the scrolling from TestFX.

Note also that the Scene Builder Hierarchy is set to display the node ids: this is very useful to get a global view of all the ids, that will be used for lookups in my test.

public class AutoSuggestEditorApp extends Application {

    @Override
    public void start(Stage stage) throws Exception {
        FXMLLoader loader = new FXMLLoader();
        loader.setLocation(getClass().getResource(&amp;amp;amp;quot;AutoSuggestEditorApp.fxml&amp;amp;amp;quot;));
        loader.setController(new AutoSuggestEditorAppController());
        Parent root = loader.load();

        Scene scene = new Scene(root);

        stage.setScene(scene);
        stage.show();
    }

    /**
     * @param args the command line arguments
     */
    public static void main(String[] args) {
        launch(args);
    }

}

<?import javafx.geometry.*?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>

<VBox prefHeight="350.0" prefWidth="259.0" spacing="20.0" xmlns="http://javafx.com/javafx/8.0.40-ea" xmlns:fx="http://javafx.com/fxml/1">
   <children>
      <AnchorPane>
         <children>
              <Button id="addBt" onAction="#addEditor" text="Add AutoSuggestEditor" AnchorPane.leftAnchor="0.0" />
            <Button id="clearBt" fx:id="clearBt" layoutX="172.0" mnemonicParsing="false" onAction="#clearEditors" text="Clear" AnchorPane.rightAnchor="0.0" />
         </children>
      </AnchorPane>
      <ScrollPane id="editorSp" fx:id="contentSp" fitToHeight="true" fitToWidth="true" VBox.vgrow="ALWAYS">
         <content>
            <VBox id="editorVb" fx:id="editorVb" prefHeight="298.0" prefWidth="237.0" spacing="5.0" />
         </content>
      </ScrollPane>
      <Label id="numberLb" fx:id="numberLb" />
   </children>
   <padding>
      <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
   </padding>
</VBox>


package app;

import autosuggesteditor.StringAutoSuggestEditor;
import java.net.URL;
import java.util.Arrays;
import java.util.ResourceBundle;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;

/**
 *
 * @author jerome
 */
public class AutoSuggestEditorAppController implements Initializable {

    @FXML
    private Button clearBt;
    @FXML
    private VBox editorVb;
    @FXML
    private Label numberLb;

    private StringAutoSuggestEditor editor;
    private int nbEditors = 0;

    private static final String[] SUGGESTED_LIST = {
        "Adult", "Aeroplane", "Air", "Aircraft Carrier", "Airforce", "Airport", "Album", "Alphabet", "Apple", "Arm", "Army",
        "Baby", "Baby", "Backpack", "Balloon", "Banana", "Bank", "Barbecue", "Bathroom", "Bathtub", "Bed", "Bed", "Bee", "Bible", "Bible", "Bird", "Bomb", "Book", "Boss", "Bottle", "Bowl", "Box", "Boy", "Brain", "Bridge", "Butterfly", "Button",
        "Cappuccino", "Car", "Car-race", "Carpet", "Carrot", "Cave", "Chair", "Chess Board", "Chief", "Child", "Chisel", "Chocolates", "Church", "Church", "Circle", "Circus", "Circus", "Clock", "Clown", "Coffee", "Coffee-shop", "Comet", "Compact Disc", "Compass", "Computer", "Crystal", "Cup", "Cycle",
        "Data Base", "Desk", "Diamond", "Dress", "Drill", "Drink", "Drum", "Dung",
        "Ears", "Earth", "Egg", "Electricity", "Elephant", "Eraser", "Explosive", "Eyes",
        "Family", "Fan", "Feather", "Festival", "Film", "Finger", "Fire", "Floodlight", "Flower", "Foot", "Fork", "Freeway", "Fruit", "Fungus",
        "Game", "Garden", "Gas", "Gate", "Gemstone", "Girl", "Gloves", "God", "Grapes", "Guitar",
        "Hammer", "Hat", "Hieroglyph", "Highway", "Horoscope", "Horse", "Hose",
        "Ice", "Ice-cream", "Insect", "Jet fighter", "Junk",
        "Kaleidoscope", "Kitchen", "Knife",
        "Leather jacket", "Leg", "Library", "Liquid",
        "Magnet", "Man", "Map", "Maze", "Meat", "Meteor", "Microscope", "Milk", "Milkshake", "Mist", "Money $$$$", "Monster", "Mosquito", "Mouth",
        "Nail", "Navy", "Necklace", "Needle",
        "Onion",
        "PaintBrush", "Pants", "Parachute", "Passport", "Pebble", "Pendulum", "Pepper", "Perfume", "Pillow", "Plane", "Planet", "Pocket", "Post-office", "Potato", "Printer", "Prison", "Pyramid",
        "Radar", "Rainbow", "Record", "Restaurant", "Rifle", "Ring", "Robot", "Rock", "Rocket", "Roof", "Room", "Rope",
        "Saddle", "Salt", "Sandpaper", "Sandwich", "Satellite", "School", "Sex", "Ship", "Shoes", "Shop", "Shower", "Signature", "Skeleton", "Slave", "Snail", "Software", "Solid", "Space Shuttle", "Spectrum", "Sphere", "Spice", "Spiral", "Spoon", "Sports-car", "Spot Light", "Square", "Staircase", "Star", "Stomach", "Sun", "Sunglasses", "Surveyor", "Swimming Pool", "Sword",
        "Table", "Tapestry", "Teeth", "Telescope", "Television", "Tennis racquet", "Thermometer", "Tiger", "Toilet", "Tongue", "Torch", "Torpedo", "Train", "Treadmill", "Triangle", "Tunnel", "Typewriter",
        "Umbrella",
        "Vacuum", "Vampire", "Videotape", "Vulture",
        "Water", "Weapon", "Web", "Wheelchair", "Window", "Woman", "Worm",
        "X-ray"
    };

    @Override
    public void initialize(URL url, ResourceBundle rb) {
        clearBt.setDisable(true);
    }

    @FXML
    private void addEditor(ActionEvent event) {
        editor = new StringAutoSuggestEditor(Arrays.asList(SUGGESTED_LIST));
        editorVb.getChildren().add(editor.getRoot());
        nbEditors++;
        updateLabel();
        clearBt.setDisable(false);
    }

    @FXML
    private void clearEditors(ActionEvent event) {
        editorVb.getChildren().clear();
        nbEditors = 0;
        updateLabel();
        clearBt.setDisable(true);
    }

    private void updateLabel() {
        numberLb.setText(nbEditors + " editor(s)");
    }

}

The Test

The test scenario is simple:

  • Do ten times:
    • Click the Add button
    • Check the Clear button is enabled
    • Type a (random) character in the TextField (scroll if needed)
    • Scroll Up and Down (random) in the context menu
    • Select a String
    • Check the TextField content has changed
  • Then, click the Clear button and check the content is empty and the Clear button is disabled.
package autosuggesteditor;

import app.AutoSuggestEditorApp;
import java.util.Random;
import java.util.Set;
import javafx.geometry.Orientation;
import javafx.geometry.VerticalDirection;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.CustomMenuItem;
import javafx.scene.control.ListView;
import javafx.scene.control.ScrollBar;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextField;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import org.junit.Test;
import org.testfx.framework.junit.ApplicationTest;

/**
 *
 * @author jerome
 */
public class AutoSuggestEditorTest extends ApplicationTest {

    private StringAutoSuggestEditor editor;
    private Button addBt;
    private Button clearBt;
    private VBox editorVb;
    private ScrollPane editorSp;

    int counter = 0;

    @Override
    public void start(Stage stage) throws Exception {
        new AutoSuggestEditorApp().start(stage);
        addBt = lookup("#addBt").queryFirst();
        clearBt = lookup("#clearBt").queryFirst();
        editorVb = lookup("#editorVb").queryFirst();
        editorSp = lookup("#editorSp").queryFirst();
    }

    @Test
    public void addAndTestAutoSuggestEditors() {
        assert clearBt.isDisable();
        for (int ii = 0; ii < 8; ii++) {
            clickAddButtonTest();
            assert !clearBt.isDisable();
        }
    }
    
    @Test
    public void clearAutoSuggestEditors() {
        clickOn(clearBt);
        assert clearBt.isDisable();
        assert editorVb.getChildren().isEmpty();
    }

    private void clickAddButtonTest() {
        clickOn(addBt);
        TextField editorTf = lookup("#textField").queryFirst();
        counter++;
        scrollIfNeeded();
        editorTf.setId("textField" + counter);
        autoSuggestEditorTest(editorTf);
    }

    private void scrollIfNeeded() {
        Set<Node> nodes = editorSp.lookupAll(".scroll-bar");
        for (final Node node : nodes) {
            if (node instanceof ScrollBar) {
                ScrollBar sb = (ScrollBar) node;
                if (sb.getOrientation() == Orientation.VERTICAL) {
                    if (sb.isVisible()) {
                        System.out.println("Vertical scrollbar found: moving UP...");
                        moveTo(editorSp);
                        scroll(30, VerticalDirection.UP);
                    }
                }
            }
        }
    }

    private void autoSuggestEditorTest(TextField editorTf) {
        assert editorTf != null;
        clickOn(editorTf);
        char typed = getRandomChar();
        write(typed);
        Object menuItemObj = editorTf.getContextMenu().getItems().get(0);
        assert menuItemObj instanceof CustomMenuItem;
        CustomMenuItem customMenuItem = (CustomMenuItem) menuItemObj;
        Node listViewContent = customMenuItem.getContent();
        assert listViewContent instanceof ListView;
        ListView listView = (ListView) listViewContent;
        assert listView.isVisible();
        // Next line gives an NPE in headless mode...
        moveTo(listView);
        scroll(getRandomOneTen(), VerticalDirection.UP);
        scroll(getRandomOneTen(), VerticalDirection.DOWN);
        clickOn(listView);
        if (listView.getSelectionModel().getSelectedItem() != null) {
            assert !editorTf.getText().equalsIgnoreCase("" + typed);
        }
    }

    private int getRandomOneTen() {
        return new Random().nextInt(10) + 1;
    }

    private char getRandomChar() {
        return (char) (new Random().nextInt(26) + 'a');
    }

}

A small video showing the test in action (obviously,  after the ‘Test’ target has been launched, I didn’t touch the mouse):

Headless Test

This is a very important feature for integrating the test in an automated test suite.

Hopefully, thanks to the Monocle graphic environnement, one can run a TestFX test in headless mode:

  1. Since Monocle jar is needed a bootstrap time, it must be copied in
    <JDK_HOME>/jre/lib/extension

    (Monocle jar can be downloaded from here)

  2. Add the following System properties:
    -Dtestfx.robot=glass -Dglass.platform=Monocle -Dmonocle.platform=Headless -Dprism.order=sw

    (e.g. in Netbeans project properties > Run > VM Options)

Here is the video of the headless test run. As you can see, I can now launch the test, then use the mouse for other purpose 🙂