Автоматическое тестирование 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, но об этом я напишу, когда погружусь глубже в исходники, — статью опубликую тут на английском.

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

ссылка на оригинал статьи http://habrahabr.ru/post/257981/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *