Vaadin
The client uses Vaadin 8 and the web has several ideas how to test (drive) it:
- Use the Model View Presenter pattern, see Vaadin Advanced Application Architectures.
- Gradually separate the UI from the logic, thinking of MVP more as a process than a pattern, starting with separate methods, which are accessible to tests so that the test can invoke them. For more details see Is MVP a Best Practice?
- Create factories for all UI components so that tests can mock the UI. See this answer on StackOverflow for more details.
- Running integrated tests in a test bed to simulate
UI.getCurrent()
orVaadinSession.getCurrent()
, e.g. using the Karibu-Testing library.
We used MVP as described in my previous article. The group decided against a view model, because there was no specific UI model. Next was the presenter. The first two tests made us implement the requirement for a successful login: User name and password given, button "Log in" clicked, back end reports success, then the form is closed.
import org.mockito.Mockito; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class LoginPresenterTest { public static final String USERNAME = "Peter"; public static final String PASSWORD = "Slovakia"; private AuthenticationService authenticationService; private NavigatorView navigatorView; private LoginView loginView; private ExecutorService executorService; private LoginPresenter loginPresenter; @BeforeEach public void init() { authenticationService = Mockito.mock(AuthenticationService.class); navigatorView = Mockito.mock(NavigatorView.class); loginView = Mockito.mock(LoginView.class); executorService = Executors.newSingleThreadExecutor(); loginPresenter = new LoginPresenter(authenticationService, navigatorView, loginView, executorService); } @Test public void should_call_backend_on_button_click() throws InterruptedException { Mockito.when(authenticationService.authenticate(USERNAME, PASSWORD)) .thenReturn(new AuthenticationResult(true, "we do not care!")); loginPresenter.login(USERNAME, PASSWORD); waitForAuthentification(); Mockito.verify(authenticationService, Mockito.times(1)) .authenticate(USERNAME, PASSWORD); } @Test public void should_navigate_on_login_success() throws InterruptedException { Mockito.when(authenticationService.authenticate(USERNAME, PASSWORD)) .thenReturn(new AuthenticationResult(true, "we do not care!")); loginPresenter.login(USERNAME, PASSWORD); waitForAuthentification(); Mockito.verify(navigatorView, Mockito.times(1)) .navigateToDashBoard(); } private void waitForAuthentification() throws InterruptedException { executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.SECONDS); }Using an
ExecutorService
made the asynchronous execution pretty simple. I like how they wrote two tests for the different aspects of successful login. Using smaller steps helped to get some logic sooner. It turned out that we needed another component for navigation, so we made up the NavigatorView
. Then we went for the requirements User name and password given, button "Log in" clicked, back end reports an error, show message in error line, form stays open and While the back end is working, the "Log in" button stays disabled.import org.mockito.InOrder; import static org.mockito.ArgumentMatchers.any; ... @Test public void should_display_error_on_login_error() throws InterruptedException { String errorMessage = "error message"; Mockito.when(authenticationService.authenticate(USERNAME, PASSWORD)) .thenReturn(new AuthenticationResult(false, errorMessage)); loginPresenter.login(USERNAME, PASSWORD); waitForAuthentification(); Mockito.verify(navigatorView, Mockito.never()) .navigateToDashBoard(); Mockito.verify(loginView, Mockito.times(1)) .displayErrorMessage(errorMessage); } @Test public void should_register_to_view() { Mockito.verify(loginView, Mockito.times(1)) .addListener(any(LoginListener.class)); } @Test public void should_disable_button_while_backend_is_working() throws InterruptedException { Mockito.when(authenticationService.authenticate(USERNAME, PASSWORD)) .thenReturn(new AuthenticationResult(false, "we do not care!")); loginPresenter.login(USERNAME, PASSWORD); InOrder inOrder = Mockito.inOrder(loginView); inOrder.verify(loginView).setLoginButtonEnabled(false); waitForAuthentification(); inOrder.verify(loginView).setLoginButtonEnabled(true); } }The finished presenter class looked almost like the one for Swing.
import java.util.concurrent.ExecutorService; public class LoginPresenter implements LoginListener { private final AuthenticationService authenticationService; private final NavigatorView navigatorView; private final LoginView loginView; private final ExecutorService executorService; public LoginPresenter(AuthenticationService authenticationService, NavigatorView navigatorView, LoginView loginView, ExecutorService executorService) { this.authenticationService = authenticationService; this.navigatorView = navigatorView; this.loginView = loginView; this.executorService = executorService; loginView.addListener(this); } @Override public void login(String username, String password) { loginView.setLoginButtonEnabled(false); executorService.submit(() -> invokeLoginService(username, password)); } private void invokeLoginService(String username, String password) { AuthenticationResult authenticate = authenticationService.authenticate(username, password); loginView.setLoginButtonEnabled(true); if (authenticate.success) { navigatorView.navigateToDashBoard(); } else { loginView.displayErrorMessage(authenticate.message); } } }And the presenter would hide behind the
LoginListener
interface.public interface LoginListener { void login(String username, String password); }Test Bed
As for the Swing version, using a test bed helped testing the view. There is a useable Vaadin testing tool, the Karibu-Testing library. It worked out of the box, no special treatment necessary. We wrote tests that the view forwards actions to the
LoginListener
, i.e. the LoginPresenter
, and that the view can display an error message.import com.github.mvysny.kaributesting.v8.MockVaadin; import com.vaadin.ui.Button; import com.vaadin.ui.Label; import com.vaadin.ui.TextField; import org.mockito.Mockito; import static com.github.mvysny.kaributesting.v8.LocatorJ.*; class LoginViewTest { private final LoginViewImpl content = new LoginViewImpl(); @BeforeEach void beforeEach() { MockVaadin.setup(() -> new HelloUI(content)); } @AfterEach void afterEach() { MockVaadin.tearDown(); } @Test void should_pass_on_button_click() { LoginListener loginListener = Mockito.mock(LoginListener.class); content.addListener(loginListener); // simulate a text entry as if entered by the user _setValue( _get(TextField.class, spec -> spec.withCaption("Phone, email or username")), "peter"); _setValue( _get(TextField.class, spec -> spec.withCaption("Password")), "Slovakia"); // simulate a button click as if clicked by the user _click(_get(Button.class, spec -> spec.withCaption("Log in"))); Mockito.verify(loginListener).login("peter", "Slovakia"); } @Test void should_display_error_message() { content.displayErrorMessage("myError message"); // simulate a text entry as if entered by the user Label label = _get(Label.class, spec -> spec.withValue("myError message")); Assertions.assertEquals(label.getStyleName(), "warning"); } }Satisfying these two tests gave us the following view:
import com.vaadin.ui.*; public class LoginViewImpl extends VerticalLayout implements LoginView { private final TextField username = new TextField("Phone, email or username"); private final TextField password = new PasswordField("Password"); private final Button loginButton = new Button("Log in"); private final Label errorLabel = new Label(); public LoginViewImpl() { addComponent(username); addComponent(password); addComponent(loginButton); addComponent(errorLabel); errorLabel.setStyleName("warning"); } @Override public void addListener(LoginListener loginListener) { loginButton.addClickListener(clickEvent -> { loginListener.login(username.getValue(), password.getValue()); }); } @Override public void displayErrorMessage(String errorMessage) { errorLabel.setValue(errorMessage); } // ... }That was as far as we went during the sessions. We did not finish the whole login window, next tests would drive the logic of
setLoginButtonEnabled(boolean)
.Conclusion
Besides small differences, the code looks much like the Swing version. The process worked the same, most tests do not deal with the UI. As I do not know many UI frameworks my conclusion is limited. Maybe - hopefully - probably this approach works for state based user interfaces as long as there is a reasonable test bed. I can see how it could work for C# WinForms. On the other hand, Vaadin and WinForms are very similar to Java's Swing. I need to look at other kind of user interfaces.
No comments:
Post a Comment