Thursday, May 1, 2014

Presenter First: An Overview

Writing GUI applications can be difficult. It's easier to understand the flow of a command-line program--you start at the top and go to the bottom. But the flow of a GUI application, with its listeners, event handlers, and callbacks, goes all over the place. Add database queries and network calls to the mix, and things get even more complicated.

Enter MVP

The Model-View-Presenter (MVP) pattern helps to manage this complexity. MVP belongs to that family of design patterns that separates the application data and logic from the way in which the information is displayed to the user. To summarize:

  • The Model is responsible for maintaining the application's raw data (typically by persisting it in a database).
  • The View is responsible for presenting the data to the user (for example, in the form of a webpage or dialog box).
  • And the Presenter is responsible for tying the model and view together. In MVP, the model and view know nothing of each other!

Presenter First

The idea with "Presenter First" is that, using the MVP pattern, you start by writing the presenter class before anything else. This forces you to think abstractly about how your dialog window is going to behave. And, in the process of writing the presenter, you naturally figure out what functionalities the model and view will need to support. So, writing the model and view becomes just a matter of implementing an interface.

Another benefit to Presenter First is that it allows you to unit test your dialog's application logic. This is because the model and view are represented as interfaces, which can be easily mocked-out in the unit tests.

To summarize, the three benefits of Presenter First are:

  1. By using MVP, the view is cleanly separated from the application data and logic. In other words, your JFrame and JDialog classes become truly "dumb"--they contain no database calls or application logic.
  2. In the process of writing the presenter, the APIs for the model and view are essentially written automatically.
  3. The application logic of your dialog is finally unit-testable!

Example

As an example, let's create a simple login dialog. This dialog will ask the user for a username and password. If the credentials are valid, then a session token will be returned and the dialog will close. The user can also choose to have the application remember his username and password.

We start by writing the presenter class.

LoginPresenter.java

import java.awt.event.*;

public class LoginPresenter{
    private final ILoginView view;
    private final ILoginModel model;

    public LoginPresenter(ILoginView view, ILoginModel model){
        this.view = view;
        this.model = model;

        //invoked when the user clicks "Login"
        view.addLoginListener(new ActionListener(){
            @Override
            public void actionPerformed(ActionEvent event){
                onLogin();
            }
        });

        //invoked when the user clicks "Cancel" or closes the window
        view.addCancelListener(new ActionListener(){
            @Override
            public void actionPerformed(ActionEvent event){
                onCancel();
            }
        });

        //populate the dialog with its initial data
        view.setUsername(model.getCachedUsername());
        view.setPassword(model.getCachedPassword());
        view.setRememberMe(model.getCachedRememberMe());

        //finally, display the dialog
        view.display();
    }

    private void onLogin(){
        //get the data that the user entered
        String username = view.getUsername();
        String password = view.getPassword();
        boolean rememberMe = view.getRememberMe();

        //send the network call to log the user in
        String session = model.login(username, password);

        if (session == null){
            //credentials were bad, so show an error dialog to the user
            view.onBadLogin();
            return;
        }

        //persist the login credentials if "remember me" is checked
        if (rememberMe){
           model.setCachedUsername(username);
           model.setCachedPassword(password);
        } else {
           model.setCachedUsername("");
           model.setCachedPassword("");
        }

        model.setCachedRememberMe(rememberMe);
        model.setSession(session);

        view.onSuccessfulLogin();
        view.close();
    }

    private void onCancel(){
        view.close();
    }
}

The constructor adds event handlers which will fire with the user presses the "Login" and "Cancel" buttons. Then, it populates the view with data from the model (in this case, the saved username and password). The "onLogin()" method contains logic which determines if the login was successful or not and acts accordingly.

Now that our presenter is written, we can write the model and view interfaces, which allows the presenter class to compile.

ILoginModel.java

public interface ILoginModel{
    String login(String username, String password);

    String getCachedUsername();
    void setCachedUsername(String username);
    String getCachedPassword();
    void setCachedPassword(String password);
    boolean getCachedRememberMe();
    void setCachedRememberMe(boolean rememberMe);
    String getSession();
    void setSession(String session);
}

ILoginView.java

import java.awt.event.*;

public interface ILoginView{
    void addLoginListener(ActionListener listener);
    void addCancelListener(ActionListener listener);

    String getUsername();
    void setUsername(String username);
    String getPassword();
    void setPassword(String password);
    boolean getRememberMe();
    void setRememberMe(boolean rememberMe);

    void onBadLogin();
    void onSuccessfulLogin();

    void display();
    void close();
}

Next, we write our tests! Using a stubbing framework like Mockito helps, but it's not required (you could always create your own test implementations of the model and view interfaces).

LoginPresenterTest.java

import java.awt.event.*;
import java.util.*;
import org.junit.*;
import org.mockito.invocation.*;
import org.mockito.stubbing.*;
import static org.mockito.Mockito.*;

public class LoginPresenterTest{
    @Test
    public void init(){
        ILoginView view = mock(ILoginView.class);

        ILoginModel model = mock(ILoginModel.class);
        when(model.getCachedUsername()).thenReturn("user");
        when(model.getCachedPassword()).thenReturn("password");
        when(model.getCachedRememberMe()).thenReturn(true);

        LoginPresenter presenter = new LoginPresenter(view, model);

        verify(view).addLoginListener(any(ActionListener.class));
        verify(view).addCancelListener(any(ActionListener.class));
        verify(view).setUsername("user");
        verify(view).setPassword("password");
        verify(view).setRememberMe(true);
        verify(view).display();
    }

    @Test
    public void bad_login(){
        ILoginView view = mock(ILoginView.class);
        when(view.getUsername()).thenReturn("user");
        when(view.getPassword()).thenReturn("password");
        ListenerAnswer loginAnswer = new ListenerAnswer();
        doAnswer(loginAnswer).when(view).addLoginListener(any(ActionListener.class));

        ILoginModel model = mock(ILoginModel.class);
        when(model.login("user", "password")).thenReturn(null); //"null" = bad login

        LoginPresenter presenter = new LoginPresenter(view, model);

        //click "login"
        loginAnswer.fire();

        verify(model, never()).setSession(anyString());
        verify(view, never()).onSuccessfulLogin();
        verify(view).onBadLogin();
        verify(view, never()).close();
    }

    @Test
    public void valid_login(){
        ILoginView view = mock(ILoginView.class);
        when(view.getUsername()).thenReturn("user");
        when(view.getPassword()).thenReturn("password");
        ListenerAnswer loginAnswer = new ListenerAnswer();
        doAnswer(loginAnswer).when(view).addLoginListener(any(ActionListener.class));

        ILoginModel model = mock(ILoginModel.class);
        when(model.login("user", "password")).thenReturn("abc123"); //non-null token = good login

        LoginPresenter presenter = new LoginPresenter(view, model);

        //click "login"
        loginAnswer.fire();

        verify(model).setSession("abc123");
        verify(view, never()).onBadLogin();
        verify(view).onSuccessfulLogin();
        verify(view).close();
    }

    @Test
    public void rememberMe_true(){
        ILoginView view = mock(ILoginView.class);
        when(view.getUsername()).thenReturn("user");
        when(view.getPassword()).thenReturn("password");
        when(view.getRememberMe()).thenReturn(true);
        ListenerAnswer loginAnswer = new ListenerAnswer();
        doAnswer(loginAnswer).when(view).addLoginListener(any(ActionListener.class));

        ILoginModel model = mock(ILoginModel.class);
        when(model.login("user", "password")).thenReturn("abc123");

        LoginPresenter presenter = new LoginPresenter(view, model);

        //click "login"
        loginAnswer.fire();

        verify(model).setCachedUsername("user");
        verify(model).setCachedPassword("password");
        verify(model).setCachedRememberMe(true);
    }

    @Test
    public void rememberMe_false(){
        ILoginView view = mock(ILoginView.class);
        when(view.getUsername()).thenReturn("user");
        when(view.getPassword()).thenReturn("password");
        when(view.getRememberMe()).thenReturn(false);
        ListenerAnswer loginAnswer = new ListenerAnswer();
        doAnswer(loginAnswer).when(view).addLoginListener(any(ActionListener.class));

        ILoginModel model = mock(ILoginModel.class);
        when(model.login("user", "password")).thenReturn("abc123");

        LoginPresenter presenter = new LoginPresenter(view, model);

        //click "login"
        loginAnswer.fire();

        verify(model).setCachedUsername("");
        verify(model).setCachedPassword("");
        verify(model).setCachedRememberMe(false);
    }

    @Test
    public void cancel(){
        ILoginView view = mock(ILoginView.class);
        ListenerAnswer cancelAnswer = new ListenerAnswer();
        doAnswer(cancelAnswer).when(view).addCancelListener(any(ActionListener.class));

        ILoginModel model = mock(ILoginModel.class);

        LoginPresenter presenter = new LoginPresenter(view, model);

        //click "cancel"
        cancelAnswer.fire();

        verify(model, never()).setSession(anyString());
        verify(view).close();
    }

    private class ListenerAnswer implements Answer<Object>{
        private final List<ActionListener> listeners = new ArrayList<ActionListener>();

        public Object answer(InvocationOnMock invocation) {
            ActionListener listener = (ActionListener)invocation.getArguments()[0];
            listeners.add(listener);
            return null;
        }

        public void fire(){
            for (ActionListener listener : listeners){
                listener.actionPerformed(null);
            }
        }
    }
}

Once our tests pass, we can write the real implementations of the model and view interfaces. Again, this is basically just a matter of creating a new class and having that class implement the interface.

LoginViewImpl.java

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import static javax.swing.SpringLayout.*;

public class LoginViewImpl extends JFrame implements ILoginView {
    private final JButton login, cancel;
    private final JTextField username;
    private final JPasswordField password;
    private final JCheckBox rememberMe;

    public LoginViewImpl() {
        setTitle("Login");
        setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);

        login = new JButton("Login");
        cancel = new JButton("Cancel");
        username = new JTextField();
        password = new JPasswordField();
        rememberMe = new JCheckBox("Remember me");

        JLabel title = new JLabel("Please enter your login credentials.");
        JLabel usernameLabel = new JLabel("Username:");
        JLabel passwordLabel = new JLabel("Password:");

        ///////////////////////

        Container contentPane = getContentPane();
        SpringLayout layout = new SpringLayout();
        contentPane.setLayout(layout);

        contentPane.add(title);
        contentPane.add(usernameLabel);
        contentPane.add(username);
        contentPane.add(passwordLabel);
        contentPane.add(password);
        contentPane.add(rememberMe);
        contentPane.add(login);
        contentPane.add(cancel);

        layout.putConstraint(WEST, title, 5, WEST, contentPane);
        layout.putConstraint(NORTH, title, 5, NORTH, contentPane);

        layout.putConstraint(WEST, usernameLabel, 5, WEST, contentPane);
        layout.putConstraint(NORTH, usernameLabel, 10, SOUTH, title);
        layout.putConstraint(WEST, username, 10, EAST, usernameLabel);
        layout.putConstraint(NORTH, username, 0, NORTH, usernameLabel);
        layout.putConstraint(EAST, username, 100, WEST, username);

        layout.putConstraint(WEST, passwordLabel, 5, WEST, contentPane);
        layout.putConstraint(NORTH, passwordLabel, 5, SOUTH, usernameLabel);
        layout.putConstraint(WEST, password, 0, WEST, username);
        layout.putConstraint(NORTH, password, 0, NORTH, passwordLabel);
        layout.putConstraint(EAST, password, 100, WEST, password);

        layout.putConstraint(WEST, rememberMe, 5, WEST, contentPane);
        layout.putConstraint(NORTH, rememberMe, 5, SOUTH, passwordLabel);

        layout.putConstraint(WEST, login, 5, WEST, contentPane);
        layout.putConstraint(NORTH, login, 10, SOUTH, rememberMe);
        layout.putConstraint(WEST, cancel, 5, EAST, login);
        layout.putConstraint(NORTH, cancel, 0, NORTH, login);

        setSize(300,200);
        setLocationRelativeTo(null);
    }

    public void addLoginListener(ActionListener listener) {
        login.addActionListener(listener);
        username.addActionListener(listener);
        password.addActionListener(listener);
    }

    public void addCancelListener(final ActionListener listener) {
        cancel.addActionListener(listener);
        addWindowListener(new WindowAdapter(){
            public void windowClosing(WindowEvent event){
                listener.actionPerformed(null);
            }
        });
    }

    public String getUsername() {
        return username.getText();
    }

    public void setUsername(String username) {
        this.username.setText(username);
    }

    public String getPassword() {
        return new String(password.getPassword());
    }

    public void setPassword(String password){
        this.password.setText(password);
    }

    public boolean getRememberMe() {
        return rememberMe.isSelected();
    }

    public void setRememberMe(boolean rememberMe) {
        this.rememberMe.setSelected(rememberMe);
    }

    public void onBadLogin() {
        JOptionPane.showMessageDialog(this, "Invalid login credentials.");
    }

    public void onSuccessfulLogin() {
        JOptionPane.showMessageDialog(this, "Login successful.");
    }

    public void display() {
        setVisible(true);
    }

    public void close() {
        dispose();
    }
}

LoginModelImpl.java

import java.io.*;
import java.util.*;

public class LoginModelImpl implements ILoginModel{
    private final File file;
    private final Properties properties;
    private String session;

    public LoginModelImpl(File file) throws IOException{
        this.file = file;
        this.properties = new Properties();

        if (file.exists()){
            this.properties.load(new FileReader(file));
        }
    }

    public String login(String username, String password){
        //normally, a network or database call would be made here
        if ("test".equals(username) && "test".equals(password)){
            return "abc123";
        }
        return null;
    }

    public String getCachedUsername(){
        return properties.getProperty("username");
    }

    public void setCachedUsername(String username){
        properties.setProperty("username", username);
        save();
    }

    public String getCachedPassword(){
        return properties.getProperty("password");
    }

    public void setCachedPassword(String password){
        properties.setProperty("password", password);
        save();
    }

    public boolean getCachedRememberMe(){
        String value = properties.getProperty("rememberMe");
        return (value == null) ? false : Boolean.parseBoolean(value);
    }

    public void setCachedRememberMe(boolean rememberMe){
        properties.setProperty("rememberMe", rememberMe + "");
        save();
    }

    public String getSession(){
        return session;
    }

    public void setSession(String session){
        this.session = session;
    }

    private void save() {
        try {
            properties.store(new FileWriter(file), "");
        } catch (IOException e){
            throw new RuntimeException(e);
        }
    }
}

To run our program, we simply create a new instance of LoginPresenter, passing in the model and view implementations that we created above.

Main.java

import java.io.*;

public class Main{
    public static void main(String args[]) throws Throwable {
        File cache = new File("cache.properties");

        ILoginModel model = new LoginModelImpl(cache);
        ILoginView view = new LoginViewImpl();
        new LoginPresenter(view, model);
    }
}

And that's all there is to it!

Download the source code

References:

1 comment:

Anonymous said...

I am missing ListenerAnswer - where is the class? Just started with MVP, sounds interesting! Simple steps to reproduce, thanks for that!