Автоматическое тестирование JavaFX приложений



    Добрый день!

    В мире, в котором стоимость ошибки на этапе внедрения превышает в сотни и тысячи раз стоимость исправления на этапе разработки, нужно всегда искать ответ на вопрос: «а как это тестировать автоматически?» Вопросы автоматизации тестирования JavaFX приложений глобальная паутина практически не освещает. Но всё же удалось найти несколько интересных идей, и я хочу поделиться с вами своими наблюдениями.

    В статье я расскажу как находить компоненты на JavaFX форме, как проверять их свойства, как кликать на них и так далее. Это минимально необходимый набор входных точек в автоматизацию тестирования JavaFX приложений.

    1. Исходные данные


    Набор библиотек: guava, testFx, hamcrest и JUnit.
    Я принципиально не буду описывать логику работы самого приложения, скажу только, что это калькулятор, написанный на скорую руку — постараемся максимально долго работать с ним, как с black-box. Тем не менее начну я с самого класса launcher-а приложения:

    public class CalculatorApp extends Application {
    	private static Optional<Callback<Parent>> callback = Optional.empty();
    
    	public static void main(String[] args) {
    		launch(args);
    	}
    
    	@Override
    	public void start(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));
    	}
    	
    	public static void onLoad(Callback<Parent> r) {
    		CalculatorApp.callback = Optional.of(r);
    	}
    }
    


    Зачем нужен callback станет понятно чуть позже. Пока нам нужно знать о нём только это:

    public interface Callback<T> {
        void call(T arg);
    }
    


    Помимо launcher-а, как вы можете догадаться, есть Calculator.java — контроллер, Calculator.fxml — компоненты со всей иерархией, layout-ами и прочим, Calculator.css — стили, используемые компонентами нашей визуалки. В конечном счёте наш калькулятор выглядит как-то так:




    2. Инициализация теста


    public class FirstTest {
    	private static GuiTest controller;	
    
    	@BeforeClass
    	public static void setUpClass() {
    		CalculatorApp.onLoad(r -> {
    			controller = new GuiTest() {
    				@Override
    				protected Parent getRootNode() {
    					return r;
    				}
    			};
    		});
    
    		FXTestUtils.launchApp(CalculatorApp.class);
    		try {
    			Thread.sleep(1000);
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    	}
    ...
    


    Чтобы автоматизировать тестирование с использованием TestFX нам требуется GuiTest() — это абстрактный класс, содержащий в себе множество полезных методов. Он требует от нас реализации Parent getRootNode(). Callback передаёт в реализацию GuiTest реальный root. Этого достаточно для того, чтобы ходить рекурсивно по иерархии компонентов, что на самом деле TestFX и делает. Очень советую заглянуть в исходники библиотеки — там есть много интересного и сразу понятны принципы её работы.

    FXTestUtils.launchApp(CalculatorApp.class);
    


    Ждать не обязательно — можно сделать более умное ожидание загрузки приложения, но для простоты у меня Thread.sleep(1000);

    3. Методы


    В первую очередь нам понадобится научить наш движок нажимать УДАЛ. для использования в Before:

    private void clear() {
    	controller.click("УДАЛ.");
    }
    


    Да, именно так просто — и это только один из способов. На самом деле происходит плавное перемещение мышки и клик. Чтобы в будущем избежать ненужной траты времени на красивости можно перейти к пробрасыванию событий напрямую нужной ноде (но я оставлю медленный вариант, чтобы показать вам видео в динамике). А пробрасывание событий делается как-то так:

    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));
    


    Итого мы имеем то, чего и добивались — очистка полей калькулятора (сброс), который будем производить перед каждым тестом:

    @Before
    public void beforeTest() {
    	clear();
    }
    


    Аналогично реализуем метод, который накликает нам нужное число на калькуляторе.

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


    Теперь я покажу более интересный вариант нажатий на различные контролы. Задача — научиться нажимать на +,-,*,/,=. Заглянем в нашу fxml и поймём, а чем таким уникальным отличаются эти компоненты.
    <Label fx:id="eq"...
    <Label fx:id="divide"...
    <Label fx:id="multiply"...
    <Label fx:id="subtract"...
    <Label fx:id="add"...
    


    Смотреть полный вариант Calculator.fxml
    <?xml version="1.0" encoding="UTF-8"?>
    
    <?import java.net.*?>
    <?import javafx.scene.control.*?>
    <?import java.lang.*?>
    <?import javafx.scene.layout.*?>
    
    <fx:root maxHeight="-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>
          <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="27.0" prefWidth="100.0" />
          <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="27.0" prefWidth="100.0" />
        <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="27.0" prefWidth="100.0" />
        <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" percentWidth="19.0" prefWidth="100.0" />
      </columnConstraints>
      <rowConstraints>
          <RowConstraints minHeight="10.0" percentHeight="25.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" percentHeight="25.0" prefHeight="30.0" vgrow="SOMETIMES" />
        <RowConstraints minHeight="10.0" percentHeight="25.0" prefHeight="30.0" vgrow="SOMETIMES" />
        <RowConstraints minHeight="10.0" percentHeight="25.0" prefHeight="30.0" vgrow="SOMETIMES" />
        <RowConstraints minHeight="10.0" percentHeight="25.0" prefHeight="30.0" vgrow="SOMETIMES" />
      </rowConstraints>
       <children>
          <StackPane maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" GridPane.columnSpan="4">
             <children>
                <TextField fx:id="input" alignment="CENTER_RIGHT" focusTraversable="false" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" styleClass="input" text="0" GridPane.columnSpan="4" />
                <Label fx:id="description" styleClass="operation" StackPane.alignment="BOTTOM_LEFT" />
             </children>
          </StackPane>
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="3" GridPane.columnIndex="2" GridPane.rowIndex="3" />
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="9" GridPane.columnIndex="2" GridPane.rowIndex="1" />
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="2" GridPane.columnIndex="1" GridPane.rowIndex="3" />
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="1" GridPane.rowIndex="3" />
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="5" GridPane.columnIndex="1" GridPane.rowIndex="2" />
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="8" GridPane.columnIndex="1" GridPane.rowIndex="1" />
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="4" GridPane.rowIndex="2" />
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="7" GridPane.rowIndex="1" />
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="," GridPane.rowIndex="4" />
          <Label fx:id="eq" alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleEq" text="=" GridPane.columnIndex="2" GridPane.rowIndex="4" />
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="0" GridPane.columnIndex="1" GridPane.rowIndex="4" />
          <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleClick" text="6" GridPane.columnIndex="2" GridPane.rowIndex="2" />
          <GridPane styleClass="operations" GridPane.columnIndex="3" GridPane.rowIndex="1" GridPane.rowSpan="4">
            <columnConstraints>
              <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
            </columnConstraints>
            <rowConstraints>
              <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
                <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
              <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
                <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
              <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
            </rowConstraints>
             <children>
                <Label alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#clear" text="УДАЛ." />
                <Label fx:id="divide" alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleOperationSelect" text="÷" GridPane.rowIndex="1" />
                <Label fx:id="multiply" alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleOperationSelect" text="×" GridPane.rowIndex="2" />
                <Label fx:id="subtract" alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleOperationSelect" text="−" GridPane.rowIndex="3" />
                <Label fx:id="add" alignment="CENTER" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onMouseClicked="#handleOperationSelect" text="+" GridPane.rowIndex="4" />
             </children>
          </GridPane>
       </children>
       <stylesheets>
          <URL value="@../../../style/base.css" />
          <URL value="@../../../style/skin.css" />
          <URL value="@Calculator.css" />
       </stylesheets>
    </fx:root>
    



    У нас есть уникальные fx:id, которыми мы и воспользуемся. Для удобства создадим enumeration с операциями:
    public enum Operation {
        ADD,
        SUBTRACT,
        MULTIPLY,
        DIVIDE,
        EQ;
    }
    


    Теперь создадим свою реализацию org.hamcrest.Matcher. Будем передавать нашу операцию в конструктор, а затем, приводя в нижний регистр, будем сравнивать с поступающими на вход объектами.

    public class OperationMatcher implements Matcher<Node> {
    	private Operation operation;
    
    	public OperationMatcher(Operation operation) {
    		this.operation = operation;
    	}
    
    	@Override
    	public boolean matches(Object item) {
    		if (item instanceof Labeled) {
    			String expected = operation.toString().toLowerCase();
    			String id = ((Labeled)item).getId();
    			if (id != null) {
    				if (expected.equals(id.toLowerCase())) {
    					return true;
    				}
    			}
    			
    		}
    		return false;
    	}
    ...
    


    Конечно, тут много лишнего я написал, но это просто чтобы показать, что item — это в первую очередь node и к нему применимы различные проверки и приведения. Теперь мы можем воспользоваться методом GuiTest:
    public GuiTest click( Matcher matcher, MouseButton… buttons ), а именно создадим метод:

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


    Итак, нам осталось проверять получающийся результат. То есть найти label (operation) и textField (input)… Никто не запрещает нам написать ещё matcher-ов — у GuiTest естественно есть метод поиска по matcher-у.

    Однако я покажу другой способ, а именно поиск по styleClass (sleep вставил опять же для простоты — надо дождаться отрисовки):

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


    Пришло время для написания простейших тестов на сложение и вычитание:

    @Test
    public void testADD() 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");
    }
    	
    @Test
    public void testSubstract() 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" для простоты — понятно, что надо делать через Formatter-ы, понятно, что надо заменять Thread.sleep на ожидание, а клики на прокидывание event-ов — тогда тесты начнут летать. Но это уже выходит за рамки рассказа про возможности TestFX.

    Кстати, я рассказал вам про TestFX третьей версии, — буквально несколько недель назад вышла alpha версия 4.0.1. Особенно интересна часть testfx-legacy, но об этом я напишу, когда погружусь глубже в исходники, - статью опубликую тут на английском.

    Обещанное видео запуска написанных тестов ниже:

    Метки:
    • +12
    • 8,4k
    • 6
    Кристалл Сервис 34,12
    Компания
    Поделиться публикацией
    Похожие публикации
    Комментарии 6
    • 0
      Я в свое время интересовался тематикой тестирования JavaFX приложений и мне показалось, что TestFX — это треш, а не фреймворк. Я написал небольшой прототип того, что я считал бы более удобным решением. В итоге я перестал работать с JavaFX, но прототип остался. Ссылки:

      Посмотрите, быть может кто-то сделал бы из этого полноценную библиотеку.
      • +1
        Спасибо за ссылки на проект. Согласен — TestFX — это просто отправная точка. Трэшем я бы её не называл, потому что она решает все задачи, которые я пока могу себе представить, но, безусловно требует доработки. Потому я и оговорился по поводу версии 4.0.1 — там много, очень много интересного.
        • 0
          В итоге я перестал работать с JavaFX

          чем-то заменили или просто перестали работать с JavaFX?
          • +1
            Просто проект на JavaFX не покатил. А так, на мой взгляд, вполне себе перспективная технология.
        • 0
          А зачем столько библиотек? Вполне достаточно jemmy fx, или glass robot.
          Ещё можно посмотреть на то, как сделаны тесты в openjfx.
          • 0
            JemmyFX не поддерживается и не развивается, был написан когда JavaFX была ещё куском очередного update-а седьмой JDK

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое