Automated testing of JavaFX applications



    Good day!

    In a world in which the cost of an error at the implementation stage exceeds hundreds and thousands of times the cost of a correction at the development stage, you should always look for the answer to the question: “How can this be tested automatically?” The global web practically does not cover issues of automating testing JavaFX applications. But still I managed to find some interesting ideas, and I want to share my observations with you.

    In the article, I will tell you how to find components in JavaFX form, how to check their properties, how to click on them, and so on. This is the minimum required set of entry points into the automation of testing JavaFX applications.

    1. The source data


    A set of libraries: guava, testFx, hamcrest and JUnit.
    I basically will not describe the logic of the application itself, I will only say that this is a quick-write calculator - we will try to work with it for as long as possible, like with a black-box. Nevertheless, I will start with the application launcher class itself:

    publicclassCalculatorAppextendsApplication{
    	privatestatic Optional<Callback<Parent>> callback = Optional.empty();
    	publicstaticvoidmain(String[] args){
    		launch(args);
    	}
    	@Overridepublicvoidstart(Stage primaryStage)throws Exception {
    		BorderPane root = new BorderPane();
    		root.setCenter(new Calculator());
    		Scene scene = new Scene(root);
    		primaryStage.setScene(scene);
    		primaryStage.show();
    		callback.ifPresent(o -> o.call(root));
    	}
    	publicstaticvoidonLoad(Callback<Parent> r){
    		CalculatorApp.callback = Optional.of(r);
    	}
    }
    


    Why callback is needed will become clear a bit later. For now, we only need to know about him:

    publicinterfaceCallback<T> {
        voidcall(T arg);
    }
    


    In addition to launcher, as you can guess, there is Calculator.java - a controller, Calculator.fxml - components with the entire hierarchy, layouts and more, Calculator.css - styles used by the components of our visualization. In the end, our calculator looks something like this:




    2. Initialization of the test


    publicclassFirstTest{
    	privatestatic GuiTest controller;	
    	@BeforeClasspublicstaticvoidsetUpClass(){
    		CalculatorApp.onLoad(r -> {
    			controller = new GuiTest() {
    				@Overrideprotected Parent getRootNode(){
    					return r;
    				}
    			};
    		});
    		FXTestUtils.launchApp(CalculatorApp.class);
    		try {
    			Thread.sleep(1000);
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    	}
    ...
    


    To automate testing using TestFX, we need GuiTest (), which is an abstract class that contains many useful methods. It requires us to implement Parent getRootNode (). Callback passes real root to the GuiTest implementation. This is enough to walk recursively through the component hierarchy, which TestFX actually does. I strongly advise you to look into the source code of the library - there are a lot of interesting things and the principles of its work are immediately clear.

    FXTestUtils.launchApp(CalculatorApp.class);
    


    You do not have to wait - you can make a smarter wait for the application to load, but for simplicity I have Thread.sleep (1000);

    3. Methods


    First of all, we need to teach our engine to press DELETE. for use in Before:

    privatevoidclear(){
    	controller.click("УДАЛ.");
    }
    


    Yes, it is so simple - and this is only one way. In fact, the mouse moves smoothly and clicks. In order to avoid unnecessary waste of time in prettiness in the future, you can proceed to throwing events directly to the desired node (but I will leave a slow version to show you video in dynamics). And throwing events is done something like this:

    Event.fireEvent(your_node, new MouseEvent(MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, MouseButton.PRIMARY, 1, true, true, true, true, true, true, true, true, true, true, null));
    


    In total, we have what we achieved - cleaning the fields of the calculator (reset), which we will perform before each test:

    @BeforepublicvoidbeforeTest(){
    	clear();
    }
    


    Similarly, we implement a method that calls us the desired number on the calculator.

    publicvoidclick(int digit){
    	String numStr = Integer.toString(digit);
    	for (int i = 0; i < numStr.length(); i++) {
    		controller.click(String.valueOf(numStr.charAt(i)));
    	}
    }
    


    Now I will show a more interesting version of clicking on various controls. The task is to learn to click on +, -, *, /, =. Let's look at our fxml and understand how these components are so unique.
    <Labelfx:id="eq"...
    <Labelfx:id="divide"...
    <Labelfx:id="multiply"...
    <Labelfx:id="subtract"...
    <Labelfx:id="add"...


    See full version of Calculator.fxml
    <?xml version="1.0" encoding="UTF-8"?><?import java.net.*?><?import javafx.scene.control.*?><?import java.lang.*?><?import javafx.scene.layout.*?><fx:rootmaxHeight="-Infinity"maxWidth="-Infinity"minHeight="-Infinity"minWidth="-Infinity"prefHeight="400.0"prefWidth="600.0"styleClass="root"type="GridPane"xmlns="http://javafx.com/javafx/8"xmlns:fx="http://javafx.com/fxml/1"><columnConstraints><ColumnConstraintshgrow="SOMETIMES"minWidth="10.0"percentWidth="27.0"prefWidth="100.0" /><ColumnConstraintshgrow="SOMETIMES"minWidth="10.0"percentWidth="27.0"prefWidth="100.0" /><ColumnConstraintshgrow="SOMETIMES"minWidth="10.0"percentWidth="27.0"prefWidth="100.0" /><ColumnConstraintshgrow="SOMETIMES"minWidth="10.0"percentWidth="19.0"prefWidth="100.0" /></columnConstraints><rowConstraints><RowConstraintsminHeight="10.0"percentHeight="25.0"prefHeight="30.0"vgrow="SOMETIMES" /><RowConstraintsminHeight="10.0"percentHeight="25.0"prefHeight="30.0"vgrow="SOMETIMES" /><RowConstraintsminHeight="10.0"percentHeight="25.0"prefHeight="30.0"vgrow="SOMETIMES" /><RowConstraintsminHeight="10.0"percentHeight="25.0"prefHeight="30.0"vgrow="SOMETIMES" /><RowConstraintsminHeight="10.0"percentHeight="25.0"prefHeight="30.0"vgrow="SOMETIMES" /></rowConstraints><children><StackPanemaxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"GridPane.columnSpan="4"><children><TextFieldfx:id="input"alignment="CENTER_RIGHT"focusTraversable="false"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"styleClass="input"text="0"GridPane.columnSpan="4" /><Labelfx:id="description"styleClass="operation"StackPane.alignment="BOTTOM_LEFT" /></children></StackPane><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text="3"GridPane.columnIndex="2"GridPane.rowIndex="3" /><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text="9"GridPane.columnIndex="2"GridPane.rowIndex="1" /><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text="2"GridPane.columnIndex="1"GridPane.rowIndex="3" /><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text="1"GridPane.rowIndex="3" /><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text="5"GridPane.columnIndex="1"GridPane.rowIndex="2" /><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text="8"GridPane.columnIndex="1"GridPane.rowIndex="1" /><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text="4"GridPane.rowIndex="2" /><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text="7"GridPane.rowIndex="1" /><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text=","GridPane.rowIndex="4" /><Labelfx:id="eq"alignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleEq"text="="GridPane.columnIndex="2"GridPane.rowIndex="4" /><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text="0"GridPane.columnIndex="1"GridPane.rowIndex="4" /><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleClick"text="6"GridPane.columnIndex="2"GridPane.rowIndex="2" /><GridPanestyleClass="operations"GridPane.columnIndex="3"GridPane.rowIndex="1"GridPane.rowSpan="4"><columnConstraints><ColumnConstraintshgrow="SOMETIMES"minWidth="10.0"prefWidth="100.0" /></columnConstraints><rowConstraints><RowConstraintsminHeight="10.0"prefHeight="30.0"vgrow="SOMETIMES" /><RowConstraintsminHeight="10.0"prefHeight="30.0"vgrow="SOMETIMES" /><RowConstraintsminHeight="10.0"prefHeight="30.0"vgrow="SOMETIMES" /><RowConstraintsminHeight="10.0"prefHeight="30.0"vgrow="SOMETIMES" /><RowConstraintsminHeight="10.0"prefHeight="30.0"vgrow="SOMETIMES" /></rowConstraints><children><Labelalignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#clear"text="УДАЛ." /><Labelfx:id="divide"alignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleOperationSelect"text="÷"GridPane.rowIndex="1" /><Labelfx:id="multiply"alignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleOperationSelect"text="×"GridPane.rowIndex="2" /><Labelfx:id="subtract"alignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleOperationSelect"text="−"GridPane.rowIndex="3" /><Labelfx:id="add"alignment="CENTER"maxHeight="1.7976931348623157E308"maxWidth="1.7976931348623157E308"onMouseClicked="#handleOperationSelect"text="+"GridPane.rowIndex="4" /></children></GridPane></children><stylesheets><URLvalue="@../../../style/base.css" /><URLvalue="@../../../style/skin.css" /><URLvalue="@Calculator.css" /></stylesheets></fx:root>



    We have unique fx: id, which we will use. For convenience, create an enumeration with operations:
    publicenum Operation {
        ADD,
        SUBTRACT,
        MULTIPLY,
        DIVIDE,
        EQ;
    }
    


    Now create your implementation of org.hamcrest.Matcher. We will pass our operation to the constructor, and then, lowering it to a lower case, we will compare it with the objects coming to the input.

    publicclassOperationMatcherimplementsMatcher<Node> {
    	private Operation operation;
    	publicOperationMatcher(Operation operation){
    		this.operation = operation;
    	}
    	@Overridepublicbooleanmatches(Object item){
    		if (item instanceof Labeled) {
    			String expected = operation.toString().toLowerCase();
    			String id = ((Labeled)item).getId();
    			if (id != null) {
    				if (expected.equals(id.toLowerCase())) {
    					returntrue;
    				}
    			}
    		}
    		returnfalse;
    	}
    ...
    


    Of course, I wrote a lot of superfluous here, but this is just to show that item is primarily a node and various checks and casts are applicable to it. Now we can use the GuiTest method:
    public GuiTest click (Matcher matcher, MouseButton ... buttons), namely, create a method:

    privatevoidperform(Operation operation){
    	Matcher<Node> matcher = new OperationMatcher(operation);
    	controller.click(matcher, MouseButton.PRIMARY);
    }
    


    So, we just have to check the result. That is, to find label (operation) and textField (input) ... Nobody forbids us to write more matcher - GuiTest naturally has a search method for matcher.

    However, I will show another way, namely a search by styleClass (sleep inserted again for simplicity - you have to wait for rendering):

    publicvoidcheckDescriptionField(String expectedText)throws InterruptedException {
    	Thread.sleep(200);
    	Node result = controller.find(".operation");
    	String actualText = ((Labeled) result).getText();
    	Assert.assertEquals(expectedText.trim(), actualText.trim());
    }
    publicvoidcheckInputField(String expectedText)throws InterruptedException {
    	Thread.sleep(200);
    	Node result = controller.find(".input");
    	String actualText = ((TextField) result).getText();
    	Assert.assertEquals(expectedText.trim(), actualText.trim());
    }
    


    It's time to write the simplest tests for addition and subtraction:

    @TestpublicvoidtestADD()throws InterruptedException {
    	int digit1 = random.nextInt(1000);
    	int digit2 = random.nextInt(1000);
    	click(digit1);
    	checkDescriptionField(String.valueOf(digit1));
    	checkInputField(String.valueOf(digit1));
    	perform(Operation.ADD);
    	click(digit2);
    	checkDescriptionField(digit1 + " + " + digit2);
    	checkInputField(String.valueOf(digit2));
    	perform(Operation.EQ);
    	checkInputField(String.valueOf(digit1 + digit2) + ",00");
    }
    @TestpublicvoidtestSubstract()throws InterruptedException {
    	int digit1 = random.nextInt(1000);
    	int digit2 = random.nextInt(1000);
    	click(digit1);
    	checkDescriptionField(String.valueOf(digit1));
    	checkInputField(String.valueOf(digit1));
    	perform(Operation.SUBTRACT);
    	click(digit2);
    	checkDescriptionField(digit1 + " − " + digit2);
    	checkInputField(String.valueOf(digit2));
    	perform(Operation.EQ);
    	checkInputField(String.valueOf(digit1 - digit2) + ",00");
    }
    


    ", 00" for simplicity - it’s clear what needs to be done through Formatter s, it’s clear that you need to replace Thread.sleep with wait, and clicks on throwing events — then the tests will start to fly. But this is already beyond the scope of the testFX features.

    By the way, I told you about TestFX of the third version - just a few weeks ago alpha version 4.0.1 was released . The testfx-legacy part is especially interesting, but I’ll write about this when I dive deeper into the sources — I will publish the article here in English.

    The promised video of running written tests below:


    Also popular now: