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:
As 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&lt;String&gt; suggestedLv; @FXML public TextField textField; private Parent root; private List&lt;String&gt; suggestedList; private boolean suggest = true; public StringAutoSuggestEditor(List&lt;String&gt; suggestedList) { preInit(suggestedList); } private void preInit(List&lt;String&gt; suggestedList) { setSuggestedList(suggestedList); URL fxmlURL = getClass().getResource(&quot;StringAutoSuggestEditor.fxml&quot;); root = loadFxml(fxmlURL, this); assert textField != null; assert root != null; initialize(); } private void initialize() { textField.focusedProperty().addListener((ChangeListener&lt;Boolean&gt;) (ov, prevVal, newVal) -&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&lt;String&gt; suggestedList) { resetSuggestedList(suggestedList); } protected void resetSuggestedList(List&lt;String&gt; suggestedList) { setSuggestedList(suggestedList); textField.setPromptText(null); } protected List&lt;String&gt; getSuggestedList() { return suggestedList; } private void setSuggestedList(List&lt;String&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(&quot;Key code : &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&lt;String&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; suggestedItems.get(0).equals(value))) { hidePopup(); } else { showPopup(suggestedItems); } } private List&lt;String&gt; getSuggestedItems(String filter, String currentValue) { List&lt;String&gt; suggestedItems = new ArrayList&lt;&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&lt;String&gt; suggestedItems) { if (!suggestedLv.getItems().equals(suggestedItems)) { suggestedLv.setItems(FXCollections.observableArrayList(suggestedItems)); } if (textField.getContextMenu().isShowing() == false) { // System.out.println(&quot;showPopup&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(&quot;Failed to load &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.
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;quot;AutoSuggestEditorApp.fxml&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:
- 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)
- 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 🙂
Thank you for this quick and easy way to enable headless testing with TestFX!
As an Eclipse user, I first struggled with setting the VM arguments for enabling Monocle. I tried to set them using the “VM arguments” section of the JRE tab in my Maven run configuration in Eclipse, but this still ran my GUI tests visibly in on the screen. I had to specify the VM arguments using Maven’s ‘argLine’ parameter:
clean install -DargLine=”-Dtestfx.robot=glass -Dglass.platform=Monocle -Dmonocle.platform=Headless -Dprism.order=sw”
Now everything works as expected and I can use the mouse for other things, as you said. 🙂
LikeLike
Pingback: Gradle and TestFX in action – Yves's blog