Тестування JavaFX MVVM-додатків
Тестування JavaFX MVVM-додатків
Вступ: Переваги MVVM для тестування
Як протестувати JavaFX-додаток? У наївному підході потрібно запустити додаток, вручну клікати кнопки, вводити текст, перевіряти результат. Це повільно, нудно, схильне до помилок. Крім того, такі тести неможливо автоматизувати — кожна зміна коду вимагає ручного повторення всіх кроків.
У класичному JavaFX-додатку без архітектурного патерну вся логіка знаходиться у Controller. Щоб протестувати логіку, потрібно створити Controller, ініціалізувати всі @FXML поля (UI-елементи), емулювати події. Це складно і крихко — тести ламаються при найменших змінах UI.
MVVM радикально змінює ситуацію. Вся логіка знаходиться у ViewModel — це звичайний Java-клас (POJO), що не залежить від JavaFX. Щоб протестувати логіку, достатньо створити ViewModel з mock залежностями та викликати його методи. Не потрібен UI, не потрібен JavaFX Application Thread, не потрібна емуляція подій.
Ця стаття про те, як тестувати JavaFX MVVM-додатки на всіх рівнях. Ми розглянемо:
- Unit-тестування ViewModel з Mockito (тестування логіки без UI).
- Тестування асинхронних операцій (Task, Properties).
- Тестування валідації та Bindings.
- Інтеграційне тестування ViewModel + Repository з H2 database.
- UI-тестування з TestFX (автоматизоване тестування JavaFX інтерфейсу).
- Test Doubles: Mock vs Stub vs Fake (коли що використовувати).
- Організацію тестів для різних шарів (unit, integration, ui).
Але перш за все, потрібно зрозуміти фундаментальний принцип: MVVM дозволяє тестувати логіку без UI. Це найбільша перевага патерну для тестування. 90% логіки додатку можна покрити швидкими unit-тестами, і лише 10% потребують повільних UI-тестів.
Unit-тестування ViewModel з Mockito
ViewModel — це POJO, що не залежить від JavaFX UI-класів. Це робить його ідеальним кандидатом для unit-тестування.
Приклад: Тестування AudiobookListViewModel
package dev.kostyl.audiobook.viewmodel;
import dev.kostyl.audiobook.domain.Audiobook;
import dev.kostyl.audiobook.domain.Author;
import dev.kostyl.audiobook.domain.Genre;
import dev.kostyl.audiobook.service.AudiobookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AudiobookListViewModelTest {
@Mock
private AudiobookService audiobookService;
private AudiobookListViewModel viewModel;
private Audiobook audiobook1;
private Audiobook audiobook2;
@BeforeEach
void setUp() {
// Створення тестових даних
Author author1 = new Author("George", "Orwell");
Author author2 = new Author("Aldous", "Huxley");
Genre genre = new Genre("Fiction");
audiobook1 = new Audiobook("1984", author1, genre, 36000, 1949);
audiobook2 = new Audiobook("Brave New World", author2, genre, 28800, 1932);
// Створення ViewModel з mock залежностями
viewModel = new AudiobookListViewModel(audiobookService);
}
@Test
void shouldLoadAudiobooks() {
// Given
List<Audiobook> audiobooks = List.of(audiobook1, audiobook2);
when(audiobookService.getAllAudiobooks()).thenReturn(audiobooks);
// When
viewModel.loadAudiobooks();
// Then
assertEquals(2, viewModel.getAudiobooks().size());
assertEquals("1984", viewModel.getAudiobooks().get(0).getTitle());
assertEquals("Brave New World", viewModel.getAudiobooks().get(1).getTitle());
verify(audiobookService).getAllAudiobooks();
}
@Test
void shouldFilterAudiobooksBySearchQuery() {
// Given
viewModel.getAudiobooks().addAll(
new AudiobookViewModel(audiobook1),
new AudiobookViewModel(audiobook2)
);
// When
viewModel.searchQueryProperty().set("1984");
// Then
assertEquals(1, viewModel.getFilteredAudiobooks().size());
assertEquals("1984", viewModel.getFilteredAudiobooks().get(0).getTitle());
}
@Test
void shouldDeleteSelectedAudiobook() {
// Given
AudiobookViewModel selected = new AudiobookViewModel(audiobook1);
viewModel.getAudiobooks().add(selected);
viewModel.setSelectedAudiobook(selected);
// When
viewModel.deleteSelected();
// Then
verify(audiobookService).delete(audiobook1.getId());
assertFalse(viewModel.getAudiobooks().contains(selected));
}
@Test
void shouldNotDeleteWhenNothingSelected() {
// Given
viewModel.setSelectedAudiobook(null);
// When
viewModel.deleteSelected();
// Then
verify(audiobookService, never()).delete(any());
}
@Test
void shouldUpdateStatusMessageOnError() {
// Given
when(audiobookService.getAllAudiobooks())
.thenThrow(new RuntimeException("Database error"));
// When
viewModel.loadAudiobooks();
// Then
assertNotNull(viewModel.getStatusMessage());
assertTrue(viewModel.getStatusMessage().contains("error"));
}
}
Розбір тесту
Рядки 18-19: @ExtendWith(MockitoExtension.class). Ця анотація ініціалізує Mockito для JUnit 5. Вона автоматично створює mock-об'єкти для полів з @Mock.
Рядки 21-22: @Mock залежності. AudiobookService — це mock. Ми не створюємо реальний Service з Repository та DataSource — лише імітацію, що повертає заздалегідь визначені дані.
Рядки 28-42: setUp(). Метод @BeforeEach викликається перед кожним тестом. Тут створюємо тестові дані (Audiobook, Author, Genre) та ініціалізуємо ViewModel з mock Service.
Рядки 44-57: shouldLoadAudiobooks(). Тест перевіряє, що loadAudiobooks() викликає audiobookService.getAllAudiobooks() та додає результат у audiobooks ObservableList. when(...).thenReturn(...) — це Mockito DSL для налаштування поведінки mock. verify(...) перевіряє, що метод був викликаний.
Рядки 59-71: shouldFilterAudiobooksBySearchQuery(). Тест перевіряє фільтрацію. Додаємо дві аудіокниги у audiobooks, встановлюємо searchQuery = "1984", перевіряємо, що filteredAudiobooks містить лише одну аудіокнигу.
Рядки 73-85: shouldDeleteSelectedAudiobook(). Тест перевіряє видалення. Встановлюємо selectedAudiobook, викликаємо deleteSelected(), перевіряємо, що audiobookService.delete() був викликаний та аудіокнига видалена зі списку.
Рядки 87-96: shouldNotDeleteWhenNothingSelected(). Тест перевіряє, що deleteSelected() не викликає Service, якщо нічого не обрано. verify(..., never()) перевіряє, що метод не був викликаний.
Рядки 98-109: shouldUpdateStatusMessageOnError(). Тест перевіряє обробку помилок. Налаштовуємо mock, щоб він викидав виняток, викликаємо loadAudiobooks(), перевіряємо, що statusMessage містить повідомлення про помилку.
Тестування асинхронних операцій
ViewModel часто виконує асинхронні операції через Task. Як протестувати такий код?
Проблема: Task виконується у фоновому потоці
public void loadAudiobooks() {
Task<List<Audiobook>> task = new Task<>() {
@Override
protected List<Audiobook> call() {
return audiobookService.getAllAudiobooks();
}
};
task.setOnSucceeded(event -> {
audiobooks.setAll(task.getValue().stream()
.map(AudiobookViewModel::new)
.collect(Collectors.toList()));
});
executor.submit(task);
}
Якщо викликати viewModel.loadAudiobooks() у тесті, Task запуститься у фоновому потоці, але тест завершиться до того, як Task виконається. Результат: audiobooks буде порожнім, тест провалиться.
Рішення 1: Синхронне виконання у тестах
Модифікуємо ViewModel, щоб він приймав Executor у конструкторі. У production використовуємо ExecutorService, у тестах — синхронний Executor.
public class AudiobookListViewModel {
private final Executor executor;
@Inject
public AudiobookListViewModel(AudiobookService service, Executor executor) {
this.audiobookService = service;
this.executor = executor;
}
public void loadAudiobooks() {
Task<List<Audiobook>> task = createLoadTask();
executor.execute(task);
}
}
У тесті передаємо синхронний Executor:
@Test
void shouldLoadAudiobooksAsync() {
// Given
Executor syncExecutor = Runnable::run; // Виконує Task у поточному потоці
viewModel = new AudiobookListViewModel(audiobookService, syncExecutor);
when(audiobookService.getAllAudiobooks()).thenReturn(List.of(audiobook1));
// When
viewModel.loadAudiobooks();
// Then
assertEquals(1, viewModel.getAudiobooks().size());
}
Runnable::run — це Executor, що виконує Task у поточному потоці (синхронно). Тепер тест чекає завершення Task перед перевіркою результату.
Рішення 2: CountDownLatch для очікування
Якщо не можна змінити ViewModel, використовуємо CountDownLatch для очікування завершення Task:
@Test
void shouldLoadAudiobooksAsync() throws InterruptedException {
// Given
CountDownLatch latch = new CountDownLatch(1);
when(audiobookService.getAllAudiobooks()).thenReturn(List.of(audiobook1));
// Listener для сигналу про завершення
viewModel.getAudiobooks().addListener((ListChangeListener<AudiobookViewModel>) c -> {
latch.countDown();
});
// When
viewModel.loadAudiobooks();
// Then
assertTrue(latch.await(5, TimeUnit.SECONDS), "Task did not complete in time");
assertEquals(1, viewModel.getAudiobooks().size());
}
CountDownLatch блокує виконання тесту до виклику countDown(). Listener викликає countDown(), коли audiobooks змінюється (Task завершився). await(5, TimeUnit.SECONDS) чекає максимум 5 секунд.
Тестування валідації та Bindings
ViewModel містить валідаційну логіку та Bindings. Як їх протестувати?
Тестування валідації Properties
@Test
void shouldSetErrorWhenTitleIsEmpty() {
// Given
AudiobookFormViewModel viewModel = new AudiobookFormViewModel(service);
// When
viewModel.titleProperty().set("");
// Then
assertNotNull(viewModel.titleErrorProperty().get());
assertTrue(viewModel.titleErrorProperty().get().contains("required"));
}
@Test
void shouldClearErrorWhenTitleIsValid() {
// Given
AudiobookFormViewModel viewModel = new AudiobookFormViewModel(service);
viewModel.titleProperty().set(""); // Встановити помилку
// When
viewModel.titleProperty().set("Valid Title");
// Then
assertNull(viewModel.titleErrorProperty().get());
}
@Test
void shouldSetErrorWhenTitleIsTooLong() {
// Given
AudiobookFormViewModel viewModel = new AudiobookFormViewModel(service);
String longTitle = "a".repeat(300); // 300 символів
// When
viewModel.titleProperty().set(longTitle);
// Then
assertNotNull(viewModel.titleErrorProperty().get());
assertTrue(viewModel.titleErrorProperty().get().contains("too long"));
}
Ці тести перевіряють реактивну валідацію: зміна titleProperty → автоматичне оновлення titleErrorProperty.
Тестування isValidProperty
@Test
void shouldBeInvalidWhenTitleIsEmpty() {
// Given
AudiobookFormViewModel viewModel = new AudiobookFormViewModel(service);
// When
viewModel.titleProperty().set("");
// Then
assertFalse(viewModel.isValidProperty().get());
}
@Test
void shouldBeValidWhenAllFieldsAreValid() {
// Given
AudiobookFormViewModel viewModel = new AudiobookFormViewModel(service);
// When
viewModel.titleProperty().set("Valid Title");
viewModel.durationProperty().set(3600);
viewModel.releaseYearProperty().set(2020);
viewModel.selectedAuthorProperty().set(author);
// Then
assertTrue(viewModel.isValidProperty().get());
}
Ці тести перевіряють агрегацію валідності: isValid дорівнює true лише коли всі поля валідні.
Інтеграційне тестування: ViewModel + Repository
Unit-тести перевіряють ViewModel ізольовано (з mock залежностями). Інтеграційні тести перевіряють взаємодію ViewModel з реальними залежностями (Repository, Service, Database).
Налаштування H2 in-memory database для тестів
package dev.kostyl.audiobook.viewmodel;
import com.google.inject.Guice;
import com.google.inject.Injector;
import dev.kostyl.audiobook.infrastructure.TestAudiobookModule;
import dev.kostyl.audiobook.repository.AudiobookRepository;
import dev.kostyl.audiobook.service.AudiobookService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Statement;
import static org.junit.jupiter.api.Assertions.*;
class AudiobookListViewModelIntegrationTest {
private Injector injector;
private AudiobookListViewModel viewModel;
private AudiobookRepository repository;
private DataSource dataSource;
@BeforeEach
void setUp() throws Exception {
// Створення Guice Injector з тестовим Module
injector = Guice.createInjector(new TestAudiobookModule());
// Отримання залежностей
viewModel = injector.getInstance(AudiobookListViewModel.class);
repository = injector.getInstance(AudiobookRepository.class);
dataSource = injector.getInstance(DataSource.class);
// Ініціалізація схеми бази даних
initializeDatabase();
}
@AfterEach
void tearDown() throws Exception {
// Очищення бази даних після кожного тесту
cleanDatabase();
}
private void initializeDatabase() throws Exception {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS authors (
id UUID PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL
)
""");
stmt.execute("""
CREATE TABLE IF NOT EXISTS genres (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE
)
""");
stmt.execute("""
CREATE TABLE IF NOT EXISTS audiobooks (
id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
author_id UUID NOT NULL,
genre_id UUID NOT NULL,
duration_seconds INT NOT NULL,
release_year INT NOT NULL,
FOREIGN KEY (author_id) REFERENCES authors(id),
FOREIGN KEY (genre_id) REFERENCES genres(id)
)
""");
}
}
private void cleanDatabase() throws Exception {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("DROP TABLE IF EXISTS audiobooks");
stmt.execute("DROP TABLE IF EXISTS genres");
stmt.execute("DROP TABLE IF EXISTS authors");
}
}
@Test
void shouldLoadAudiobooksFromDatabase() {
// Given
Author author = new Author("George", "Orwell");
Genre genre = new Genre("Fiction");
Audiobook audiobook = new Audiobook("1984", author, genre, 36000, 1949);
repository.save(audiobook);
// When
viewModel.loadAudiobooks();
// Then
assertEquals(1, viewModel.getAudiobooks().size());
assertEquals("1984", viewModel.getAudiobooks().get(0).getTitle());
}
@Test
void shouldDeleteAudiobookFromDatabase() {
// Given
Author author = new Author("George", "Orwell");
Genre genre = new Genre("Fiction");
Audiobook audiobook = new Audiobook("1984", author, genre, 36000, 1949);
repository.save(audiobook);
viewModel.loadAudiobooks();
viewModel.setSelectedAudiobook(viewModel.getAudiobooks().get(0));
// When
viewModel.deleteSelected();
// Then
assertTrue(viewModel.getAudiobooks().isEmpty());
assertFalse(repository.findById(audiobook.getId()).isPresent());
}
}
TestAudiobookModule: Guice Module для тестів
package dev.kostyl.audiobook.infrastructure;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.kostyl.audiobook.repository.AudiobookRepository;
import dev.kostyl.audiobook.repository.jdbc.JdbcAudiobookRepository;
import dev.kostyl.audiobook.service.AudiobookService;
import dev.kostyl.audiobook.viewmodel.AudiobookListViewModel;
import javax.sql.DataSource;
import java.util.concurrent.Executor;
public class TestAudiobookModule extends AbstractModule {
@Override
protected void configure() {
// Repositories
bind(AudiobookRepository.class).to(JdbcAudiobookRepository.class);
// Services
bind(AudiobookService.class).in(Singleton.class);
// ViewModels
bind(AudiobookListViewModel.class).in(Singleton.class);
// Синхронний Executor для тестів
bind(Executor.class).toInstance(Runnable::run);
}
@Provides
@Singleton
DataSource provideDataSource() {
// H2 in-memory database для тестів
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
config.setUsername("sa");
config.setPassword("");
config.setMaximumPoolSize(5);
return new HikariDataSource(config);
}
}
Переваги інтеграційних тестів: Перевіряють реальну взаємодію компонентів (ViewModel → Service → Repository → Database). Виявляють помилки, що не видні у unit-тестах (SQL-запити, транзакції, маппінг даних).
Недоліки: Повільніші за unit-тести (IO-операції з БД), потребують налаштування (створення схеми, очищення даних).
UI-тестування з TestFX
TestFX — це фреймворк для автоматизованого тестування JavaFX UI. Він дозволяє емулювати дії користувача (кліки, введення тексту) та перевіряти стан UI.
Налаштування TestFX
pom.xml:
<dependency>
<groupId>org.testfx</groupId>
<artifactId>testfx-junit5</artifactId>
<version>4.0.18</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testfx</groupId>
<artifactId>openjfx-monocle</artifactId>
<version>jdk-12.0.1+2</version>
<scope>test</scope>
</dependency>
openjfx-monocle — це headless JavaFX runtime для запуску тестів без графічного екрану (на CI-серверах).
Приклад: Тестування AudiobookListView
package dev.kostyl.audiobook.ui;
import dev.kostyl.audiobook.AudiobookApp;
import javafx.scene.control.Button;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.stage.Stage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.testfx.api.FxRobot;
import org.testfx.framework.junit5.ApplicationExtension;
import org.testfx.framework.junit5.Start;
import static org.junit.jupiter.api.Assertions.*;
import static org.testfx.assertions.api.Assertions.assertThat;
@ExtendWith(ApplicationExtension.class)
class AudiobookListViewTest {
@Start
public void start(Stage stage) throws Exception {
// Запуск додатку
AudiobookApp app = new AudiobookApp();
app.start(stage);
}
@Test
void shouldDisplayAudiobooksInTable(FxRobot robot) {
// Given: дані вже завантажені у ViewModel
// Then
TableView<?> table = robot.lookup("#audiobookTable").query();
assertNotNull(table);
assertTrue(table.getItems().size() > 0);
}
@Test
void shouldFilterAudiobooksBySearchQuery(FxRobot robot) {
// Given
TableView<?> table = robot.lookup("#audiobookTable").query();
int initialSize = table.getItems().size();
// When
robot.clickOn("#searchField");
robot.write("1984");
// Then
assertTrue(table.getItems().size() < initialSize);
}
@Test
void shouldOpenFormWhenAddButtonClicked(FxRobot robot) {
// When
robot.clickOn("#addButton");
// Then
// Перевірка, що відкрився новий екран (форма додавання)
assertNotNull(robot.lookup("#audiobookFormView").query());
}
@Test
void shouldEnableDeleteButtonWhenRowSelected(FxRobot robot) {
// Given
Button deleteButton = robot.lookup("#deleteButton").query();
assertTrue(deleteButton.isDisabled());
// When
robot.clickOn(".table-row-cell"); // Клік на перший рядок таблиці
// Then
assertFalse(deleteButton.isDisabled());
}
@Test
void shouldDeleteAudiobookWhenDeleteButtonClicked(FxRobot robot) {
// Given
TableView<?> table = robot.lookup("#audiobookTable").query();
int initialSize = table.getItems().size();
robot.clickOn(".table-row-cell"); // Вибрати рядок
// When
robot.clickOn("#deleteButton");
robot.clickOn("OK"); // Підтвердження у діалозі
// Then
assertEquals(initialSize - 1, table.getItems().size());
}
}
Розбір TestFX тесту
Рядок 17: @ExtendWith(ApplicationExtension.class). Ця анотація ініціалізує TestFX для JUnit 5. Вона запускає JavaFX Application Thread та надає FxRobot для емуляції дій користувача.
Рядки 20-24: @Start метод. Цей метод викликається перед кожним тестом. Тут запускаємо додаток через app.start(stage). TestFX автоматично створює Stage та передає його у метод.
Рядок 28: FxRobot. Це об'єкт для емуляції дій користувача. robot.clickOn("#addButton") — клік на кнопку з fx:id="addButton". robot.write("text") — введення тексту.
Рядок 32: Селектори. robot.lookup("#audiobookTable") — пошук елемента за fx:id. robot.lookup(".table-row-cell") — пошук за CSS-класом. robot.lookup("Button Text") — пошук за текстом.
Рядок 33: query(). Метод query() повертає знайдений елемент. Якщо елемент не знайдено, викидає виняток.
Рядки 48-52: Емуляція введення тексту. robot.clickOn("#searchField") — фокус на поле. robot.write("1984") — введення тексту. TestFX автоматично емулює натискання клавіш.
Рядки 77-84: Емуляція діалогу. robot.clickOn("OK") — клік на кнопку діалогу за текстом. TestFX автоматично знаходить діалог та клікає на кнопку.
Test Doubles: Mock vs Stub vs Fake
У тестах ми замінюємо реальні залежності на Test Doubles — об'єкти-замінники. Є три основні типи:
Mock: Перевірка взаємодії
Концепція: Mock перевіряє, що метод був викликаний з правильними параметрами.
@Test
void shouldCallRepositorySave() {
// Given
AudiobookRepository mockRepository = mock(AudiobookRepository.class);
AudiobookService service = new AudiobookService(mockRepository);
Audiobook audiobook = new Audiobook("Title", author, genre, 3600, 2020);
// When
service.save(audiobook);
// Then
verify(mockRepository).save(audiobook);
verify(mockRepository).save(argThat(a -> a.getTitle().equals("Title")));
}
Коли використовувати: Для перевірки, що ViewModel викликає правильні методи Service/Repository.
Stub: Повернення заздалегідь визначених даних
Концепція: Stub повертає заздалегідь визначені дані, не перевіряючи взаємодію.
@Test
void shouldLoadAudiobooksFromStub() {
// Given
AudiobookRepository stubRepository = mock(AudiobookRepository.class);
when(stubRepository.findAll()).thenReturn(List.of(audiobook1, audiobook2));
AudiobookListViewModel viewModel = new AudiobookListViewModel(stubRepository);
// When
viewModel.loadAudiobooks();
// Then
assertEquals(2, viewModel.getAudiobooks().size());
// Не перевіряємо, чи був викликаний findAll() — це не важливо
}
Коли використовувати: Для unit-тестів ViewModel, коли потрібні дані, але не важлива взаємодія.
Fake: Спрощена реалізація
Концепція: Fake — це робоча реалізація, але спрощена (наприклад, InMemoryRepository замість JdbcRepository).
public class InMemoryAudiobookRepository implements AudiobookRepository {
private final Map<UUID, Audiobook> storage = new HashMap<>();
@Override
public void save(Audiobook audiobook) {
storage.put(audiobook.getId(), audiobook);
}
@Override
public Optional<Audiobook> findById(UUID id) {
return Optional.ofNullable(storage.get(id));
}
@Override
public List<Audiobook> findAll() {
return new ArrayList<>(storage.values());
}
@Override
public void delete(UUID id) {
storage.remove(id);
}
}
Використання у тестах:
@Test
void shouldSaveAndRetrieveAudiobook() {
// Given
AudiobookRepository fakeRepository = new InMemoryAudiobookRepository();
AudiobookService service = new AudiobookService(fakeRepository);
Audiobook audiobook = new Audiobook("Title", author, genre, 3600, 2020);
// When
service.save(audiobook);
Optional<Audiobook> retrieved = fakeRepository.findById(audiobook.getId());
// Then
assertTrue(retrieved.isPresent());
assertEquals("Title", retrieved.get().getTitle());
}
Коли використовувати: Для інтеграційних тестів, коли потрібна робоча реалізація без зовнішніх залежностей (БД, API).
Порівняння Test Doubles
== Mock Призначення: Перевірка взаємодії
Приклад: verify(repository).save(audiobook)
Переваги:
- Перевіряє, що метод викликаний
- Перевіряє параметри виклику
Недоліки:
- Не перевіряє логіку
- Крихкі тести (зміна порядку викликів ламає тест)
Використання: Unit-тести ViewModel
== Stub Призначення: Повернення даних
Приклад: when(repository.findAll()).thenReturn(list)
Переваги:
- Простий у налаштуванні
- Не залежить від порядку викликів
Недоліки:
- Не перевіряє взаємодію
- Потрібно налаштовувати для кожного тесту
Використання: Unit-тести ViewModel ::
== Fake Призначення: Спрощена реалізація
Приклад: InMemoryRepository
Переваги:
- Робоча реалізація (перевіряє логіку)
- Перевикористовується між тестами
Недоліки:
- Складніший у реалізації
- Може відрізнятися від реальної реалізації
Використання: Інтеграційні тести :: ::
Практичні завдання
Рівень 1: Unit-тестування ViewModel
Завдання 1.1: Створіть unit-тест для AudiobookFormViewModel, що перевіряє валідацію поля duration: має бути більше 0 та менше 86400 (24 години). Використайте Mockito для mock AudiobookService.
Завдання 1.2: Створіть unit-тест для AudiobookListViewModel.deleteSelected(), що перевіряє: якщо selectedAudiobook == null, метод audiobookService.delete() не викликається.
Завдання 1.3: Створіть unit-тест для фільтрації за жанром: додайте 3 аудіокниги різних жанрів, встановіть selectedGenre, перевірте, що filteredAudiobooks містить лише аудіокниги цього жанру.
Рівень 2: Інтеграційне тестування та асинхронність
Завдання 2.1: Створіть TestAudiobookModule з H2 in-memory database. Напишіть інтеграційний тест, що перевіряє збереження та завантаження аудіокниги через AudiobookListViewModel → AudiobookService → JdbcAudiobookRepository.
Завдання 2.2: Створіть тест для асинхронної операції loadAudiobooks(). Використайте синхронний Executor (Runnable::run) для спрощення тесту. Перевірте, що після виклику loadAudiobooks() список audiobooks містить дані з Repository.
Завдання 2.3: Створіть тест для обробки помилок: налаштуйте mock AudiobookService, щоб він викидав DataAccessException при виклику getAllAudiobooks(). Перевірте, що statusMessage містить повідомлення про помилку.
Рівень 3: UI-тестування з TestFX
Завдання 3.1: Створіть TestFX тест для форми додавання аудіокниги: введіть дані у всі поля, натисніть "Save", перевірте, що форма закрилася та аудіокнига з'явилася у списку.
Завдання 3.2: Створіть TestFX тест для валідації: залишіть поле title порожнім, перевірте, що кнопка "Save" неактивна та біля поля відображається повідомлення про помилку.
Завдання 3.3: Створіть InMemoryAudiobookRepository (Fake) та використайте його у інтеграційних тестах замість H2 database. Порівняйте швидкість виконання тестів з H2 та InMemory реалізацією.
Підсумок
У цій статті ми розглянули тестування JavaFX MVVM-додатків на всіх рівнях. Ключові висновки:
MVVM дозволяє тестувати логіку без UI. ViewModel — це POJO, що не залежить від JavaFX. Це робить unit-тестування простим, швидким та надійним. 90% логіки можна покрити unit-тестами ViewModel з mock залежностями.
Unit-тести ViewModel з Mockito. Створюємо mock залежності (@Mock AudiobookService), передаємо їх у ViewModel через конструктор, викликаємо методи ViewModel, перевіряємо результат через assertions та verify(). Тести швидкі (без IO), ізольовані (не залежать від БД), легко підтримувати.
Тестування асинхронних операцій. Два підходи: синхронний Executor у тестах (простіше, швидше) або CountDownLatch для очікування завершення Task (fallback для незмінного коду). Синхронний Executor (Runnable::run) виконує Task у поточному потоці — тест чекає завершення.
Тестування валідації та Bindings. Перевіряємо реактивну валідацію: зміна Property → автоматичне оновлення error Property. Перевіряємо агрегацію валідності: isValid дорівнює true лише коли всі поля валідні. Тести простіші за UI-тести, бо працюють з Properties, а не з UI-елементами.
Інтеграційні тести з H2 in-memory database. Перевіряють реальну взаємодію ViewModel → Service → Repository → Database. Використовуємо TestAudiobookModule з H2 in-memory для швидкості та ізоляції. Ініціалізуємо схему у @BeforeEach, очищуємо у @AfterEach.
UI-тестування з TestFX. Автоматизоване тестування JavaFX UI: емуляція кліків (robot.clickOn()), введення тексту (robot.write()), перевірка стану UI (robot.lookup().query()). Найповільніші та найкрихкіші тести — використовуємо лише для критичних user flows.
Test Doubles: Mock vs Stub vs Fake. Mock перевіряє взаємодію (verify()), Stub повертає дані (when().thenReturn()), Fake — спрощена реалізація (InMemoryRepository). Mock та Stub для unit-тестів, Fake для інтеграційних тестів.
Піраміда тестування: Багато unit-тестів (швидкі, дешеві) → менше інтеграційних тестів (повільніші, дорожчі) → мало UI-тестів (найповільніші, найдорожчі). MVVM дозволяє зсунути більшість тестів у нижню частину піраміди.
У наступній статті ми розглянемо стилізацію та теми у JavaFX: CSS для JavaFX (синтаксис, селектори, псевдокласи), створення Light та Dark тем, CSS Variables для централізованого керування кольорами, стилізацію складних компонентів (TableView, ListView), підключення Font Awesome для іконок, та responsive design для адаптації під різні розміри вікна.
Навігація та управління екранами у JavaFX MVVM
Від хаотичних переходів до централізованої навігації: Navigator Pattern, ScreenRegistry, передача параметрів між екранами, Navigation Stack для історії переходів, модальні діалоги з поверненням результату, інтеграція з ViewModel через Events.
Стилізація та теми у JavaFX: CSS та User Experience
Від функціональності до естетики: JavaFX CSS (синтаксис, селектори, псевдокласи), створення Light та Dark тем, CSS Variables, стилізація TableView, підключення Font Awesome, responsive design.