Skip to content

Commit f695a72

Browse files
authored
ci: ускорение testfx-тестов через общий toolkit на класс (#39)
## Что сделано - Новый базовый класс ui.FxTestBase: JavaFX toolkit поднимается один раз на тестовый класс через FxToolkit.registerPrimaryStage в BeforeAll (TestInstance PER_CLASS), очистка в AfterAll. Изоляция между тестами сохранена: каждый тест пересоздаёт сцену и моки в BeforeEach. - Переведены 5 GUI-классов с ApplicationExtension (перезапуск приложения перед каждым тестом) на FxTestBase: LoginFormControllerTest, ResourceFormControllerTest, ResourceListControllerTest, ResourceDetailsControllerTest, MockFlowTest. Тела подготовки сцены переиспользованы без изменений. - Посимвольный ввод robot.write заменён прямым setText через хелпер fillText: синтетические клавиши под headless Monocle идут около 90 мс на символ, а контроллеры читают поля только по кнопке (обработчиков клавиш в продуктивном коде нет). - Продуктивный код не менялся, только тесты. ## Замеры mvn -B test, до/после: - Всё: 81.6 сек / 70 тестов -> 14.3 сек / 70 тестов (все зелёные) - ResourceFormControllerTest: 39.3 -> 3.5 сек - LoginFormControllerTest: 12.8 -> 2.1 сек - ResourceListControllerTest: 6.8 -> 3.6 сек - ResourceDetailsControllerTest: 3.7 -> 2.2 сек - MockFlowTest: 1.1 -> 0.5 сек mvn -B checkstyle:check и mvn -B spotless:check зелёные. ## Не переведено UI-интеграционные тесты (ResourceListIntegrationTest, ResourceFormIntegrationTest) оставлены на ApplicationExtension: они исключены из дефолтной сборки и требуют живого бэкенда, проверить их локально нельзя. Перевод отдельным изменением при поднятом бэкенде. Интеграционные тесты с тегом integration не запускались (нет живого бэкенда). Closes #37
1 parent 2d38b43 commit f695a72

8 files changed

Lines changed: 236 additions & 97 deletions

‎src/test/java/ui/FxTestBase.java‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package ui;
2+
3+
import java.util.concurrent.TimeoutException;
4+
import javafx.scene.control.TextInputControl;
5+
import org.junit.jupiter.api.AfterAll;
6+
import org.junit.jupiter.api.BeforeAll;
7+
import org.junit.jupiter.api.TestInstance;
8+
import org.testfx.api.FxRobot;
9+
import org.testfx.api.FxToolkit;
10+
11+
/**
12+
* Toolkit поднимается один раз на класс, а не перед каждым тестом, как это делает {@code
13+
* ApplicationExtension}. Чистое состояние между тестами даёт пересоздание сцены и моков в {@code
14+
* BeforeEach} наследника.
15+
*/
16+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
17+
public abstract class FxTestBase {
18+
19+
protected final FxRobot robot = new FxRobot();
20+
21+
@BeforeAll
22+
void initToolkit() throws TimeoutException {
23+
startSharedToolkit();
24+
}
25+
26+
@AfterAll
27+
void cleanupToolkit() throws TimeoutException {
28+
stopSharedToolkit(robot);
29+
}
30+
31+
/**
32+
* Подъём toolkit для тестов, которые не могут наследовать этот класс (уже есть предок). Повторные
33+
* вызовы дешёвые: toolkit стартует один раз на JVM.
34+
*/
35+
public static void startSharedToolkit() throws TimeoutException {
36+
FxToolkit.registerPrimaryStage();
37+
}
38+
39+
/** Пара к {@link #startSharedToolkit()}. */
40+
public static void stopSharedToolkit(FxRobot robot) throws TimeoutException {
41+
FxToolkit.cleanupStages();
42+
FxToolkit.cleanupInput(robot);
43+
}
44+
45+
/**
46+
* Синтетический ввод под headless Monocle идёт ~90 мс на клавишу и иногда теряется, а контроллеры
47+
* читают поля только по кнопке, поэтому текст ставится напрямую.
48+
*/
49+
protected static void fillText(FxRobot robot, String query, String value) {
50+
robot.interact(() -> robot.lookup(query).queryAs(TextInputControl.class).setText(value));
51+
}
52+
}

‎src/test/java/ui/LoginFormControllerTest.java‎

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,30 @@
2323
import javafx.scene.Scene;
2424
import javafx.scene.control.Label;
2525
import javafx.stage.Stage;
26+
import org.junit.jupiter.api.BeforeEach;
2627
import org.junit.jupiter.api.Test;
27-
import org.junit.jupiter.api.extension.ExtendWith;
28-
import org.testfx.api.FxRobot;
29-
import org.testfx.framework.junit5.ApplicationExtension;
30-
import org.testfx.framework.junit5.Start;
28+
import org.testfx.api.FxToolkit;
3129

32-
@ExtendWith(ApplicationExtension.class)
33-
class LoginFormControllerTest {
30+
class LoginFormControllerTest extends FxTestBase {
3431

3532
private static final String VALID_EMAIL = "user@example.com";
3633
private static final String VALID_PASSWORD = "secret123";
3734

3835
private AuthService authService;
3936
private Navigator navigator;
4037

41-
@Start
38+
@BeforeEach
39+
void resetScene() throws Exception {
40+
FxToolkit.setupStage(
41+
stage -> {
42+
try {
43+
start(stage);
44+
} catch (Exception e) {
45+
throw new RuntimeException(e);
46+
}
47+
});
48+
}
49+
4250
void start(Stage stage) throws Exception {
4351
authService = mock(AuthService.class);
4452
ServiceRegistry services = mock(ServiceRegistry.class);
@@ -62,7 +70,7 @@ void start(Stage stage) throws Exception {
6270
}
6371

6472
@Test
65-
void emptyFieldsShowErrorAndDoNotCallApi(FxRobot robot) {
73+
void emptyFieldsShowErrorAndDoNotCallApi() {
6674
robot.clickOn("#submitButton");
6775

6876
Label error = robot.lookup("#errorLabel").queryAs(Label.class);
@@ -73,8 +81,8 @@ void emptyFieldsShowErrorAndDoNotCallApi(FxRobot robot) {
7381
}
7482

7583
@Test
76-
void blankPasswordShowsErrorAndDoesNotCallApi(FxRobot robot) {
77-
robot.clickOn("#emailField").write(VALID_EMAIL);
84+
void blankPasswordShowsErrorAndDoesNotCallApi() {
85+
fillText(robot, "#emailField", VALID_EMAIL);
7886
robot.clickOn("#submitButton");
7987

8088
Label error = robot.lookup("#errorLabel").queryAs(Label.class);
@@ -85,9 +93,9 @@ void blankPasswordShowsErrorAndDoesNotCallApi(FxRobot robot) {
8593
}
8694

8795
@Test
88-
void invalidEmailShowsErrorAndDoesNotCallApi(FxRobot robot) {
89-
robot.clickOn("#emailField").write("not-an-email");
90-
robot.clickOn("#passwordField").write(VALID_PASSWORD);
96+
void invalidEmailShowsErrorAndDoesNotCallApi() {
97+
fillText(robot, "#emailField", "not-an-email");
98+
fillText(robot, "#passwordField", VALID_PASSWORD);
9199
robot.clickOn("#submitButton");
92100

93101
Label error = robot.lookup("#errorLabel").queryAs(Label.class);
@@ -98,12 +106,12 @@ void invalidEmailShowsErrorAndDoesNotCallApi(FxRobot robot) {
98106
}
99107

100108
@Test
101-
void successfulLoginNavigatesToResourceList(FxRobot robot) {
109+
void successfulLoginNavigatesToResourceList() {
102110
User user = new User(UUID.randomUUID(), VALID_EMAIL, Role.USER, "Имя", "Фамилия", true);
103111
when(authService.login(VALID_EMAIL, VALID_PASSWORD)).thenReturn(user);
104112

105-
robot.clickOn("#emailField").write(VALID_EMAIL);
106-
robot.clickOn("#passwordField").write(VALID_PASSWORD);
113+
fillText(robot, "#emailField", VALID_EMAIL);
114+
fillText(robot, "#passwordField", VALID_PASSWORD);
107115
robot.clickOn("#submitButton");
108116

109117
verify(authService).login(VALID_EMAIL, VALID_PASSWORD);
@@ -113,12 +121,12 @@ void successfulLoginNavigatesToResourceList(FxRobot robot) {
113121
}
114122

115123
@Test
116-
void failedLoginShowsServerErrorAndDoesNotNavigate(FxRobot robot) {
124+
void failedLoginShowsServerErrorAndDoesNotNavigate() {
117125
when(authService.login(VALID_EMAIL, VALID_PASSWORD))
118126
.thenThrow(new ServiceException("Неверный логин или пароль"));
119127

120-
robot.clickOn("#emailField").write(VALID_EMAIL);
121-
robot.clickOn("#passwordField").write(VALID_PASSWORD);
128+
fillText(robot, "#emailField", VALID_EMAIL);
129+
fillText(robot, "#passwordField", VALID_PASSWORD);
122130
robot.clickOn("#submitButton");
123131

124132
Label error = robot.lookup("#errorLabel").queryAs(Label.class);

‎src/test/java/ui/MockFlowTest.java‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,33 @@
1616
import javafx.scene.control.Label;
1717
import javafx.scene.control.TableView;
1818
import javafx.stage.Stage;
19+
import org.junit.jupiter.api.BeforeEach;
1920
import org.junit.jupiter.api.Test;
20-
import org.junit.jupiter.api.extension.ExtendWith;
21-
import org.testfx.api.FxRobot;
22-
import org.testfx.framework.junit5.ApplicationExtension;
23-
import org.testfx.framework.junit5.Start;
21+
import org.testfx.api.FxToolkit;
2422

2523
/**
2624
* Mock-backed UI flow: real pages wired to real in-memory mock services (no Mockito on services, no
2725
* backend). Proves that MOCK_DATA mode renders actual data.
2826
*/
29-
@ExtendWith(ApplicationExtension.class)
30-
class MockFlowTest {
27+
class MockFlowTest extends FxTestBase {
3128

3229
private static final UUID SEEDED_RESOURCE_ID =
3330
UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
3431

3532
private ResourceDetailsController controller;
3633

37-
@Start
34+
@BeforeEach
35+
void resetScene() throws Exception {
36+
FxToolkit.setupStage(
37+
stage -> {
38+
try {
39+
start(stage);
40+
} catch (Exception e) {
41+
throw new RuntimeException(e);
42+
}
43+
});
44+
}
45+
3846
void start(Stage stage) throws Exception {
3947
Navigator navigator = mockNavigator();
4048
ServiceRegistry services = ServiceRegistry.mock(new Session());
@@ -68,7 +76,7 @@ void mockLoginPutsUserIntoSession() {
6876

6977
@Test
7078
@SuppressWarnings("unchecked")
71-
void detailsPageRendersSeededMockResource(FxRobot robot) {
79+
void detailsPageRendersSeededMockResource() {
7280
robot.interact(
7381
() -> {
7482
controller.setResourceId(SEEDED_RESOURCE_ID);
@@ -83,7 +91,7 @@ void detailsPageRendersSeededMockResource(FxRobot robot) {
8391

8492
@Test
8593
@SuppressWarnings("unchecked")
86-
void detailsPageAutoSelectsFirstSeedWithoutId(FxRobot robot) {
94+
void detailsPageAutoSelectsFirstSeedWithoutId() {
8795
robot.interact(() -> controller.load());
8896

8997
assertEquals("Годовой отчёт (мок)", robot.lookup("#titleLabel").queryAs(Label.class).getText());

‎src/test/java/ui/ResourceDetailsControllerTest.java‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,13 @@
3232
import javafx.scene.control.TableView;
3333
import javafx.stage.Stage;
3434
import javafx.stage.Window;
35+
import org.junit.jupiter.api.BeforeEach;
3536
import org.junit.jupiter.api.Test;
36-
import org.junit.jupiter.api.extension.ExtendWith;
3737
import org.junit.jupiter.api.io.TempDir;
3838
import org.testfx.api.FxRobot;
39-
import org.testfx.framework.junit5.ApplicationExtension;
40-
import org.testfx.framework.junit5.Start;
39+
import org.testfx.api.FxToolkit;
4140

42-
@ExtendWith(ApplicationExtension.class)
43-
class ResourceDetailsControllerTest {
41+
class ResourceDetailsControllerTest extends FxTestBase {
4442

4543
private static final UUID RESOURCE_ID = UUID.randomUUID();
4644
private static final UUID FILE_ID_1 = UUID.randomUUID();
@@ -64,7 +62,18 @@ protected File chooseDirectory(Window owner) {
6462
}
6563
}
6664

67-
@Start
65+
@BeforeEach
66+
void resetScene() throws Exception {
67+
FxToolkit.setupStage(
68+
stage -> {
69+
try {
70+
start(stage);
71+
} catch (Exception e) {
72+
throw new RuntimeException(e);
73+
}
74+
});
75+
}
76+
6877
void start(Stage stage) throws Exception {
6978
resourceService = mock(ResourceService.class);
7079
fileService = mock(FileService.class);
@@ -83,7 +92,7 @@ void start(Stage stage) throws Exception {
8392
}
8493

8594
@Test
86-
void successfulLoadShowsMetadataAndFiles(FxRobot robot) {
95+
void successfulLoadShowsMetadataAndFiles() {
8796
when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
8897
loadPage(robot);
8998

@@ -101,7 +110,7 @@ void successfulLoadShowsMetadataAndFiles(FxRobot robot) {
101110
}
102111

103112
@Test
104-
void resourceNotFoundShowsErrorAndLeavesTableEmpty(FxRobot robot) {
113+
void resourceNotFoundShowsErrorAndLeavesTableEmpty() {
105114
when(resourceService.getById(RESOURCE_ID)).thenThrow(new ServiceException("Ресурс не найден"));
106115
loadPage(robot);
107116

@@ -113,7 +122,7 @@ void resourceNotFoundShowsErrorAndLeavesTableEmpty(FxRobot robot) {
113122
}
114123

115124
@Test
116-
void downloadSavesFileAndShowsStatus(FxRobot robot, @TempDir Path tempDir) {
125+
void downloadSavesFileAndShowsStatus(@TempDir Path tempDir) {
117126
when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
118127
loadPage(robot);
119128

@@ -128,7 +137,7 @@ void downloadSavesFileAndShowsStatus(FxRobot robot, @TempDir Path tempDir) {
128137
}
129138

130139
@Test
131-
void downloadErrorShowsInErrorLabel(FxRobot robot, @TempDir Path tempDir) {
140+
void downloadErrorShowsInErrorLabel(@TempDir Path tempDir) {
132141
when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
133142
loadPage(robot);
134143

@@ -144,7 +153,7 @@ void downloadErrorShowsInErrorLabel(FxRobot robot, @TempDir Path tempDir) {
144153
}
145154

146155
@Test
147-
void cancelledDirectoryChooserDoesNotCallDownload(FxRobot robot) {
156+
void cancelledDirectoryChooserDoesNotCallDownload() {
148157
when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
149158
loadPage(robot);
150159

@@ -155,7 +164,7 @@ void cancelledDirectoryChooserDoesNotCallDownload(FxRobot robot) {
155164
}
156165

157166
@Test
158-
void backNavigatesToResourceList(FxRobot robot) {
167+
void backNavigatesToResourceList() {
159168
robot.clickOn("#backButton");
160169

161170
verify(navigator).goTo("/fxml/ResourceList.fxml");

0 commit comments

Comments
 (0)