How to test JavaFX (MVC) Controller Logic?

2019-05-21 10:20发布

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("test@gmail.com"));

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!
        }
    }
}

1条回答
Bombasti
2楼-- · 2019-05-21 11:12

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.

查看更多
登录 后发表回答