Проектування баз даних

Тестування JavaFX MVVM-додатків

Від unit-тестів ViewModel до UI-автоматизації: тестування з Mockito, інтеграційні тести з H2 database, TestFX для автоматизованого тестування JavaFX UI, Test Doubles (Mock vs Stub vs Fake), організація тестів для різних шарів.

Тестування 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-тести (багато, швидкі, дешеві) → Інтеграційні тести (менше, повільніші, дорожчі) → UI-тести (мало, найповільніші, найдорожчі). MVVM дозволяє зсунути більшість тестів у нижню частину піраміди (unit-тести ViewModel), що робить тестування швидшим та надійнішим.

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 містить повідомлення про помилку.

Переваги unit-тестування ViewModel: Швидкі (без IO-операцій), ізольовані (не залежать від бази даних, UI), легко писати (POJO з явними залежностями), легко підтримувати (зміни UI не ламають тести).

Тестування асинхронних операцій

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 секунд.

Асинхронні тести складніші та повільніші. Якщо можливо, використовуйте синхронний Executor у тестах (Рішення 1). Це робить тести простішими, швидшими та надійнішими. CountDownLatch — це fallback для випадків, коли ViewModel не можна модифікувати.

Тестування валідації та 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-операції з БД), потребують налаштування (створення схеми, очищення даних).

Використовуйте H2 in-memory для інтеграційних тестів. Це швидко (БД у пам'яті), ізольовано (кожен тест має свою БД), не потребує зовнішніх залежностей (PostgreSQL, MySQL). Для production-like тестів використовуйте Testcontainers з реальною БД.

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 автоматично знаходить діалог та клікає на кнопку.

UI-тести найповільніші та найкрихкіші. Вони залежать від структури UI (fx:id, CSS-класи, текст кнопок). Зміна UI ламає тести. Використовуйте UI-тести лише для критичних user flows (реєстрація, оплата, основні функції). Більшість логіки покривайте unit-тестами ViewModel.

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 Призначення: Перевірка взаємодії

== 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. Напишіть інтеграційний тест, що перевіряє збереження та завантаження аудіокниги через AudiobookListViewModelAudiobookServiceJdbcAudiobookRepository.

Завдання 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 для адаптації під різні розміри вікна.

Copyright © 2026