The behavior of an UI is a complex state machine which should be tested. This can be done not only by automated UI-tests, but also with Unit Tests. It’s not easy to write Unit Tests for UIs, because you need to decouple the state of the UI from its declaration.
One approach is the MVVM pattern. MVVM introduces a ViewModel layer that contains the state and behavior of an UI. The View only represents the ViewModel and reacts on its changes.
Shortfacts MVVM:
- View does not know the Model
- Model does not know anybody
- ViewModel does not know the View (one difference to MVP)
- ViewModel knows Model
Let’s try it. We want to build a login component which has two text fields (name/password) and a login button. The button should only be active when there is some text in the text fields. You can find the example on GitHub.
Step 1 - Write tests to specify the ViewModel
Because the ViewModel represents the UI state, we create the ViewModel first.Testdriven. Therefore we write an unit test to specify the behavior. The ViewModel should provide three properties which can be accessed
- userNameProperty() – represents the current user input for the username
- passwordProperty() - represents the current user input for the password
- loginPossibleProperty() - represents whether a login is possible based on conditions (username and password not empty)
public class LoginViewModelTest { @Test public void disableLoginButton() throws Exception { LoginViewModel loginViewModel = new LoginViewModel(); // No username and password were set Assert.assertFalse(loginViewModel.isLoginPossibleProperty().get()); loginViewModel.userNameProperty().set("Stefanie Albrecht"); // username was set, but no password Assert.assertFalse(loginViewModel.isLoginPossibleProperty().get()); loginViewModel.passwordProperty().set("<3"); // username and password were set Assert.assertTrue(loginViewModel.isLoginPossibleProperty().get()); } }
Step 2 - Implement the ViewModel, so the tests can pass
Yea – we have a test, but the test won’t compile and pass, if we don’t provide a proper implementation. So lets implement the ViewModel.
The userName
and the password
properties are representing the current input of upcoming text fields, so they are of the type StringProperty
. The loginPossible
property signals whether the user provided some input for the username and password field. It should be true, if both other properties have values.
public class LoginViewModel { private final StringProperty userName = new SimpleStringProperty(); private final StringProperty password = new SimpleStringProperty(); private final ReadOnlyBooleanWrapper loginPossible = new ReadOnlyBooleanWrapper(); public LoginViewModel() { //Logic to check, whether the login is possible or not loginPossible.bind(userName.isNotEmpty().and(password.isNotEmpty())); } public StringProperty userNameProperty() { return userName; } public StringProperty passwordProperty() { return password; } public ReadOnlyBooleanProperty isLoginPossibleProperty() { return loginPossible.getReadOnlyProperty(); } }
Step 3 - Implement the View and bind it to the ViewModel
We can implement the View after the unit test for the ViewModel passed. I suggest to create a FXML-file by using the Scene Builder first. Now we create the JavaFX-Controller which is also part of the View layer in MVVM. You can view both files in the next code box by clicking on the tabs of the box. Let’s connect the View and the ViewModel by data binding, which ensures the propagation of changes in the properties. As we want the login button to be deactivated when there is no input in the text fields, we have to bind the disableProperty()
of the login button to the isLoginPossibleProperty()
in the ViewModel.
public class LoginView { @FXML private TextField userNameTextField; @FXML private PasswordField passwordTextField; @FXML private Button loginButton; @FXML void initialize() { //Create the ViewModel - In production this should be done by dependency injection LoginViewModel loginViewModel = new LoginViewModel(); //Connect the ViewModel userNameTextField.textProperty().bindBidirectional(loginViewModel.userNameProperty()); passwordTextField.textProperty().bindBidirectional(loginViewModel.passwordProperty()); loginButton.disableProperty().bind(loginViewModel.isLoginPossibleProperty().not()); } }
<AnchorPane xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1" fx:controller="de.saxsys.mvvm.LoginView"> <children> <VBox alignment="CENTER_RIGHT" spacing="10.0"> <children> <TextField fx:id="userNameTextField" alignment="BOTTOM_RIGHT" promptText="Username" /> <PasswordField fx:id="passwordTextField" alignment="CENTER_RIGHT" promptText="Password" /> <Button fx:id="loginButton" mnemonicParsing="false" onAction="#onLoginButtonPressed" text="Login" textAlignment="CENTER" /> </children> <padding> <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" /> </padding> </VBox> </children> <opaqueInsets> <Insets /> </opaqueInsets> </AnchorPane>
If the user enters a username or password the changes will be propagated by data binding from the View to the ViewModel. The ViewModel reevaluates the defined binding of the isLoginPossibleProperty()
. A possible change of the isLoginPossibleProperty()
will be propagated to the disabledProperty()
of the loginButton
.
As you can see, we removed every logic from the view. The view is stupid and the behavior testable.
The example can be found on GitHub.