How to test JavaFX (MVC) Controller Logic?
Asked Answered
D

2

6

How do we properly write unit/integration tests for the JavaFX Controller logic? Assuming the Controller class I'm testing is named LoadController, and it's unit test class is LoadControllerTest, my confusion stems from:

  • If the LoadControllerTest class instantiates a new LoadController object via LoadController loadController = new LoadController(); I can then inject values into the controller via (many) setters. This seems the only way short of using reflection (legacy code). If I don't inject the values into the FXML controls then the controls obviously aren't initialized yet, returning null.

  • If I instead use FXMLLoader's loader.getController() method to retrieve the loadController it will properly initialize the FXML controls but the controller's initialize() is thus invoked which results in a very slow run, and since there's no way to inject the mocked dependencies, it's more of an integration test poorly written.

I'm using the former approach right now, but is there a better way?

TestFX

The answer here involves TestFX which has @Tests based around the main app's start method not the Controller class. It shows a method of testing the controller with

     verifyThat("#email", hasText("[email protected]"));

but this answer involves DataFX - whereas I'm simply asking about JavaFX's MVC pattern. Most TestFX discussion focuses on it's GUI capabilities, so I'm curious whether it's ideal for the controller too.

The following example shows how I inject the controller with a VBox so that it isn't null during the test. Is there a better way? Please be specific

 public class LoadControllerTest {

    @Rule
    public JavaFXThreadingRule javafxRule = new JavaFXThreadingRule();

    private LoadController loadController;
    private FileSorter fileSorter;
    private LocalDB localDB;
    private Notifications notifications;
    private VBox mainVBox = new VBox();      // VBox to inject

    @Before
    public void setUp() throws MalformedURLException {
        fileSorter = mock(FileSorter.class);    // Mock all dependencies    

        when(fileSorter.sortDoc(3)).thenReturn("PDF");   // Expected result

        loadController = new LoadController();
        URL url = new URL("http://example.com/");
        ResourceBundle rb = null;
        loadController.initialize(url, rb);   // Perhaps really dumb approach
    }

    @Test
    public void testFormatCheck() {
        loadController.setMainVBox(mainVBox);  // set value for FXML control
        assertEquals("PDF", loadController.checkFormat(3));
    }
}

public class LoadController implements Initializable {

    @FXML
    private VBox mainVBox;   // control that's null unless injected/instantiated

    private FileSorter fileSorter = new FileSorter();  // dependency to mock

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        //... create listeners
    }

    public String checkFormat(int i) {
        if (mainVBox != null) {    // This is why injection was needed, otherwise it's null
            return fileSorter.sortDoc(i);
        }
        return "";
    }

    public void setMainVBox(VBox menuBar) {
        this.mainVBox = mainVBox;     // set FXML control's value
    }

    // ... many more setters ...
}

UPDATE

Here's a complete demo based on hotzst's suggestions, but it returns this error:

org.mockito.exceptions.base.MockitoException: Cannot instantiate @InjectMocks field named 'loadController' of type 'class com.mypackage.LoadController'. You haven't provided the instance at field declaration so I tried to construct the instance. However the constructor or the initialization block threw an exception : null

import javafx.scene.layout.VBox;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;

@RunWith(MockitoJUnitRunner.class)
public class LoadControllerTest {

    @Rule
    public JavaFXThreadingRule javafxRule = new JavaFXThreadingRule();
    @Mock
    private FileSorter fileSorter;
    @Mock
    private VBox mainVBox;
    @InjectMocks
    private LoadController loadController;  

    @Test
    public void testTestOnly(){
        loadController.testOnly();    // Doesn't even get this far
    }
}

import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.layout.VBox;
import java.net.URL;
import java.util.ResourceBundle;

public class LoadController implements Initializable {

    private FileSorter fileSorter = new FileSorter(); // Fails here since creates a real object *not* using the mock.

    @FXML
    private VBox mainVBox;

    @Override
    public void initialize(URL location, ResourceBundle resources) {
      //
    }

    public void testOnly(){
        if(mainVBox==null){
            System.out.println("NULL VBOX");
        }else{
            System.out.println("NON-NULL VBOX"); // I want this to be printed somehow!
        }
    }
}
Dealate answered 19/1, 2017 at 2:16 Comment(5)
I cannot reproduce the error that you mentioned, however I had to adjust your example slightly: The FileSorter was unknown, so I used an empty static inner class and in the Test the JavaFXThreadingRule can be omitted.Bawdry
@Bawdry Are you saying that you got my code to work correctly, printing "NON-NULL VBOX"? I understand you don't have the FileSorter class, but it's merely to represent any privately instatiated class I might have. My controller happens to have ~10 similar instantiations but the @Mock annotation doesn't seem to take effect for them. Why the heck would FileSorter be creating a real object here and not using the test's mock? So strange!Dealate
@Bawdry if I simply comment out private FileSorter fileSorter = new FileSorter(); then the code works, returning "NON-NULL VBOX". Why is the @Mock private FileSorter fileSorter; code not taking effect?Dealate
Yes when running the test it runs through successfully and the output I get printed on the console is indeed ON-NULL VBOX. The fileSorter should not pose a problem, even when instantiating the variable with new, which should be overriden by the mock in the test. What you can try is to move the initialisation into the constructor.Bawdry
@Bawdry You've proved your point, and deserve the +1. Although, I feel that some of my initial confusion remains such as ... is this the most appropriate method? Setters were bulky but perhaps they maintain encapsulation better. @InjectMocks seems like a reflection hack similar to injecting via WhiteBox. Thoughts?Dealate
B
2

You can use a test framework like Mockito to inject your dependencies in the controller. Thereby you can forgo probably most of the setters, at least the ones that are only present to facilitate testing.

Going with the example code you provided I adjusted the class under test (define an inner class for the FileSorter):

public class LoadController implements Initializable {

    private FileSorter fileSorter = new FileSorter();

    @FXML
    private VBox mainVBox;

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        //
    }

    public void testOnly(){
        if(mainVBox==null){
            System.out.println("NULL VBOX");
        }else{
            System.out.println("NON-NULL VBOX");
        }
    }

    public static class FileSorter {}
}

The @FXML annotation does not make any sense here, as no fxml file is attached, but it does not seem to have any effect on the code or Test.

Your test class could then look something like this:

@RunWith(MockitoJUnitRunner.class)
public class LoadControllerTest {

    @Mock
    private LoadController.FileSorter fileSorter;
    @Mock
    private VBox mainVBox;
    @InjectMocks
    private LoadController loadController;

    @Test
    public void testTestOnly(){
        loadController.testOnly();
    }
}

This test runs through successfully with the following output:

NON-NULL VBOX

The @Rule JavaFXThreadingRule can be ommited, as when testin like this you are not running through any part of code that should be executed in the JavaFX Thread.

The @Mock annotation together with the MockitoJUnitRunner creates a mock instance that is then injected into the instance annotated with @InjectMocks.

An excellent tutorial can be found here. There are also other frameworks for mocking in tests like EasyMock and PowerMock, but Mockito is the one I use and am most familiar with.

I used Java 8 (1.8.0_121) together with Mockito 1.10.19.

Bawdry answered 19/1, 2017 at 9:42 Comment(4)
I'm sorry, but I don't see how this answers my question. You've only shown how to instantiate a mocked mainVBox but don't show how to inject it into the loadController object, which is precisely what I'm confused about. You say Mockito can do this, but your example doesn't show how to actually inject the mocked VBox into the LoadController class (SUT). Also, I'm assuming I continue to do loadController.initialize(url, rb); in the setUp method? Your answer hasn't taught me anything yet so I can't upvote it, please clarifyGarv
Given your code, my SUT would still return null on this line if (mainVBox != null) {... because it's value isn't set or mocked.Garv
@Mathomatic, you are correct, the code missed that bit (the @InjectMocks annotation), which I added now along with some explanation.Bawdry
Thank you but the mocked FileSorter doesn't seem to be injected because the class under test still tries to instantiate it! I've updated my question to include the error I'm receiving as well as a full "working" demo which shows where the error occurs. What am I doing wrong?Garv
W
0

If you want to test the controller by interacting with the UI, TestFX may be an option for you.

I created a simple test project to showcase its capabilities:
https://github.com/ArchibaldBienetre/javaFxTestGradle

Find a full test case here: https://github.com/ArchibaldBienetre/javaFxTestGradle/blob/main/src/integrationTest/java/com/example/javafxtest/integrationtest/FileChooserApplicationTest.java

Wary answered 21/12, 2021 at 19:29 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.