Sunday 16 June 2013

Make your Gadgeteer app testable with the MVP pattern

As Gadgeteer is very much about hardware, it's not always obvious how to write unit tests for the code. This post will go through the mechanics of the MVP pattern. The example app cconsists of a button and a multicolour LED; when the button is pressed, the LED displays a random colour. If you don't believe me, there's a video.

The MVP pattern

The Model-View-Presenter (MVP) pattern is designed to split the UI (View) from the business logic (Model) via a class that sends messages between the two (Presenter). In the implementation I use (Passive View) the view has little or, if possible, no logic. When an event occurs (e.g. a button press), the view raises an event which is handled by the presenter. The presenter then invokes a void method on the model. If required, the model will raise an event to signal that the work is completed. The presenter handles that event too, and calls a method on the view to cause a UI update to occur.

Here's the sequence that occurs in the example app:

The Classes

The solution consists of three projects: the Gadgeteer project, the class library and the test project.
The class library defines the classes and interfaces required to implement the MVP pattern - the model, the presenter and the view interface.
The IView interface declares an event ButtonPressed, to be handled by the presenter, and a method ShowColour that will be called by the presenter:

using Microsoft.SPOT;

namespace ButtonAndLight.Lib
{
    public delegate void ButtonPressedEventHandler(object sender, EventArgs args);

    public interface IView
    {
        // The event that the UI will raise when the button is pressed
        event ButtonPressedEventHandler ButtonPressed;
        
        //The method the presenter will call when the colour needs to change
        void ShowColour(byte r, byte g, byte b);
    }    
}

The Model class declares an event ColourChanged, to be handled by the presenter, and a method CalculateNewColour, to be called by the presenter:
using Microsoft.SPOT;

namespace ButtonAndLight.Lib
{
    public class Model
    {
        public Model()
            : this(new RandomGenerator())
        { }

        public Model(IRandomGenerator randomGenerator)
        {
            _randomGenerator = randomGenerator;
        }

        private IRandomGenerator _randomGenerator;

        // Old school event handler for the Micro Framework
        public delegate void ColourChangedEventHandler(object sender, ColourChangedEventArgs args);

        // The event that will be raised when the colour has changed
        public event ColourChangedEventHandler ColourChanged;

        // This will be called by the presenter
        public void CalculateNewColour()
        {
            var r = _randomGenerator.GetNextColourPart();
            var g = _randomGenerator.GetNextColourPart();
            var b = _randomGenerator.GetNextColourPart();

            OnColourChanged(r, g, b);
        }

        private void OnColourChanged(byte r, byte g, byte b)
        {
            if (ColourChanged != null)
            {
                ColourChanged(this, new ColourChangedEventArgs(r, g, b));
            }
        }
    }

    public class ColourChangedEventArgs : EventArgs
    {
        public byte R { get; private set; }
        public byte G { get; private set; }
        public byte B { get; private set; }
        public ColourChangedEventArgs(byte r, byte g, byte b)
        {
            R = r;
            G = g;
            B = b;
        }
    }
}
The Presenter class stitches the model and view together, by calling the relevant method when an event occurs:
using Microsoft.SPOT;

namespace ButtonAndLight.Lib
{
    public class Presenter
    {
        IView _view;
        Model _model;

        public Presenter(IView view, Model model)
        {
            _view = view;
            _model = model;

            _view.ButtonPressed += new ButtonPressedEventHandler(view_ButtonPressed);
            _model.ColourChanged += new Model.ColourChangedEventHandler(model_ColourChanged);
        }

        void model_ColourChanged(object sender, ColourChangedEventArgs args)
        {
            _view.ShowColour(args.R, args.G, args.B);
        }

        void view_ButtonPressed(object sender, EventArgs args)
        {
            _model.CalculateNewColour();
        }
    }
}
These are the Gadgeteer components:

The program class in the Gadgeteer project needs to implement the IView interface, so it can pass a reference to itself to the presenter. In this example I've also made it responsible for building the presenter:

using ButtonAndLight.Lib;
using Microsoft.SPOT.Presentation.Media;
using Microsoft.SPOT;
using Gadgeteer.Modules.GHIElectronics;

namespace ButtonAndLight
{
    public partial class Program : IView
    {
        private Presenter _presenter;

        void ProgramStarted()
        {
            _presenter = new Presenter(this, new Model());

            // Wire up handler for the the physical button press
            button.ButtonPressed += new Button.ButtonEventHandler(button_ButtonPressed);
        }

        void button_ButtonPressed(Button sender, Button.ButtonState state)
        {
            if (ButtonPressed != null)
            {
                ButtonPressed(this, EventArgs.Empty);
            }
        }

        public event ButtonPressedEventHandler ButtonPressed;

        public void ShowColour(byte r, byte g, byte b)
        {
            var colour = ColorUtility.ColorFromRGB(r, g, b);
            multicolorLed.TurnColor(colour);
        }
    }
}
All that's left now is the test class. As this is the Micro Framework, I've had to do by hand what I'd normally have done with Rhino and NUnit
using System;
using Microsoft.SPOT;

namespace ButtonAndLight.Lib.Test
{
    public class Program
    {
        public static void Main()
        {
            ButtonPressed_ExpectRandomColourGenerated();
        }

        private static void ButtonPressed_ExpectRandomColourGenerated()
        {
            // Create objects
            var view = new FakeView();
            var model = new Model(new FakeRandomGenerator());
            var presenter = new Presenter(view, model);

            // Raise event
            view.RaiseButtonPressEvent();

            // Check result
            if (view.R == 10 && view.G == 20 && view.B == 30)
            {
                Debug.Print("Success");
                return;
            }

            throw new InvalidOperationException("Failure");
        }
    }

    public class FakeView : IView
    {
        // Sensing variables
        public byte R { get; private set; }
        public byte G { get; private set; }
        public byte B { get; private set; }

        // Pretend a button's been pushed
        public void RaiseButtonPressEvent()
        {
            if (ButtonPressed != null)
            {
                ButtonPressed(this, EventArgs.Empty);
            }
        }

        #region IView interface
        public event ButtonPressedEventHandler ButtonPressed;

        public void ShowColour(byte r, byte g, byte b)
        {
            R = r;
            G = g;
            B = b;
        }
        #endregion
    }

    public class FakeRandomGenerator : IRandomGenerator
    {
        byte[] _values = new byte[] { 10, 20, 30 };
        int _valuePointer = 0;

        public byte GetNextColourPart()
        {
            return _values[_valuePointer++];
        }
    }
}
Here's a video of the hardware in action:
The code for this example can be found on GitHub at https://github.com/orangutanboy/MVPExample/

No comments:

Post a Comment