Efficient GWT unit testing – MVP + google gin + AtUnit
For about the last year and a half I have been using a few simple patterns that make testing GWT applications very easy. None of these are anything new, in fact most have been in use for years. It always surprises me when I work with a new team that is not doing these in their UI (GWT) code, but have used all or most of them on the server side. Testing GWT apps is simple! There are just a few key things to consider from the start, and you can easily achieve (and measure) 80% test coverage.
- Maintain separation between the business logic and your GUI layout. I use the MVP (Model View Presenter) pattern to do this. The view is where all the UI layout, data display and eventing necessary to intercept user interaction lives. Any code that leads to a GWT.create() call will cause regular unit tests to fail, and needs to be confined to the view. The presenter is where almost all the code to unit test is. Business logic, data processing (if not done on the server), RPC, event handling, etc.
- Use Dependency Injection (Inversion of Control) to loosely couple the GUI code and business logic. Google gin is pretty much the only choice here, fortunately its a great one.
- Use a mock object library for creating stand-ins for your GUI code during unit testing. Mockito is my favorite choice for this task, it gets the job done and gets out of the way.
@ImplementedBy(PresenterImpl.class)
public interface Presenter {
void attach();
}
@Singleton
public class PresenterImpl implements Presenter, InteractionHandler {
protected static final String PLEASE_ENTER_AT_LEAST_FOUR_CHARACTERS = "Please enter at least four characters";
protected static final String REMOTE_PROCEDURE_CALL_FAILURE = "Remote Procedure Call - Failure";
protected static final String REMOTE_PROCEDURE_CALL = "Remote Procedure Call";
@Inject
protected View view;
@Inject
protected GreetingServiceAsync greetingService;
@Override
public void attach() {
view.attach();
}
/**
* Send the name from the nameField to the server and wait for a response.
*/
@Override
public void sendNameToServer(String textToServer) {
// First, we validate the input.
if (!FieldVerifier.isValidName(textToServer)) {
view.setErrorLabelText(PLEASE_ENTER_AT_LEAST_FOUR_CHARACTERS);
return;
} else {
view.setErrorLabelText("");
}
// Then, we send the input to the server.
view.setSendButtonEnabled(false);
view.setTextToServerLabelText(textToServer);
view.clearServerResponseLabelText();
greetingService.greetServer(textToServer, new TextToServerCallback());
}
protected class TextToServerCallback implements AsyncCallback<String> {
public void onFailure(Throwable caught) {
// Show the RPC error message to the user
view.onFailure(REMOTE_PROCEDURE_CALL_FAILURE);
}
public void onSuccess(String result) {
view.onSuccess(REMOTE_PROCEDURE_CALL, result);
}
}
}
Likewise the view gets a reference to the presenter through the interaction handler interface to pass user interaction back to it. The injection is done using a gin Provider to prevent an infinite loop in gin.
@Singleton
public class ViewImpl extends Composite implements View {
private static final Binder BINDER = GWT.create(Binder.class);
/**
* The message displayed to the user when the server cannot be reached or
* returns an error.
*/
private static final String SERVER_ERROR = "An error occurred while "
+ "attempting to contact the server. Please check your network " + "connection and try again.";
@Inject
protected Provider<InteractionHandler> interacrionHandlerProvider;
@UiField
protected TextBox nameField;
@UiField
protected Button sendButton;
@UiField
protected Label errorLabel;
private DialogBox dialogBox;
private Button closeButton;
private HTML serverResponseLabel;
private Label textToServerLabel;
public ViewImpl() {
super();
initWidget(BINDER.createAndBindUi(this));
// Focus the cursor on the name field when the app loads
nameField.setFocus(true);
nameField.selectAll();
createDialog();
}
@UiHandler("sendButton")
public void handleSendButtonClick(ClickEvent event) {
interacrionHandlerProvider.get().sendNameToServer(nameField.getText());
}
@UiHandler("nameField")
public void handleNameFieldEnter(KeyUpEvent event) {
if (event.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
interacrionHandlerProvider.get().sendNameToServer(nameField.getText());
}
}
public void createDialog() {
dialogBox = new DialogBox();
dialogBox.setText("Remote Procedure Call");
dialogBox.setAnimationEnabled(true);
closeButton = new Button("Close");
// We can set the id of a widget by accessing its Element
closeButton.getElement().setId("closeButton");
textToServerLabel = new Label();
serverResponseLabel = new HTML();
VerticalPanel dialogVPanel = new VerticalPanel();
dialogVPanel.addStyleName("dialogVPanel");
dialogVPanel.add(new HTML("<b>Sending name to the server:</b>"));
dialogVPanel.add(textToServerLabel);
dialogVPanel.add(new HTML("<br><b>Server replies:</b>"));
dialogVPanel.add(serverResponseLabel);
dialogVPanel.setHorizontalAlignment(VerticalPanel.ALIGN_RIGHT);
dialogVPanel.add(closeButton);
dialogBox.setWidget(dialogVPanel);
// Add a handler to close the DialogBox
closeButton.addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
dialogBox.hide();
sendButton.setEnabled(true);
sendButton.setFocus(true);
}
});
}
@Override
public Widget asWidget() {
return this;
}
@Override
public void attach() {
RootPanel.get().add(this);
}
@Override
public void setErrorLabelText(String text) {
errorLabel.setText(text);
}
@Override
public void setSendButtonEnabled(boolean enabled) {
sendButton.setEnabled(enabled);
}
@Override
public void setTextToServerLabelText(String textToServer) {
textToServerLabel.setText(textToServer);
}
@Override
public void onFailure(String string) {
dialogBox.setText(string);
serverResponseLabel.addStyleName("serverResponseLabelError");
serverResponseLabel.setHTML(SERVER_ERROR);
dialogBox.center();
closeButton.setFocus(true);
}
@Override
public void onSuccess(String text, String result) {
dialogBox.setText(text);
serverResponseLabel.removeStyleName("serverResponseLabelError");
serverResponseLabel.setHTML(result);
dialogBox.center();
closeButton.setFocus(true);
}
@Override
public void clearServerResponseLabelText() {
serverResponseLabel.setText("");
}
}
Now for the testing magic! For the same reason you can replace the view implementation with something else at test time (decoupled by interfaces), you can replace the gin implementation with a guice one. Gin is full of GWT.create() calls, but Guice is a java library and works beautifully in unit tests. Because the gin annotations are really guice annotations, simply starting a guice module instead of a gin one works with no other modifications.
The most efficient way to start a guice module in a unit test and combine it with the power of Mockito is AtUnit. To setup a test where the presenter is created for you and injected with either the configuration defined in the annotations or mockito objects all you have to do is add a few simple annotations.
@RunWith(AtUnit.class)
@Container(Container.Option.GUICE)
@MockFramework(MockFramework.Option.MOCKITO)
public class PresenterTest extends Assert implements Module {
private static final String IT_WORKED = "It worked";
private static final String HELLO_WORLD = "Hello World";
/**
* Inject the class to be tested. Using the Impl gives access to methods not
* on the presenter interface w/o casting.
*/
@Inject
@Unit
PresenterImpl presenter;
/**
* Override the annotation configuration in the code to inject a mock
* instance.
*/
@Mock
View view;
/**
* Override the default gin call to GWT.create() to inject a mock instance.
*/
@Mock
GreetingServiceAsync greetingService;
@Override
public void configure(Binder binder) {
// Nothing extra to configure. All injection configuration is already
// done by annotations in the code or the @Mock annotations above
}
/**
* Verify the Presenter.attach() delegates to the view.
*/
@Test
public void testAttach() {
presenter.attach();
verify(view).attach();
}
@Test
public void testSendToServer() {
presenter.sendNameToServer(HELLO_WORLD);
verify(view).setErrorLabelText("");
verify(view).setSendButtonEnabled(false);
verify(view).setTextToServerLabelText(HELLO_WORLD);
verify(view).clearServerResponseLabelText();
verify(greetingService).greetServer(Mockito.eq(HELLO_WORLD), Mockito.any(TextToServerCallback.class));
}
@Test
public void testSendToServerInvalidText() {
presenter.sendNameToServer("123");
verify(view).setErrorLabelText(PresenterImpl.PLEASE_ENTER_AT_LEAST_FOUR_CHARACTERS);
}
@Test
public void testCallbackOnSuccess() {
TextToServerCallback callback = presenter.new TextToServerCallback();
callback.onSuccess(IT_WORKED);
verify(view).onSuccess(PresenterImpl.REMOTE_PROCEDURE_CALL, IT_WORKED);
}
@Test
public void testCallbackOnFailure() {
TextToServerCallback callback = presenter.new TextToServerCallback();
callback.onFailure(new Exception());
verify(view).onFailure(PresenterImpl.REMOTE_PROCEDURE_CALL_FAILURE);
}
}
